feat(chat): scope agent to current organization

This commit is contained in:
-Puter
2026-07-23 16:31:31 +05:30
parent 535e9dde62
commit 5d5139dc95
11 changed files with 1084 additions and 11 deletions

View File

@@ -0,0 +1,90 @@
/**
* Targeted runtime smoke for the authenticated conversation ingress.
*
* Simulates the exact sequence the Flue route middleware drives, end to end,
* against the real Convex schema and modules via convexTest:
*
* 1. ensurePersonalOrganization (bearer auth → org resolution)
* 2. beginUserMessage (evidence before Flue admission)
* 3. markAdmitted (Flue receipt submission id)
*
* Then verifies the stored normalized evidence has exact raw text, organization
* scope, conversation/message identity, request id, role user, and the admitted
* submission id.
*
* This is a smoke, not a unit test: delete after the live browser smoke is
* confirmed.
*/
import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|smoke-user";
const identityA = { tokenIdentifier: ID_A };
const newTest = () => convexTest({ schema, modules });
describe("conversation ingress smoke", () => {
test("one user message produces normalized admitted evidence end to end", async () => {
const t = newTest();
// 1. Auth + org resolution (the middleware resolves this from the bearer
// token via a request-scoped ConvexHttpClient).
const org = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const rawText = " Fix the login bug \n\t please ";
const clientRequestId = "smoke-req-001";
// 2. Begin evidence before Flue admission.
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: org._id,
rawText,
});
expect(begun.status).toBe("admitting");
expect(begun.rawText).toBe(rawText);
// 3. Mark admitted with the Flue receipt submission id.
const submissionId = "flue-sub-abc123";
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: org._id,
submissionId,
});
// 4. Verify normalized evidence via authorized observation.
const observed = await t
.withIdentity(identityA)
.query(api.conversationMessages.getByRequest, {
clientRequestId,
organizationId: org._id,
});
expect(observed).not.toBeNull();
expect(observed?.rawText).toBe(rawText);
expect(observed?.organizationId).toBe(org._id);
expect(observed?.conversationId).toBe(org._id);
expect(typeof observed?.messageId).toBe("string");
expect(observed?.clientRequestId).toBe(clientRequestId);
expect(observed?.role).toBe("user");
expect(observed?.status).toBe("admitted");
expect(observed?.submissionId).toBe(submissionId);
});
});

View File

@@ -0,0 +1,300 @@
import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { requireOrganizationMember } from "./authz";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|user-a";
const ID_B = "https://convex.test|user-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const newTest = () => convexTest({ schema, modules });
const ensureOrg = async (
t: ReturnType<typeof newTest>,
identity: { readonly tokenIdentifier: string }
) => {
const org = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
return org._id as string;
};
describe("conversationMessages", () => {
test("unauthenticated access is rejected", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
await expect(
t.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-1",
organizationId: orgId,
rawText: "hello",
})
).rejects.toThrow(/Authentication required/u);
await expect(
t.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-1",
organizationId: orgId,
submissionId: "sub-1",
})
).rejects.toThrow(/Authentication required/u);
await expect(
t.query(api.conversationMessages.getByRequest, {
clientRequestId: "req-1",
organizationId: orgId,
})
).rejects.toThrow(/Authentication required/u);
});
test("cross-organization access is denied for begin and observation", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const _orgB = await ensureOrg(t, identityB);
// User A captures a message in its own organization.
const created = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-cross",
organizationId: orgA,
rawText: "private to org A",
});
// User B cannot begin a message under organization A.
await expect(
t
.withIdentity(identityB)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-cross",
organizationId: orgA,
rawText: "intruder",
})
).rejects.toThrow(/Organization membership required/u);
// User B cannot observe organization A's message by request id.
await expect(
t.withIdentity(identityB).query(api.conversationMessages.getByRequest, {
clientRequestId: "req-cross",
organizationId: orgA,
})
).rejects.toThrow(/Organization membership required/u);
// User B cannot list organization A's conversation.
await expect(
t
.withIdentity(identityB)
.query(api.conversationMessages.listForCurrentOrganization, {
organizationId: orgA,
})
).rejects.toThrow(/Organization membership required/u);
// The membership boundary independently denies cross-org access.
await expect(
t
.withIdentity(identityB)
.query((ctx) => requireOrganizationMember(ctx, orgA as never))
).rejects.toThrow(/Organization membership required/u);
expect(created.status).toBe("admitting");
});
test("duplicate request id is idempotent and preserves exact whitespace and casing", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const rawText = " Hello WORLD \n\t with spaces ";
const first = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-dup",
organizationId: orgId,
rawText,
});
// A second begin with the same request id returns the same row — no
// duplicate is created.
const second = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-dup",
organizationId: orgId,
rawText: "DIFFERENT TEXT MUST BE IGNORED",
});
expect(second._id).toBe(first._id);
expect(second.messageId).toBe(first.messageId);
expect(second.rawText).toBe(rawText);
expect(second.clientRequestId).toBe("req-dup");
expect(second.role).toBe("user");
expect(second.status).toBe("admitting");
// The conversation id equals the organization id (global agent scope).
expect(first.conversationId).toBe(orgId);
});
test("admitted transition records the submission id", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-admit",
organizationId: orgId,
rawText: "please help",
});
expect(begun.status).toBe("admitting");
expect(begun.submissionId).toBeNull();
const admitted = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-admit",
organizationId: orgId,
submissionId: "sub-receipt-123",
});
expect(admitted.status).toBe("admitted");
expect(admitted.submissionId).toBe("sub-receipt-123");
// Marking admitted again is idempotent and keeps the same submission id.
const again = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-admit",
organizationId: orgId,
submissionId: "sub-receipt-123",
});
expect(again.status).toBe("admitted");
expect(again.submissionId).toBe("sub-receipt-123");
});
test("failed transition records the failed status and preserves evidence", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-fail",
organizationId: orgId,
rawText: "doomed message",
});
const failed = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markFailed, {
clientRequestId: "req-fail",
organizationId: orgId,
});
expect(failed.status).toBe("failed");
expect(failed.submissionId).toBeNull();
expect(failed.rawText).toBe("doomed message");
// A failed message is still observable as evidence.
const observed = await t
.withIdentity(identityA)
.query(api.conversationMessages.getByRequest, {
clientRequestId: "req-fail",
organizationId: orgId,
});
expect(observed?.status).toBe("failed");
expect(observed?.rawText).toBe("doomed message");
});
test("admission wins over a concurrent failure and cannot be overwritten", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-race",
organizationId: orgId,
rawText: "race",
});
// Admission completes first.
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-race",
organizationId: orgId,
submissionId: "sub-won",
});
// A late failure is a no-op: an admitted message keeps its submission id.
const afterFail = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markFailed, {
clientRequestId: "req-race",
organizationId: orgId,
});
expect(afterFail.status).toBe("admitted");
expect(afterFail.submissionId).toBe("sub-won");
expect(begun.status).toBe("admitting");
});
test("list returns only the authenticated organization's messages", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const orgB = await ensureOrg(t, identityB);
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-a1",
organizationId: orgA,
rawText: "a-one",
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-a2",
organizationId: orgA,
rawText: "a-two",
});
await t
.withIdentity(identityB)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-b1",
organizationId: orgB,
rawText: "b-one",
});
const forA = await t
.withIdentity(identityA)
.query(api.conversationMessages.listForCurrentOrganization, {
organizationId: orgA,
});
const forB = await t
.withIdentity(identityB)
.query(api.conversationMessages.listForCurrentOrganization, {
organizationId: orgB,
});
expect(forA).toHaveLength(2);
expect(
forA.map((m: { clientRequestId: string }) => m.clientRequestId)
).toEqual(expect.arrayContaining(["req-a1", "req-a2"]));
expect(forB).toHaveLength(1);
expect(forB[0]?.clientRequestId).toBe("req-b1");
});
});

View File

@@ -0,0 +1,220 @@
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import { requireOrganizationMember } from "./authz";
export type ConversationMessageStatus = "admitting" | "admitted" | "failed";
interface ConversationMessageView {
readonly _id: Id<"conversationMessages">;
readonly _creationTime: number;
readonly organizationId: Id<"organizations">;
readonly conversationId: string;
readonly messageId: string;
readonly submissionId: string | null;
readonly clientRequestId: string;
readonly role: "user";
readonly rawText: string;
readonly status: ConversationMessageStatus;
readonly createdAt: number;
}
const toView = (doc: Doc<"conversationMessages">): ConversationMessageView => ({
_creationTime: doc._creationTime,
_id: doc._id,
clientRequestId: doc.clientRequestId,
conversationId: doc.conversationId,
createdAt: doc.createdAt,
messageId: doc.messageId,
organizationId: doc.organizationId,
rawText: doc.rawText,
role: doc.role,
status: doc.status,
submissionId: doc.submissionId ?? null,
});
/**
* The global Zopu conversation uses the organization ID as its conversation
* identity (and agent instance ID). This resolves the conversation ID for the
* authenticated user's current personal organization, so callers never supply
* tenancy themselves.
*/
const resolveConversationId = (organizationId: Id<"organizations">): string =>
organizationId;
/**
* Idempotently begin a user message at the conversation ingress. If a row with
* the same (organizationId, clientRequestId) already exists, return it instead
* of creating a duplicate — Flue retries that replay the same request id must
* not produce duplicate evidence. The raw text is stored verbatim; it is never
* trimmed, collapsed, or rewritten.
*
* Authorization: the authenticated identity must be a member of the target
* organization. The conversation ID, message ID, and timestamp are all
* generated server-side; none are trusted from the caller.
*/
export const beginUserMessage = mutation({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
rawText: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
if (existing) {
return toView(existing);
}
const createdAt = Date.now();
const messageId = crypto.randomUUID();
const conversationId = resolveConversationId(args.organizationId);
const rowId = await ctx.db.insert("conversationMessages", {
clientRequestId: args.clientRequestId,
conversationId,
createdAt,
messageId,
organizationId: args.organizationId,
rawText: args.rawText,
role: "user",
status: "admitting",
});
const created = await ctx.db.get(rowId);
if (!created) {
throw new Error("Failed to read created conversation message");
}
return toView(created);
},
});
/**
* Mark a begun message as admitted after Flue accepts the submission. The
* submission id links the normalized evidence to the Flue transcript. Only an
* `admitting` message may transition to `admitted`; an already-admitted or
* failed message is returned unchanged so a retry is idempotent.
*/
export const markAdmitted = mutation({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
submissionId: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
if (!existing) {
throw new ConvexError("Conversation message not found");
}
if (existing.status === "admitting") {
await ctx.db.patch(existing._id, {
status: "admitted",
submissionId: args.submissionId,
});
}
const updated = await ctx.db.get(existing._id);
if (!updated) {
throw new Error("Failed to read updated conversation message");
}
return toView(updated);
},
});
/**
* Mark a begun message as failed when Flue admission rejects it. Failed
* messages remain as evidence with an explicit status; they are never silently
* deleted. Only an `admitting` message may transition to `failed`; an
* already-admitted message is never overwritten (admission won), and an
* already-failed message is returned unchanged.
*/
export const markFailed = mutation({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
if (!existing) {
throw new ConvexError("Conversation message not found");
}
if (existing.status === "admitting") {
await ctx.db.patch(existing._id, { status: "failed" });
}
const updated = await ctx.db.get(existing._id);
if (!updated) {
throw new Error("Failed to read updated conversation message");
}
return toView(updated);
},
});
/**
* Read a single message by its client request id. Authorization requires
* membership in the message's organization; cross-organization observation is
* denied. Returns null when no message matches so observers cannot probe for
* another organization's request ids.
*/
export const getByRequest = query({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView | null> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
return existing ? toView(existing) : null;
},
});
/**
* List the user messages for the authenticated user's current organization
* conversation, newest first. This is the authorized observation path used by
* GET/SSE routes and product clients. Cross-organization access is denied.
*/
export const listForCurrentOrganization = query({
args: {
organizationId: v.id("organizations"),
},
handler: async (ctx, args): Promise<ConversationMessageView[]> => {
await requireOrganizationMember(ctx, args.organizationId);
const conversationId = resolveConversationId(args.organizationId);
const rows = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
)
.order("desc")
.take(100);
return rows.map(toView);
},
});

View File

@@ -167,6 +167,38 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
// -----------------------------------------------------------------
// Normalized conversation message evidence. Each row is one user
// message captured at the conversation ingress, before/with Flue
// admission. The raw text is stored exactly as the user typed it
// (never trimmed or rewritten). Organization scope is authoritative;
// an agent instance may only observe its own organization's messages.
// -----------------------------------------------------------------
conversationMessages: defineTable({
organizationId: v.id("organizations"),
conversationId: v.string(),
messageId: v.string(),
submissionId: v.optional(v.string()),
clientRequestId: v.string(),
role: v.literal("user"),
rawText: v.string(),
status: v.union(
v.literal("admitting"),
v.literal("admitted"),
v.literal("failed")
),
createdAt: v.number(),
})
.index("by_organization_and_message", [
"organizationId",
"conversationId",
"messageId",
])
.index("by_organization_and_request", [
"organizationId",
"clientRequestId",
]),
// -----------------------------------------------------------------
// Flue persistence stores (schema/format version 4).
// -----------------------------------------------------------------