mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
added e2e tests
This commit is contained in:
parent
30e3e5c803
commit
edd33b1e14
@ -1570,6 +1570,30 @@ export namespace User {
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
export async function setClientReadOnlyMetadata(userId: string, metadata: Record<string, unknown>) {
|
||||
const response = await niceBackendFetch(`/api/v1/users/${userId}`, {
|
||||
method: "PATCH",
|
||||
accessType: "server",
|
||||
body: {
|
||||
client_read_only_metadata: metadata,
|
||||
},
|
||||
});
|
||||
expect(response).toMatchObject({ status: 200 });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace AiChatReviewer {
|
||||
export async function createReviewer() {
|
||||
const { userId, accessToken, refreshToken } = await Auth.fastSignUp();
|
||||
await User.setClientReadOnlyMetadata(userId, { isAiChatReviewer: true });
|
||||
return { userId, accessToken, refreshToken };
|
||||
}
|
||||
|
||||
export async function createNonReviewer() {
|
||||
return await Auth.fastSignUp();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import { it } from "../../../../../helpers";
|
||||
import { AiChatReviewer, niceBackendFetch } from "../../../../backend-helpers";
|
||||
|
||||
// Every mcp-review endpoint shares the same auth gate (isAiChatReviewer metadata check)
|
||||
// and the same short-circuit order: auth → metadata → yup → reducer. Tests here cover
|
||||
// the first three.
|
||||
const endpoints = [
|
||||
{
|
||||
path: "/api/latest/internal/mcp-review/mark-reviewed",
|
||||
validBody: { correlationId: "abc123" },
|
||||
invalidBody: {},
|
||||
},
|
||||
{
|
||||
path: "/api/latest/internal/mcp-review/unmark-reviewed",
|
||||
validBody: { correlationId: "abc123" },
|
||||
invalidBody: {},
|
||||
},
|
||||
{
|
||||
path: "/api/latest/internal/mcp-review/update-correction",
|
||||
validBody: {
|
||||
correlationId: "abc123",
|
||||
correctedQuestion: "q",
|
||||
correctedAnswer: "a",
|
||||
publish: false,
|
||||
},
|
||||
invalidBody: { correlationId: "abc123", publish: "yes" as unknown as boolean },
|
||||
},
|
||||
{
|
||||
path: "/api/latest/internal/mcp-review/add-manual",
|
||||
validBody: { question: "q", answer: "a", publish: false },
|
||||
invalidBody: { question: "q" },
|
||||
},
|
||||
{
|
||||
path: "/api/latest/internal/mcp-review/delete",
|
||||
validBody: { correlationId: "abc123" },
|
||||
invalidBody: {},
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const { path, validBody, invalidBody } of endpoints) {
|
||||
it(`${path}: rejects unauthenticated requests`, async ({ expect }) => {
|
||||
const response = await niceBackendFetch(path, {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: validBody,
|
||||
});
|
||||
// yup schema on createSmartRouteHandler requires auth.user; missing auth fails
|
||||
// shape validation as 400 before reaching the handler's auth-specific error path.
|
||||
expect([400, 401]).toContain(response.status);
|
||||
});
|
||||
|
||||
it(`${path}: rejects a signed-in user without isAiChatReviewer metadata`, async ({ expect }) => {
|
||||
await AiChatReviewer.createNonReviewer();
|
||||
const response = await niceBackendFetch(path, {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: validBody,
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(String(response.body)).toContain("not approved to perform MCP review operations");
|
||||
});
|
||||
|
||||
it(`${path}: rejects a reviewer sending an invalid body`, async ({ expect }) => {
|
||||
await AiChatReviewer.createReviewer();
|
||||
const response = await niceBackendFetch(path, {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: invalidBody,
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import { it } from "../../../../../helpers";
|
||||
import { AiChatReviewer, niceBackendFetch } from "../../../../backend-helpers";
|
||||
|
||||
const VALID_HEX = "a".repeat(64);
|
||||
|
||||
it("rejects unauthenticated requests", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: VALID_HEX },
|
||||
});
|
||||
// createSmartRouteHandler's yup schema requires auth.user; missing auth trips
|
||||
// schema validation before the handler runs, so this comes back as 400, not 401.
|
||||
expect([400, 401]).toContain(response.status);
|
||||
});
|
||||
|
||||
it("rejects a signed-in user without isAiChatReviewer metadata", async ({ expect }) => {
|
||||
await AiChatReviewer.createNonReviewer();
|
||||
const response = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: VALID_HEX },
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(String(response.body)).toContain("not approved to perform MCP review operations");
|
||||
});
|
||||
|
||||
it("rejects a reviewer sending a non-hex identity", async ({ expect }) => {
|
||||
await AiChatReviewer.createReviewer();
|
||||
const response = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: "not-a-hex-identity" },
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(String(response.body)).toContain("Invalid identity");
|
||||
});
|
||||
|
||||
it("rejects a reviewer sending a hex identity of the wrong length", async ({ expect }) => {
|
||||
await AiChatReviewer.createReviewer();
|
||||
const response = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: "a".repeat(63) },
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(String(response.body)).toContain("Invalid identity");
|
||||
});
|
||||
|
||||
it("rejects a reviewer sending a request without an identity field", async ({ expect }) => {
|
||||
await AiChatReviewer.createReviewer();
|
||||
const response = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {},
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
143
apps/e2e/tests/spacetimedb/helpers.ts
Normal file
143
apps/e2e/tests/spacetimedb/helpers.ts
Normal file
@ -0,0 +1,143 @@
|
||||
// All traffic uses the
|
||||
// SpacetimeDB HTTP API (POST /v1/identity, /v1/database/{db}/call/{reducer},
|
||||
// /v1/database/{db}/sql) — avoids pulling the `spacetimedb` client SDK into
|
||||
// the e2e package just for a handful of subscriptions.
|
||||
|
||||
export type MintedIdentity = {
|
||||
token: string,
|
||||
/** 64-hex identity string, without the "0x" prefix the WS SDK sometimes prints. */
|
||||
identity: string,
|
||||
};
|
||||
|
||||
type SqlRow = Record<string, unknown>;
|
||||
|
||||
export type SpacetimedbConfig = {
|
||||
baseUrl: string,
|
||||
dbName: string,
|
||||
logToken: string | null,
|
||||
};
|
||||
|
||||
export function getSpacetimedbConfig(): SpacetimedbConfig {
|
||||
return {
|
||||
baseUrl: process.env.STACK_SPACETIMEDB_URL ?? "",
|
||||
dbName: process.env.STACK_SPACETIMEDB_DB_NAME ?? "stack-auth-llm",
|
||||
logToken: process.env.STACK_MCP_LOG_TOKEN ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function isSpacetimedbReachable(): Promise<boolean> {
|
||||
const { baseUrl } = getSpacetimedbConfig();
|
||||
if (!baseUrl) return false;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1/identity`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mintIdentity(): Promise<MintedIdentity> {
|
||||
const { baseUrl } = getSpacetimedbConfig();
|
||||
const res = await fetch(`${baseUrl}/v1/identity`, { method: "POST" });
|
||||
if (!res.ok) throw new Error(`mintIdentity failed: HTTP ${res.status}`);
|
||||
const body = await res.json() as { token: string, identity: string };
|
||||
// SpacetimeDB sometimes returns the identity with a leading "0x"; normalize it off.
|
||||
const identity = body.identity.startsWith("0x") ? body.identity.slice(2) : body.identity;
|
||||
return { token: body.token, identity };
|
||||
}
|
||||
|
||||
/**
|
||||
* SpacetimeDB encodes `.optional()` fields as a tagged sum type — clients must
|
||||
* send `{ some: value }` or `{ none: [] }`, not raw null. Mirrors the `opt()`
|
||||
* helper in apps/backend/src/lib/ai/mcp-logger.ts:87.
|
||||
*/
|
||||
export function opt<T>(value: T | null | undefined): { some: T } | { none: [] } {
|
||||
return value == null ? { none: [] } : { some: value };
|
||||
}
|
||||
|
||||
export type ReducerCallResult = {
|
||||
status: number,
|
||||
ok: boolean,
|
||||
body: string,
|
||||
};
|
||||
|
||||
export async function callReducer(
|
||||
token: string,
|
||||
reducer: string,
|
||||
args: unknown[],
|
||||
): Promise<ReducerCallResult> {
|
||||
const { baseUrl, dbName } = getSpacetimedbConfig();
|
||||
const res = await fetch(`${baseUrl}/v1/database/${dbName}/call/${reducer}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(args, (_, v) => {
|
||||
if (typeof v !== "bigint") return v;
|
||||
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
if (v <= MAX && v >= -MAX) return Number(v);
|
||||
return v.toString();
|
||||
}),
|
||||
});
|
||||
return { status: res.status, ok: res.ok, body: await res.text() };
|
||||
}
|
||||
|
||||
export type SqlQueryResult = {
|
||||
columns: string[],
|
||||
rows: SqlRow[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up the `correlationId` of a freshly-inserted row by its unique `question`.
|
||||
* Caller must pass a SpacetimeDB token for an enrolled operator — only they can
|
||||
* read `my_visible_mcp_call_log`. Returns undefined if no match is found.
|
||||
*/
|
||||
export async function findCorrelationIdByQuestion(
|
||||
token: string,
|
||||
question: string,
|
||||
): Promise<string | undefined> {
|
||||
const { rows } = await sqlQuery(token, "SELECT * FROM my_visible_mcp_call_log");
|
||||
const match = rows.find(r => r.question === question);
|
||||
if (!match) return undefined;
|
||||
const raw = match.correlation_id ?? match.correlationId;
|
||||
return typeof raw === "string" ? raw : undefined;
|
||||
}
|
||||
|
||||
export async function sqlQuery(token: string, sql: string): Promise<SqlQueryResult> {
|
||||
const { baseUrl, dbName } = getSpacetimedbConfig();
|
||||
const res = await fetch(`${baseUrl}/v1/database/${dbName}/sql`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: sql,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`SQL ${JSON.stringify(sql)} failed: HTTP ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const payload = await res.json() as Array<{
|
||||
schema: { elements: Array<{ name: { some: string } | { none: null } }> },
|
||||
rows: unknown[][],
|
||||
}>;
|
||||
// `/sql` returns an array of query results (one per statement). We only send one.
|
||||
if (payload.length === 0) return { columns: [], rows: [] };
|
||||
const first = payload[0];
|
||||
const columns = first.schema.elements.map(el => "some" in el.name ? el.name.some : "");
|
||||
const rows: SqlRow[] = first.rows.map(tuple => {
|
||||
const obj: SqlRow = {};
|
||||
columns.forEach((c, i) => {
|
||||
obj[c] = tuple[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
return { columns, rows };
|
||||
}
|
||||
113
apps/e2e/tests/spacetimedb/operators-rls.test.ts
Normal file
113
apps/e2e/tests/spacetimedb/operators-rls.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../helpers";
|
||||
import { AiChatReviewer, niceBackendFetch } from "../backend/backend-helpers";
|
||||
import { callReducer, getSpacetimedbConfig, isSpacetimedbReachable, mintIdentity, sqlQuery } from "./helpers";
|
||||
|
||||
const canRun = await isSpacetimedbReachable();
|
||||
const { logToken } = getSpacetimedbConfig();
|
||||
|
||||
describe.skipIf(!canRun)("operators table RLS", () => {
|
||||
it("each reviewer sees only their own operators row", async ({ expect }) => {
|
||||
const a = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enrollA = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: a.identity },
|
||||
});
|
||||
expect(enrollA.status).toBe(200);
|
||||
|
||||
const b = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enrollB = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: b.identity },
|
||||
});
|
||||
expect(enrollB.status).toBe(200);
|
||||
|
||||
const asA = await sqlQuery(a.token, "SELECT * FROM operators");
|
||||
const asB = await sqlQuery(b.token, "SELECT * FROM operators");
|
||||
|
||||
expect(asA.rows.length).toBe(1);
|
||||
expect(asB.rows.length).toBe(1);
|
||||
// Different reviewers must see different (own) rows — if RLS broke, both would see two.
|
||||
expect(JSON.stringify(asA.rows[0])).not.toEqual(JSON.stringify(asB.rows[0]));
|
||||
});
|
||||
|
||||
it("a freshly-minted non-operator identity sees zero operators rows", async ({ expect }) => {
|
||||
// Seed at least one operator so the table isn't empty.
|
||||
const seeded = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: seeded.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
|
||||
const stranger = await mintIdentity();
|
||||
const { rows } = await sqlQuery(stranger.token, "SELECT * FROM operators");
|
||||
expect(rows.length).toBe(0);
|
||||
});
|
||||
|
||||
it("enrolling a second identity as the same reviewer sweeps the first", async ({ expect }) => {
|
||||
// The add_operator reducer's sweep logic deletes prior rows with the same
|
||||
// stackUserId before inserting a new identity — a reviewer switching browsers
|
||||
// should not accumulate stale operator rows.
|
||||
const x = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enrollX = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: x.identity },
|
||||
});
|
||||
expect(enrollX.status).toBe(200);
|
||||
|
||||
// Same reviewer (backendContext.userAuth unchanged) enrolls a second identity.
|
||||
const y = await mintIdentity();
|
||||
const enrollY = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: y.identity },
|
||||
});
|
||||
expect(enrollY.status).toBe(200);
|
||||
|
||||
// X should no longer be in operators — sweep removed its row.
|
||||
const asX = await sqlQuery(x.token, "SELECT * FROM operators");
|
||||
expect(asX.rows.length).toBe(0);
|
||||
// Y should still be the active operator.
|
||||
const asY = await sqlQuery(y.token, "SELECT * FROM operators");
|
||||
expect(asY.rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it.skipIf(!logToken)(
|
||||
"remove_operator reducer revokes an operator's view access",
|
||||
async ({ expect }) => {
|
||||
const target = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: target.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
|
||||
// Confirm enrolled.
|
||||
const before = await sqlQuery(target.token, "SELECT * FROM operators");
|
||||
expect(before.rows.length).toBe(1);
|
||||
|
||||
// Directly call remove_operator with the log token.
|
||||
const caller = await mintIdentity();
|
||||
const removed = await callReducer(caller.token, "remove_operator", [
|
||||
logToken!,
|
||||
[`0x${target.identity}`],
|
||||
]);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
// Target is no longer an operator.
|
||||
const after = await sqlQuery(target.token, "SELECT * FROM operators");
|
||||
expect(after.rows.length).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
120
apps/e2e/tests/spacetimedb/private-tables.test.ts
Normal file
120
apps/e2e/tests/spacetimedb/private-tables.test.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../helpers";
|
||||
import { AiChatReviewer, niceBackendFetch } from "../backend/backend-helpers";
|
||||
import { callReducer, getSpacetimedbConfig, isSpacetimedbReachable, mintIdentity, opt, sqlQuery } from "./helpers";
|
||||
|
||||
const canRun = await isSpacetimedbReachable();
|
||||
const { logToken } = getSpacetimedbConfig();
|
||||
|
||||
describe.skipIf(!canRun)("private log tables and view gating", () => {
|
||||
// my_visible_ai_query_log is the counterpart to my_visible_mcp_call_log. Seeding
|
||||
// requires the log token (no user-facing endpoint writes to ai_query_log), so
|
||||
// skip when unavailable rather than asserting against an empty table.
|
||||
it.skipIf(!logToken)(
|
||||
"a freshly-minted non-operator identity sees zero rows in my_visible_ai_query_log",
|
||||
async ({ expect }) => {
|
||||
const seeder = await mintIdentity();
|
||||
const seed = await callReducer(seeder.token, "log_ai_query", [
|
||||
logToken!,
|
||||
`corr-${Date.now()}`,
|
||||
"chat",
|
||||
"system-prompt-id",
|
||||
"high",
|
||||
"fast",
|
||||
"some-model",
|
||||
false,
|
||||
opt(null),
|
||||
opt(null),
|
||||
"[]",
|
||||
"[]",
|
||||
"[]",
|
||||
"final text",
|
||||
opt(null),
|
||||
opt(null),
|
||||
opt(null),
|
||||
opt(null),
|
||||
0,
|
||||
0n,
|
||||
opt(null),
|
||||
opt(null),
|
||||
opt(null),
|
||||
]);
|
||||
expect(seed.ok).toBe(true);
|
||||
|
||||
const stranger = await mintIdentity();
|
||||
const { rows } = await sqlQuery(stranger.token, "SELECT * FROM my_visible_ai_query_log");
|
||||
expect(rows.length).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot subscribe to the private mcp_call_log table directly", async ({ expect }) => {
|
||||
// Seed a row so the table isn't empty — we're testing access control, not emptiness.
|
||||
const seeder = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: seeder.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
const seed = await niceBackendFetch("/api/latest/internal/mcp-review/add-manual", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { question: "seeded", answer: "a", publish: false },
|
||||
});
|
||||
expect(seed.status).toBe(200);
|
||||
|
||||
// Private table: SpacetimeDB should either reject the query outright or return
|
||||
// zero rows to non-operators. Either outcome is acceptable — the invariant is
|
||||
// "the caller does not see any private-table rows."
|
||||
const stranger = await mintIdentity();
|
||||
try {
|
||||
const { rows } = await sqlQuery(stranger.token, "SELECT * FROM mcp_call_log");
|
||||
expect(rows.length).toBe(0);
|
||||
} catch (err) {
|
||||
// Rejection path: the error confirms the table isn't publicly readable.
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!logToken)(
|
||||
"cannot subscribe to the private ai_query_log table directly",
|
||||
async ({ expect }) => {
|
||||
const seeder = await mintIdentity();
|
||||
const seed = await callReducer(seeder.token, "log_ai_query", [
|
||||
logToken!,
|
||||
`corr-${Date.now()}`,
|
||||
"chat",
|
||||
"system-prompt-id",
|
||||
"high",
|
||||
"fast",
|
||||
"some-model",
|
||||
false,
|
||||
opt(null),
|
||||
opt(null),
|
||||
"[]",
|
||||
"[]",
|
||||
"[]",
|
||||
"final text",
|
||||
opt(null),
|
||||
opt(null),
|
||||
opt(null),
|
||||
opt(null),
|
||||
0,
|
||||
0n,
|
||||
opt(null),
|
||||
opt(null),
|
||||
opt(null),
|
||||
]);
|
||||
expect(seed.ok).toBe(true);
|
||||
|
||||
const stranger = await mintIdentity();
|
||||
try {
|
||||
const { rows } = await sqlQuery(stranger.token, "SELECT * FROM ai_query_log");
|
||||
expect(rows.length).toBe(0);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
70
apps/e2e/tests/spacetimedb/published-qa-projection.test.ts
Normal file
70
apps/e2e/tests/spacetimedb/published-qa-projection.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../helpers";
|
||||
import { AiChatReviewer, niceBackendFetch } from "../backend/backend-helpers";
|
||||
import { isSpacetimedbReachable, mintIdentity, sqlQuery } from "./helpers";
|
||||
|
||||
const canRun = await isSpacetimedbReachable();
|
||||
|
||||
const EXPECTED_PUBLISHED_QA_COLUMNS = ["id", "question", "answer", "published_at"] as const;
|
||||
|
||||
// Fields from mcp_call_log that MUST NOT appear in the public view. If any of these
|
||||
// reappear, the projection has regressed and internal metadata is leaking to the
|
||||
// unauthenticated /questions page.
|
||||
const FORBIDDEN_COLUMNS = [
|
||||
"human_reviewed_by",
|
||||
"human_reviewed_at",
|
||||
"user_prompt",
|
||||
"qa_reviewed_at",
|
||||
"qa_flags_json",
|
||||
"qa_improvement_suggestions",
|
||||
"qa_conversation_json",
|
||||
"model_id",
|
||||
"correlation_id",
|
||||
"conversation_id",
|
||||
"response",
|
||||
"reason",
|
||||
"tool_name",
|
||||
"inner_tool_calls_json",
|
||||
"human_corrected_question",
|
||||
"human_corrected_answer",
|
||||
];
|
||||
|
||||
describe.skipIf(!canRun)("published_qa view projection", () => {
|
||||
it("exposes only {id, question, answer, publishedAt} — no reviewer or QA internals", async ({ expect }) => {
|
||||
const reviewerIdentity = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: reviewerIdentity.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
|
||||
const markerQuestion = `test-projection-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const markerAnswer = "answer-for-projection-test";
|
||||
|
||||
const publish = await niceBackendFetch("/api/latest/internal/mcp-review/add-manual", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { question: markerQuestion, answer: markerAnswer, publish: true },
|
||||
});
|
||||
expect(publish.status).toBe(200);
|
||||
|
||||
// Query with a fresh non-operator token — published_qa is anonymousView so any
|
||||
// bearer works; using a stranger identity matches the real public-page scenario.
|
||||
const stranger = await mintIdentity();
|
||||
const result = await sqlQuery(stranger.token, "SELECT * FROM published_qa");
|
||||
|
||||
// Regression: the projected column set must be exactly these four names.
|
||||
expect([...result.columns].sort()).toEqual([...EXPECTED_PUBLISHED_QA_COLUMNS].sort());
|
||||
for (const forbidden of FORBIDDEN_COLUMNS) {
|
||||
expect(result.columns).not.toContain(forbidden);
|
||||
}
|
||||
|
||||
// Our marker row must be present and carry the corrected (answer) payload, not
|
||||
// the raw response (which would be empty for a manually-added row).
|
||||
const ours = result.rows.find(r => r.question === markerQuestion);
|
||||
expect(ours).toBeDefined();
|
||||
expect(ours).toMatchObject({ question: markerQuestion, answer: markerAnswer });
|
||||
});
|
||||
});
|
||||
151
apps/e2e/tests/spacetimedb/published-qa-visibility.test.ts
Normal file
151
apps/e2e/tests/spacetimedb/published-qa-visibility.test.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../helpers";
|
||||
import { AiChatReviewer, niceBackendFetch } from "../backend/backend-helpers";
|
||||
import { findCorrelationIdByQuestion, isSpacetimedbReachable, mintIdentity, sqlQuery } from "./helpers";
|
||||
|
||||
const canRun = await isSpacetimedbReachable();
|
||||
|
||||
function uniqueMarker(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function publishedQaContains(question: string): Promise<boolean> {
|
||||
const stranger = await mintIdentity();
|
||||
const { rows } = await sqlQuery(stranger.token, "SELECT * FROM published_qa");
|
||||
return rows.some(r => r.question === question);
|
||||
}
|
||||
|
||||
describe.skipIf(!canRun)("published_qa visibility", () => {
|
||||
it("does not expose rows added with publish:false", async ({ expect }) => {
|
||||
const reviewer = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: reviewer.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
|
||||
const marker = uniqueMarker("unpublished");
|
||||
const add = await niceBackendFetch("/api/latest/internal/mcp-review/add-manual", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { question: marker, answer: "x", publish: false },
|
||||
});
|
||||
expect(add.status).toBe(200);
|
||||
|
||||
expect(await publishedQaContains(marker)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes a row from published_qa when update-correction sets publish:false", async ({ expect }) => {
|
||||
const reviewer = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: reviewer.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
|
||||
const marker = uniqueMarker("to-unpublish");
|
||||
const add = await niceBackendFetch("/api/latest/internal/mcp-review/add-manual", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { question: marker, answer: "x", publish: true },
|
||||
});
|
||||
expect(add.status).toBe(200);
|
||||
expect(await publishedQaContains(marker)).toBe(true);
|
||||
|
||||
const correlationId = await findCorrelationIdByQuestion(reviewer.token, marker);
|
||||
expect(correlationId).toBeDefined();
|
||||
|
||||
const update = await niceBackendFetch("/api/latest/internal/mcp-review/update-correction", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {
|
||||
correlationId,
|
||||
correctedQuestion: marker,
|
||||
correctedAnswer: "x",
|
||||
publish: false,
|
||||
},
|
||||
});
|
||||
expect(update.status).toBe(200);
|
||||
|
||||
expect(await publishedQaContains(marker)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes a row from published_qa when deleted", async ({ expect }) => {
|
||||
const reviewer = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: reviewer.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
|
||||
const marker = uniqueMarker("to-delete");
|
||||
const add = await niceBackendFetch("/api/latest/internal/mcp-review/add-manual", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { question: marker, answer: "x", publish: true },
|
||||
});
|
||||
expect(add.status).toBe(200);
|
||||
expect(await publishedQaContains(marker)).toBe(true);
|
||||
|
||||
const correlationId = await findCorrelationIdByQuestion(reviewer.token, marker);
|
||||
expect(correlationId).toBeDefined();
|
||||
|
||||
const del = await niceBackendFetch("/api/latest/internal/mcp-review/delete", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { correlationId },
|
||||
});
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
expect(await publishedQaContains(marker)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets reviewer B delete a row published by reviewer A (cross-reviewer integrity)", async ({ expect }) => {
|
||||
// A publishes.
|
||||
const reviewerA = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enrollA = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: reviewerA.identity },
|
||||
});
|
||||
expect(enrollA.status).toBe(200);
|
||||
|
||||
const marker = uniqueMarker("cross-reviewer");
|
||||
const add = await niceBackendFetch("/api/latest/internal/mcp-review/add-manual", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { question: marker, answer: "x", publish: true },
|
||||
});
|
||||
expect(add.status).toBe(200);
|
||||
expect(await publishedQaContains(marker)).toBe(true);
|
||||
|
||||
const correlationId = await findCorrelationIdByQuestion(reviewerA.token, marker);
|
||||
expect(correlationId).toBeDefined();
|
||||
|
||||
// B deletes. fastSignUp re-points backendContext.userAuth to B; subsequent calls use B's auth.
|
||||
const reviewerB = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enrollB = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: reviewerB.identity },
|
||||
});
|
||||
expect(enrollB.status).toBe(200);
|
||||
|
||||
const del = await niceBackendFetch("/api/latest/internal/mcp-review/delete", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { correlationId },
|
||||
});
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
expect(await publishedQaContains(marker)).toBe(false);
|
||||
});
|
||||
});
|
||||
103
apps/e2e/tests/spacetimedb/reducer-auth.test.ts
Normal file
103
apps/e2e/tests/spacetimedb/reducer-auth.test.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../helpers";
|
||||
import { AiChatReviewer, niceBackendFetch } from "../backend/backend-helpers";
|
||||
import { callReducer, getSpacetimedbConfig, isSpacetimedbReachable, mintIdentity, opt, sqlQuery } from "./helpers";
|
||||
|
||||
const canRun = await isSpacetimedbReachable();
|
||||
const { logToken } = getSpacetimedbConfig();
|
||||
|
||||
describe.skipIf(!canRun)("SpacetimeDB reducer auth", () => {
|
||||
it("a freshly-minted non-operator identity sees zero rows in my_visible_mcp_call_log", async ({ expect }) => {
|
||||
// Seed a published row so the underlying mcp_call_log is definitely non-empty —
|
||||
// otherwise a 0-row result could be a false positive from an empty table.
|
||||
const reviewerIdentity = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enroll = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: reviewerIdentity.identity },
|
||||
});
|
||||
expect(enroll.status).toBe(200);
|
||||
const seedPublish = await niceBackendFetch("/api/latest/internal/mcp-review/add-manual", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { question: "q", answer: "a", publish: false },
|
||||
});
|
||||
expect(seedPublish.status).toBe(200);
|
||||
|
||||
const stranger = await mintIdentity();
|
||||
const result = await sqlQuery(stranger.token, "SELECT * FROM my_visible_mcp_call_log");
|
||||
expect(result.rows.length).toBe(0);
|
||||
});
|
||||
|
||||
// Smoke-test every mutating reducer's token gate. The existing add_operator test
|
||||
// above catches regressions on that one reducer specifically; this loop ensures
|
||||
// no new mutating reducer ships without a token check. If a reducer is added, it
|
||||
// must be added here too or this smoke covers one less attack surface.
|
||||
it("every mutating reducer rejects calls with a wrong log token", async ({ expect }) => {
|
||||
const caller = await mintIdentity();
|
||||
const victim = await mintIdentity();
|
||||
const wrong = "definitely-not-the-real-token";
|
||||
const hexId = `0x${victim.identity}`;
|
||||
|
||||
const cases = [
|
||||
{ name: "add_operator", args: [wrong, [hexId], "some-user", "Some Name"] },
|
||||
{ name: "remove_operator", args: [wrong, [hexId]] },
|
||||
{ name: "enroll_service", args: [wrong, "Some Service"] },
|
||||
{ name: "mark_human_reviewed", args: [wrong, "corr", "reviewer"] },
|
||||
{ name: "unmark_human_reviewed", args: [wrong, "corr"] },
|
||||
{
|
||||
name: "update_human_correction",
|
||||
args: [wrong, "corr", "q", "a", false, "reviewer"],
|
||||
},
|
||||
{ name: "add_manual_qa", args: [wrong, "q", "a", false, "reviewer"] },
|
||||
{ name: "delete_qa_entry", args: [wrong, "corr"] },
|
||||
{
|
||||
name: "log_mcp_call",
|
||||
args: [wrong, "corr", opt(null), "tool", "reason", "prompt", "q", "r", 0, "[]", 0n, "model", opt(null)],
|
||||
},
|
||||
{
|
||||
name: "update_mcp_qa_review",
|
||||
args: [wrong, "corr", false, false, false, "[]", "", 0, "model", opt(null), opt(null)],
|
||||
},
|
||||
{
|
||||
name: "log_ai_query",
|
||||
args: [wrong, "corr", "chat", "sys", "q", "s", "model", false, opt(null), opt(null), "[]", "[]", "[]", "text", opt(null), opt(null), opt(null), opt(null), 0, 0n, opt(null), opt(null), opt(null)],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, args } of cases) {
|
||||
const result = await callReducer(caller.token, name, args);
|
||||
expect(result.ok, `reducer ${name} should reject wrong token`).toBe(false);
|
||||
expect(result.body, `reducer ${name} should report invalid-token error`).toContain("Invalid log token");
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!logToken)(
|
||||
"rejects add_operator when an existing identity is claimed under a different stackUserId",
|
||||
async ({ expect }) => {
|
||||
// Enroll identity X with stackUserId=A via the backend endpoint (legitimate flow).
|
||||
const target = await mintIdentity();
|
||||
const callerA = await mintIdentity();
|
||||
await AiChatReviewer.createReviewer();
|
||||
const enrollA = await niceBackendFetch("/api/latest/internal/spacetimedb-enroll-reviewer", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: { identity: target.identity },
|
||||
});
|
||||
expect(enrollA.status).toBe(200);
|
||||
|
||||
// Now directly call add_operator with a DIFFERENT stackUserId for the same identity.
|
||||
// Simulates an attacker with the log token trying to relabel X's row.
|
||||
const result = await callReducer(callerA.token, "add_operator", [
|
||||
logToken!,
|
||||
[`0x${target.identity}`],
|
||||
"attacker-different-stack-user-id",
|
||||
"Attacker Display Name",
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.body).toContain("Identity is bound to a different Stack user");
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import { schema, t, table, SenderError } from 'spacetimedb/server';
|
||||
import type { Timestamp } from 'spacetimedb';
|
||||
|
||||
// Injected at publish time by the spacetime:inject-token pnpm script from STACK_MCP_LOG_TOKEN env var.
|
||||
// Must match STACK_MCP_LOG_TOKEN in the backend .env.
|
||||
@ -118,11 +119,36 @@ export const myVisibleAiQueryLog = spacetimedb.view(
|
||||
|
||||
// Public view for the /questions page — returns rows reviewers have explicitly
|
||||
// published. Uses `anonymousView` so SpacetimeDB materializes once and shares
|
||||
// the result across all subscribers.
|
||||
// the result across all subscribers. Projected to only fields the public page
|
||||
// needs; everything else (reviewer attribution, QA internals, raw prompt,
|
||||
// tool-call metadata) stays private.
|
||||
const publishedQaRow = t.object('PublishedQaRow', {
|
||||
id: t.u64(),
|
||||
question: t.string(),
|
||||
answer: t.string(),
|
||||
publishedAt: t.timestamp().optional(),
|
||||
});
|
||||
|
||||
export const publishedQa = spacetimedb.anonymousView(
|
||||
{ name: 'published_qa', public: true },
|
||||
t.array(mcpCallLog.rowType),
|
||||
(ctx) => Array.from(ctx.db.mcpCallLog.publishedToQa.filter(true)),
|
||||
t.array(publishedQaRow),
|
||||
(ctx) => {
|
||||
const out: Array<{
|
||||
id: bigint,
|
||||
question: string,
|
||||
answer: string,
|
||||
publishedAt: Timestamp | undefined,
|
||||
}> = [];
|
||||
for (const row of ctx.db.mcpCallLog.publishedToQa.filter(true)) {
|
||||
out.push({
|
||||
id: row.id,
|
||||
question: row.humanCorrectedQuestion ?? row.question,
|
||||
answer: row.humanCorrectedAnswer ?? row.response,
|
||||
publishedAt: row.publishedAt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
export const add_operator = spacetimedb.reducer(
|
||||
@ -138,6 +164,9 @@ export const add_operator = spacetimedb.reducer(
|
||||
}
|
||||
const existing = ctx.db.operators.identity.find(args.identity);
|
||||
if (existing != null) {
|
||||
if (existing.stackUserId !== args.stackUserId) {
|
||||
throw new SenderError('Identity is bound to a different Stack user');
|
||||
}
|
||||
ctx.db.operators.identity.update({
|
||||
identity: args.identity,
|
||||
addedAt: existing.addedAt,
|
||||
|
||||
@ -48,10 +48,10 @@ export default function QuestionsPage() {
|
||||
<div className="space-y-8">
|
||||
{publishedQa.map(row => (
|
||||
<article key={String(row.id)} className="border-b border-gray-200 pb-8 last:border-b-0">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">{row.humanCorrectedQuestion ?? row.question}</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">{row.question}</h2>
|
||||
<div className="prose prose-sm max-w-none text-gray-700">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{row.humanCorrectedAnswer ?? row.response}
|
||||
{row.answer}
|
||||
</Markdown>
|
||||
</div>
|
||||
{row.publishedAt && (
|
||||
|
||||
@ -2,7 +2,7 @@ import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { Identity } from "spacetimedb";
|
||||
import { DbConnection, type ErrorContext, type EventContext, type SubscriptionEventContext } from "../module_bindings";
|
||||
import type { AiQueryLogRow, McpCallLogRow } from "../types";
|
||||
import type { AiQueryLogRow, McpCallLogRow, PublishedQaRow } from "../types";
|
||||
|
||||
export type EnsureEnrolled = (identity: Identity) => Promise<void>;
|
||||
|
||||
@ -16,6 +16,9 @@ function resolveEnv(raw: string | undefined, devDefault: string, name: string):
|
||||
throw new Error(`${name} is not configured. Set it in .env.local or hosting platform env.`);
|
||||
}
|
||||
const HOST = resolveEnv(rawHost, "ws://localhost:8139", "NEXT_PUBLIC_SPACETIMEDB_HOST");
|
||||
if (!IS_DEV && !HOST.startsWith("wss://")) {
|
||||
throw new Error("NEXT_PUBLIC_SPACETIMEDB_HOST must use wss:// in production");
|
||||
}
|
||||
const DB_NAME = resolveEnv(rawDbName, "stack-auth-llm", "NEXT_PUBLIC_SPACETIMEDB_DB_NAME");
|
||||
const TOKEN_KEY = `spacetimedb_${HOST}/${DB_NAME}/auth_token`;
|
||||
|
||||
@ -186,14 +189,14 @@ const aiQueryBinding: TableBinding<AiQueryLogRow> = {
|
||||
},
|
||||
};
|
||||
|
||||
const publishedQaBinding: TableBinding<McpCallLogRow> = {
|
||||
const publishedQaBinding: TableBinding<PublishedQaRow> = {
|
||||
tableName: "published_qa",
|
||||
iter: (ctx) => ctx.db.publishedQa.iter(),
|
||||
onInsert: (conn, cb) => {
|
||||
conn.db.publishedQa.onInsert((_ctx: EventContext, row: McpCallLogRow) => cb(row));
|
||||
conn.db.publishedQa.onInsert((_ctx: EventContext, row: PublishedQaRow) => cb(row));
|
||||
},
|
||||
onDelete: (conn, cb) => {
|
||||
conn.db.publishedQa.onDelete((_ctx: EventContext, row: McpCallLogRow) => cb(row));
|
||||
conn.db.publishedQa.onDelete((_ctx: EventContext, row: PublishedQaRow) => cb(row));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -12,34 +12,7 @@ import {
|
||||
|
||||
export default __t.row({
|
||||
id: __t.u64(),
|
||||
shard: __t.u8(),
|
||||
correlationId: __t.string().name("correlation_id"),
|
||||
conversationId: __t.option(__t.string()).name("conversation_id"),
|
||||
createdAt: __t.timestamp().name("created_at"),
|
||||
toolName: __t.string().name("tool_name"),
|
||||
reason: __t.string(),
|
||||
userPrompt: __t.string().name("user_prompt"),
|
||||
question: __t.string(),
|
||||
response: __t.string(),
|
||||
stepCount: __t.u32().name("step_count"),
|
||||
innerToolCallsJson: __t.string().name("inner_tool_calls_json"),
|
||||
durationMs: __t.u64().name("duration_ms"),
|
||||
modelId: __t.string().name("model_id"),
|
||||
errorMessage: __t.option(__t.string()).name("error_message"),
|
||||
qaReviewedAt: __t.option(__t.timestamp()).name("qa_reviewed_at"),
|
||||
qaNeedsHumanReview: __t.option(__t.bool()).name("qa_needs_human_review"),
|
||||
qaAnswerCorrect: __t.option(__t.bool()).name("qa_answer_correct"),
|
||||
qaAnswerRelevant: __t.option(__t.bool()).name("qa_answer_relevant"),
|
||||
qaFlagsJson: __t.option(__t.string()).name("qa_flags_json"),
|
||||
qaImprovementSuggestions: __t.option(__t.string()).name("qa_improvement_suggestions"),
|
||||
qaOverallScore: __t.option(__t.u32()).name("qa_overall_score"),
|
||||
qaReviewModelId: __t.option(__t.string()).name("qa_review_model_id"),
|
||||
qaConversationJson: __t.option(__t.string()).name("qa_conversation_json"),
|
||||
qaErrorMessage: __t.option(__t.string()).name("qa_error_message"),
|
||||
humanReviewedAt: __t.option(__t.timestamp()).name("human_reviewed_at"),
|
||||
humanReviewedBy: __t.option(__t.string()).name("human_reviewed_by"),
|
||||
humanCorrectedQuestion: __t.option(__t.string()).name("human_corrected_question"),
|
||||
humanCorrectedAnswer: __t.option(__t.string()).name("human_corrected_answer"),
|
||||
publishedToQa: __t.bool().name("published_to_qa"),
|
||||
answer: __t.string(),
|
||||
publishedAt: __t.option(__t.timestamp()).name("published_at"),
|
||||
});
|
||||
|
||||
@ -88,6 +88,11 @@ export const Operators = __t.object("Operators", {
|
||||
});
|
||||
export type Operators = __Infer<typeof Operators>;
|
||||
|
||||
export const PublishedQa = __t.object("PublishedQa", {});
|
||||
export const PublishedQa = __t.object("PublishedQa", {
|
||||
id: __t.u64(),
|
||||
question: __t.string(),
|
||||
answer: __t.string(),
|
||||
publishedAt: __t.option(__t.timestamp()),
|
||||
});
|
||||
export type PublishedQa = __Infer<typeof PublishedQa>;
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
export type { AiQueryLog as AiQueryLogRow, McpCallLog as McpCallLogRow } from "./module_bindings/types";
|
||||
export type { AiQueryLog as AiQueryLogRow, McpCallLog as McpCallLogRow, PublishedQa as PublishedQaRow } from "./module_bindings/types";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user