Skip to content

Commit 185d245

Browse files
committed
update reviewer authentication and API calls
1 parent 1389d37 commit 185d245

19 files changed

Lines changed: 206 additions & 193 deletions

File tree

apps/backend/src/app/api/latest/internal/mcp-review/add-manual/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { callReducerStrict } from "@/lib/ai/spacetimedb-client";
1+
import { callReducerStrict, opt } from "@/lib/ai/spacetimedb-client";
22
import { assertIsAiChatReviewer } from "@/lib/ai/qa/reviewer-auth";
33
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
44
import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
@@ -16,6 +16,7 @@ export const POST = createSmartRouteHandler({
1616
question: yupString().defined(),
1717
answer: yupString().defined(),
1818
publish: yupBoolean().defined(),
19+
requestId: yupString(),
1920
}).defined(),
2021
method: yupString().oneOf(["POST"]).defined(),
2122
}),
@@ -27,8 +28,8 @@ export const POST = createSmartRouteHandler({
2728
}).defined(),
2829
}),
2930
handler: async ({ auth, body }) => {
31+
assertIsAiChatReviewer(auth);
3032
const user = auth.user;
31-
assertIsAiChatReviewer(user);
3233

3334
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
3435
await callReducerStrict("add_manual_qa", [
@@ -37,6 +38,7 @@ export const POST = createSmartRouteHandler({
3738
body.answer,
3839
body.publish,
3940
user.display_name ?? user.primary_email ?? user.id,
41+
body.requestId
4042
]);
4143

4244
return {

apps/backend/src/app/api/latest/internal/mcp-review/delete/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const POST = createSmartRouteHandler({
2525
}).defined(),
2626
}),
2727
handler: async ({ auth, body }) => {
28-
assertIsAiChatReviewer(auth.user);
28+
assertIsAiChatReviewer(auth);
2929

3030
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
3131
await callReducerStrict("delete_qa_entry", [

apps/backend/src/app/api/latest/internal/mcp-review/mark-reviewed/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export const POST = createSmartRouteHandler({
2525
}).defined(),
2626
}),
2727
handler: async ({ auth, body }) => {
28+
assertIsAiChatReviewer(auth);
2829
const user = auth.user;
29-
assertIsAiChatReviewer(user);
3030

3131
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
3232
await callReducerStrict("mark_human_reviewed", [

apps/backend/src/app/api/latest/internal/mcp-review/unmark-reviewed/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const POST = createSmartRouteHandler({
2525
}).defined(),
2626
}),
2727
handler: async ({ auth, body }) => {
28-
assertIsAiChatReviewer(auth.user);
28+
assertIsAiChatReviewer(auth);
2929

3030
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
3131
await callReducerStrict("unmark_human_reviewed", [

apps/backend/src/app/api/latest/internal/mcp-review/update-correction/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export const POST = createSmartRouteHandler({
2828
}).defined(),
2929
}),
3030
handler: async ({ auth, body }) => {
31+
assertIsAiChatReviewer(auth);
3132
const user = auth.user;
32-
assertIsAiChatReviewer(user);
3333

3434
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
3535
const reviewer = user.display_name ?? user.primary_email ?? user.id;

apps/backend/src/app/api/latest/internal/mcp-review/update-qa-entry/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export const POST = createSmartRouteHandler({
2828
}).defined(),
2929
}),
3030
handler: async ({ auth, body }) => {
31+
assertIsAiChatReviewer(auth);
3132
const user = auth.user;
32-
assertIsAiChatReviewer(user);
3333

3434
const token = getEnvVariable("STACK_MCP_LOG_TOKEN");
3535
const editor = user.display_name ?? user.primary_email ?? user.id;

apps/backend/src/app/api/latest/internal/spacetimedb-enroll-reviewer/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ export const POST = createSmartRouteHandler({
2626
}).defined(),
2727
}),
2828
handler: async ({ auth, body }) => {
29+
assertIsAiChatReviewer(auth);
2930
const user = auth.user;
30-
assertIsAiChatReviewer(user);
3131
if (!/^[0-9a-fA-F]{64}$/.test(body.identity)) {
3232
throw new StatusError(StatusError.BadRequest, "Invalid identity.");
3333
}

apps/backend/src/lib/ai/qa/reviewer-auth.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1+
import { KnownErrors } from "@stackframe/stack-shared";
12
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
23

3-
export function assertIsAiChatReviewer(user: { client_read_only_metadata?: unknown }): void {
4+
export function assertIsAiChatReviewer(auth: {
5+
project: { id: string },
6+
user?: { client_read_only_metadata?: unknown } | null,
7+
}): void {
8+
if (auth.project.id !== "internal") {
9+
throw new KnownErrors.ExpectedInternalProject();
10+
}
11+
12+
const user = auth.user;
13+
if (!user) {
14+
throw new StatusError(StatusError.Unauthorized, "You must be signed in to perform MCP review operations.");
15+
}
16+
417
const metadata = user.client_read_only_metadata;
518
if (!(metadata && typeof metadata === "object" && "isAiChatReviewer" in metadata && metadata.isAiChatReviewer === true)) {
619
throw new StatusError(StatusError.Forbidden, "You are not approved to perform MCP review operations.");

apps/backend/src/lib/ai/spacetimedb-client.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,27 +67,34 @@ function spacetimeDbError(label: string, status: number, preview: string): Error
6767
return new StackAssertionError(detail);
6868
}
6969

70-
export async function callReducer(reducer: string, args: unknown[]): Promise<void> {
70+
async function callWithEnrollmentRetry(reducer: string, args: unknown[]): Promise<boolean> {
7171
const token = await getServiceToken();
72-
if (!token) return;
73-
await rawCallReducer(token, reducer, args);
72+
if (!token) return false;
73+
try {
74+
await rawCallReducer(token, reducer, args);
75+
return true;
76+
} catch (err) {
77+
if (!(err instanceof StatusError) || err.statusCode !== 401) throw err;
78+
enrollmentPromise = null;
79+
const fresh = await getServiceToken();
80+
if (!fresh) throw err;
81+
await rawCallReducer(fresh, reducer, args);
82+
return true;
83+
}
84+
}
85+
86+
export async function callReducer(reducer: string, args: unknown[]): Promise<void> {
87+
await callWithEnrollmentRetry(reducer, args);
7488
}
7589

76-
/**
77-
* Like {@link callReducer} but throws when SpacetimeDB isn't configured, rather
78-
* than no-opping. Use for endpoints where the client treats a 200 as proof the
79-
* mutation actually ran (reviewer enrollment, human QA edits, deletions).
80-
* Fire-and-forget logging paths should keep using the best-effort variant.
81-
*/
8290
export async function callReducerStrict(reducer: string, args: unknown[]): Promise<void> {
83-
const token = await getServiceToken();
84-
if (!token) {
91+
const ran = await callWithEnrollmentRetry(reducer, args);
92+
if (!ran) {
8593
throw new StackAssertionError(
8694
`SpacetimeDB is not configured. Reducer ${reducer} cannot run. ` +
8795
`Check STACK_SPACETIMEDB_URL and STACK_SPACETIMEDB_SERVICE_TOKEN.`
8896
);
8997
}
90-
await rawCallReducer(token, reducer, args);
9198
}
9299

93100
/**
@@ -130,4 +137,3 @@ export async function callSql<T = Record<string, unknown>>(sql: string): Promise
130137
return obj as T;
131138
});
132139
}
133-

apps/e2e/tests/spacetimedb/helpers.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,27 @@ function coerceBigInt(raw: unknown): bigint | undefined {
158158
if (typeof raw === "bigint") return raw;
159159
return undefined;
160160
}
161+
async function collectStackUserIdsForIdentities(
162+
callerToken: string,
163+
identities: ReadonlySet<string>,
164+
): Promise<Set<string>> {
165+
const out = new Set<string>();
166+
if (identities.size === 0) return out;
167+
const { rows } = await sqlQuery(callerToken, "SELECT * FROM operators");
168+
for (const row of rows) {
169+
const stackUserIdRaw = row.stack_user_id ?? row.stackUserId;
170+
if (typeof stackUserIdRaw !== "string") continue;
171+
if (stackUserIdRaw === "__service__") continue;
172+
const rowJson = JSON.stringify(row).toLowerCase();
173+
for (const identity of identities) {
174+
if (rowJson.includes(identity.toLowerCase())) {
175+
out.add(stackUserIdRaw);
176+
break;
177+
}
178+
}
179+
}
180+
return out;
181+
}
161182

162183
/**
163184
* Per-test collector for anything these tests drop into SpacetimeDB so
@@ -198,11 +219,12 @@ export function createCleanupScope(): CleanupScope {
198219
const caller = await mintIdentity().catch(() => null);
199220
if (caller == null) return;
200221

222+
const callerStackUserId = `cleanup-${caller.identity}`;
201223
try {
202224
await callReducer(caller.token, "add_operator", [
203225
logToken,
204226
[`0x${caller.identity}`],
205-
`__cleanup__-${caller.identity}`,
227+
callerStackUserId,
206228
"Cleanup Scope",
207229
]).catch(() => undefined);
208230

@@ -225,11 +247,15 @@ export function createCleanupScope(): CleanupScope {
225247
await callReducer(caller.token, "delete_ai_query_log", [logToken, correlationId]).catch(() => undefined);
226248
}
227249

228-
for (const identity of identities) {
229-
await callReducer(caller.token, "remove_operator", [logToken, [`0x${identity}`]]).catch(() => undefined);
250+
if (identities.size > 0) {
251+
const stackUserIdsToRemove = await collectStackUserIdsForIdentities(caller.token, identities)
252+
.catch(() => new Set<string>());
253+
for (const stackUserId of stackUserIdsToRemove) {
254+
await callReducer(caller.token, "remove_operators_for_user", [logToken, stackUserId]).catch(() => undefined);
255+
}
230256
}
231257
} finally {
232-
await callReducer(caller.token, "remove_operator", [logToken, [`0x${caller.identity}`]]).catch(() => undefined);
258+
await callReducer(caller.token, "remove_operators_for_user", [logToken, callerStackUserId]).catch(() => undefined);
233259
identities.clear();
234260
questions.clear();
235261
aiQueryCorrelationIds.clear();

0 commit comments

Comments
 (0)