handzon-core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/collections.ts +97 -3
  3. package/src/components/ai/ChatButton.tsx +51 -3
  4. package/src/components/ai/ChatPanel.tsx +86 -23
  5. package/src/components/ai/CopyStep.tsx +44 -0
  6. package/src/components/ai/OpenInAgent.tsx +55 -0
  7. package/src/components/ai/SelectionAsk.tsx +98 -0
  8. package/src/components/ai/StepHelp.tsx +31 -0
  9. package/src/components/mdx/Checkpoint.tsx +66 -2
  10. package/src/components/mdx/CopyPrompt.astro +10 -0
  11. package/src/components/mdx/CopyPrompt.tsx +56 -0
  12. package/src/components/mdx/HelpMe.astro +10 -0
  13. package/src/components/mdx/HelpMe.tsx +29 -0
  14. package/src/components/mdx/Playground.tsx +61 -9
  15. package/src/components/mdx/Quiz.tsx +18 -0
  16. package/src/index.ts +5 -0
  17. package/src/layouts/TutorialLayout.astro +19 -0
  18. package/src/lib/ai/assist.ts +81 -0
  19. package/src/lib/ai/prompts.ts +126 -0
  20. package/src/lib/ai/stepData.ts +74 -0
  21. package/src/lib/mdx-components.ts +4 -0
  22. package/src/lib/progress/remote.ts +86 -25
  23. package/src/lib/progress/types.ts +23 -0
  24. package/src/lib/progress/useProgress.ts +8 -4
  25. package/src/pages/TutorialStep.astro +12 -1
  26. package/src/server/auth.ts +84 -1
  27. package/src/server/db/schema.ts +53 -0
  28. package/src/server/handlers/helpInbox.ts +45 -0
  29. package/src/server/handlers/mcp.ts +72 -0
  30. package/src/server/handlers/progress.ts +7 -51
  31. package/src/server/handlers/progressEvents.ts +68 -0
  32. package/src/server/mcp/protocol.ts +99 -0
  33. package/src/server/mcp/server.ts +94 -0
  34. package/src/server/mcp/tools.ts +175 -0
  35. package/src/server/mcp/writeTools.ts +407 -0
  36. package/src/server/progress.ts +86 -0
  37. package/src/server/progressBus.ts +51 -0
  38. package/src/server/tokens.ts +80 -0
  39. package/src/server/verify/evaluator.ts +134 -0
  40. package/src/types/ai.ts +6 -0
  41. package/styles/components/assist.css +101 -0
  42. package/styles/components/checkpoint.css +29 -0
  43. package/styles/components.css +1 -0
@@ -0,0 +1,407 @@
1
+ import { getStep } from "../../lib/content.ts";
2
+ import { getDb } from "../db/client.ts";
3
+ import { helpRequests } from "../db/schema.ts";
4
+ import { writeProgressEntries } from "../progress.ts";
5
+ import { type CheckObservation, evaluate } from "../verify/evaluator.ts";
6
+ import { errorResult, type McpTool, text } from "./protocol.ts";
7
+
8
+ const SCOPE = "progress:write";
9
+
10
+ function requireLearner(learnerId: string | undefined) {
11
+ if (!learnerId) {
12
+ throw new Error("No resolved learner — bearer token required.");
13
+ }
14
+ return learnerId;
15
+ }
16
+
17
+ /**
18
+ * MCP write tools. Each one builds the same `{ kind, scope, key, value }`
19
+ * row shape the cookie-authed /api/progress endpoint accepts and pipes
20
+ * it through the shared writeProgressEntries() writer. Sources tag the
21
+ * row with `source: "mcp"` so author-side telemetry can distinguish
22
+ * agent-driven completions from in-browser clicks.
23
+ */
24
+ export const progressWriteTools: McpTool[] = [
25
+ {
26
+ name: "complete_checkpoint",
27
+ description:
28
+ "Mark a checkpoint complete for the authenticated learner. Optionally include evidence (command, output, files) for telemetry.",
29
+ requiredScope: SCOPE,
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ tutorial: { type: "string", minLength: 1 },
34
+ step: { type: "string", minLength: 1 },
35
+ checkpointId: { type: "string", minLength: 1 },
36
+ evidence: {
37
+ type: "object",
38
+ properties: {
39
+ command: { type: "string" },
40
+ output: { type: "string" },
41
+ files: { type: "object" },
42
+ },
43
+ additionalProperties: true,
44
+ },
45
+ },
46
+ required: ["tutorial", "step", "checkpointId"],
47
+ additionalProperties: false,
48
+ },
49
+ handler: async (args, ctx) => {
50
+ const a = args as {
51
+ tutorial: string;
52
+ step: string;
53
+ checkpointId: string;
54
+ evidence?: Record<string, unknown>;
55
+ };
56
+ const learnerId = requireLearner(ctx.learnerId);
57
+ // Checkpoint rows use scope "global" to match the in-browser
58
+ // client (state.checkpoints is keyed by checkpoint id, not
59
+ // by step). Tutorial + step are part of the value payload
60
+ // for telemetry and for the SSE handler that wants to know
61
+ // which step the ack belongs to.
62
+ await writeProgressEntries(learnerId, [
63
+ {
64
+ kind: "checkpoint",
65
+ scope: "global",
66
+ key: a.checkpointId,
67
+ value: {
68
+ source: "mcp",
69
+ tutorial: a.tutorial,
70
+ step: a.step,
71
+ evidence: a.evidence ?? null,
72
+ ts: Date.now(),
73
+ },
74
+ },
75
+ ]);
76
+ return text(`Checkpoint ${a.checkpointId} marked complete.`);
77
+ },
78
+ },
79
+ {
80
+ name: "uncheck_checkpoint",
81
+ description: "Undo a previously completed checkpoint (tombstone the row).",
82
+ requiredScope: SCOPE,
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ tutorial: { type: "string", minLength: 1 },
87
+ step: { type: "string", minLength: 1 },
88
+ checkpointId: { type: "string", minLength: 1 },
89
+ },
90
+ required: ["tutorial", "step", "checkpointId"],
91
+ additionalProperties: false,
92
+ },
93
+ handler: async (args, ctx) => {
94
+ const a = args as { tutorial: string; step: string; checkpointId: string };
95
+ const learnerId = requireLearner(ctx.learnerId);
96
+ // Uncheck also drops the matching kind:"verification" telemetry
97
+ // row so a re-attempt isn't pre-poisoned by the previous
98
+ // failure feedback.
99
+ await writeProgressEntries(learnerId, [
100
+ { kind: "checkpoint", scope: "global", key: a.checkpointId, value: null },
101
+ {
102
+ kind: "verification",
103
+ scope: `${a.tutorial}/${a.step}`,
104
+ key: a.checkpointId,
105
+ value: null,
106
+ },
107
+ ]);
108
+ return text(`Checkpoint ${a.checkpointId} unchecked.`);
109
+ },
110
+ },
111
+ {
112
+ name: "complete_step",
113
+ description: "Mark a step complete (mirrors clicking through after a checkpoint).",
114
+ requiredScope: SCOPE,
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ tutorial: { type: "string", minLength: 1 },
119
+ step: { type: "string", minLength: 1 },
120
+ },
121
+ required: ["tutorial", "step"],
122
+ additionalProperties: false,
123
+ },
124
+ handler: async (args, ctx) => {
125
+ const a = args as { tutorial: string; step: string };
126
+ const learnerId = requireLearner(ctx.learnerId);
127
+ await writeProgressEntries(learnerId, [
128
+ {
129
+ kind: "step",
130
+ scope: a.tutorial,
131
+ key: a.step,
132
+ value: "complete",
133
+ },
134
+ ]);
135
+ return text(`Step ${a.step} marked complete.`);
136
+ },
137
+ },
138
+ {
139
+ name: "mark_step_incomplete",
140
+ description: "Undo a step completion.",
141
+ requiredScope: SCOPE,
142
+ inputSchema: {
143
+ type: "object",
144
+ properties: {
145
+ tutorial: { type: "string", minLength: 1 },
146
+ step: { type: "string", minLength: 1 },
147
+ },
148
+ required: ["tutorial", "step"],
149
+ additionalProperties: false,
150
+ },
151
+ handler: async (args, ctx) => {
152
+ const a = args as { tutorial: string; step: string };
153
+ const learnerId = requireLearner(ctx.learnerId);
154
+ await writeProgressEntries(learnerId, [
155
+ {
156
+ kind: "step",
157
+ scope: a.tutorial,
158
+ key: a.step,
159
+ value: null,
160
+ },
161
+ ]);
162
+ return text(`Step ${a.step} marked incomplete.`);
163
+ },
164
+ },
165
+ {
166
+ name: "record_quiz",
167
+ description: "Record a quiz attempt (chosen indices + correct flag).",
168
+ requiredScope: SCOPE,
169
+ inputSchema: {
170
+ type: "object",
171
+ properties: {
172
+ tutorial: { type: "string", minLength: 1 },
173
+ quizId: { type: "string", minLength: 1 },
174
+ chosen: { type: "array", items: { type: "integer" } },
175
+ correct: { type: "boolean" },
176
+ },
177
+ required: ["tutorial", "quizId", "chosen", "correct"],
178
+ additionalProperties: false,
179
+ },
180
+ handler: async (args, ctx) => {
181
+ const a = args as {
182
+ tutorial: string;
183
+ quizId: string;
184
+ chosen: number[];
185
+ correct: boolean;
186
+ };
187
+ const learnerId = requireLearner(ctx.learnerId);
188
+ await writeProgressEntries(learnerId, [
189
+ {
190
+ kind: "quiz",
191
+ scope: a.tutorial,
192
+ key: a.quizId,
193
+ value: { chosen: a.chosen, correct: a.correct, ts: Date.now(), source: "mcp" },
194
+ },
195
+ ]);
196
+ return text(`Quiz ${a.quizId} recorded.`);
197
+ },
198
+ },
199
+ {
200
+ name: "set_last_visited",
201
+ description: "Update the learner's last-visited step for a tutorial.",
202
+ requiredScope: SCOPE,
203
+ inputSchema: {
204
+ type: "object",
205
+ properties: {
206
+ tutorial: { type: "string", minLength: 1 },
207
+ step: { type: "string", minLength: 1 },
208
+ },
209
+ required: ["tutorial", "step"],
210
+ additionalProperties: false,
211
+ },
212
+ handler: async (args, ctx) => {
213
+ const a = args as { tutorial: string; step: string };
214
+ const learnerId = requireLearner(ctx.learnerId);
215
+ await writeProgressEntries(learnerId, [
216
+ {
217
+ kind: "lastVisited",
218
+ scope: a.tutorial,
219
+ key: "step",
220
+ value: { step: a.step, ts: Date.now() },
221
+ },
222
+ ]);
223
+ return text(`Last-visited set to ${a.tutorial}/${a.step}.`);
224
+ },
225
+ },
226
+ {
227
+ name: "request_help",
228
+ description:
229
+ "Post a help request from the agent into the in-browser tutor's inbox. The next time the learner opens ChatPanel, your query is prepended as a user turn.",
230
+ requiredScope: SCOPE,
231
+ inputSchema: {
232
+ type: "object",
233
+ properties: {
234
+ tutorial: { type: "string", minLength: 1 },
235
+ step: { type: "string", minLength: 1 },
236
+ query: { type: "string", minLength: 1 },
237
+ },
238
+ required: ["tutorial", "step", "query"],
239
+ additionalProperties: false,
240
+ },
241
+ handler: async (args, ctx) => {
242
+ const a = args as { tutorial: string; step: string; query: string };
243
+ const learnerId = requireLearner(ctx.learnerId);
244
+ const db = getDb();
245
+ const [row] = await db
246
+ .insert(helpRequests)
247
+ .values({
248
+ learnerId,
249
+ tutorialSlug: a.tutorial,
250
+ stepSlug: a.step,
251
+ query: a.query,
252
+ })
253
+ .returning({ id: helpRequests.id });
254
+ return text(
255
+ `Help request queued (${row!.id}). It will appear in the learner's tutor on next open.`,
256
+ );
257
+ },
258
+ },
259
+ {
260
+ name: "set_preference",
261
+ description: "Set a learner preference (free-form key/value).",
262
+ requiredScope: SCOPE,
263
+ inputSchema: {
264
+ type: "object",
265
+ properties: {
266
+ key: { type: "string", minLength: 1 },
267
+ value: {},
268
+ },
269
+ required: ["key", "value"],
270
+ additionalProperties: false,
271
+ },
272
+ handler: async (args, ctx) => {
273
+ const a = args as { key: string; value: unknown };
274
+ const learnerId = requireLearner(ctx.learnerId);
275
+ await writeProgressEntries(learnerId, [
276
+ {
277
+ kind: "pref",
278
+ scope: "global",
279
+ key: a.key,
280
+ value: a.value,
281
+ },
282
+ ]);
283
+ return text(`Preference ${a.key} set.`);
284
+ },
285
+ },
286
+ ];
287
+
288
+ /**
289
+ * Catch-all helper for surfacing handler errors as MCP-shaped error
290
+ * results — keeps the tool factories tidy when a wrapped call fails.
291
+ */
292
+ export function toolError(message: string) {
293
+ return errorResult(message);
294
+ }
295
+
296
+ /**
297
+ * Family D verification: agent reports observations, server scores.
298
+ * On pass, the same writeProgressEntries() path that
299
+ * complete_checkpoint uses fires + a kind:"verification" telemetry
300
+ * row lands. On fail, only the telemetry row lands and the verdict
301
+ * is returned to the agent. SSE fans the telemetry row to the open
302
+ * browser tab so <Checkpoint> can render an inline failure hint.
303
+ */
304
+ export const verificationTools: McpTool[] = [
305
+ {
306
+ name: "submit_verification",
307
+ description:
308
+ "Submit observed values for the current step's verify checks. The server scores against the declared spec and (on pass) marks the matching checkpoint complete. On fail, returns the failing check + hint and does not write a checkpoint row.",
309
+ requiredScope: SCOPE,
310
+ inputSchema: {
311
+ type: "object",
312
+ properties: {
313
+ tutorial: { type: "string", minLength: 1 },
314
+ step: { type: "string", minLength: 1 },
315
+ observations: {
316
+ type: "array",
317
+ description:
318
+ "One observation per declared check, in order. Fields per kind: file_exists {exists}, file_contains {exists, body}, shell {exitCode, stdout}, http {status, responseBody}.",
319
+ items: { type: "object" },
320
+ },
321
+ },
322
+ required: ["tutorial", "step", "observations"],
323
+ additionalProperties: false,
324
+ },
325
+ handler: async (args, ctx) => {
326
+ const a = args as {
327
+ tutorial: string;
328
+ step: string;
329
+ observations: CheckObservation[];
330
+ };
331
+ const learnerId = requireLearner(ctx.learnerId);
332
+ const stepEntry = await getStep(a.tutorial, a.step);
333
+ if (!stepEntry) {
334
+ return errorResult(`No step "${a.step}" in "${a.tutorial}".`);
335
+ }
336
+ const spec = (stepEntry.data as { verify?: unknown }).verify as
337
+ | import("../../collections.ts").VerifySpec
338
+ | undefined;
339
+ if (!spec) {
340
+ return errorResult(
341
+ `Step ${a.tutorial}/${a.step} has no verify block. Use complete_checkpoint for prose-fallback verification.`,
342
+ );
343
+ }
344
+
345
+ const verdict = evaluate(spec, a.observations);
346
+ const scope = `${a.tutorial}/${a.step}`;
347
+ const ts = Date.now();
348
+
349
+ if (verdict.passed) {
350
+ await writeProgressEntries(learnerId, [
351
+ {
352
+ kind: "checkpoint",
353
+ scope: "global",
354
+ key: spec.id,
355
+ value: {
356
+ source: "verify",
357
+ tutorial: a.tutorial,
358
+ step: a.step,
359
+ results: a.observations,
360
+ ts,
361
+ },
362
+ },
363
+ {
364
+ kind: "verification",
365
+ scope,
366
+ key: spec.id,
367
+ value: { pass: true, ts },
368
+ },
369
+ ]);
370
+ return text(
371
+ JSON.stringify(
372
+ { passed: true, checkpointId: spec.id, message: "All checks passed." },
373
+ null,
374
+ 2,
375
+ ),
376
+ );
377
+ }
378
+
379
+ await writeProgressEntries(learnerId, [
380
+ {
381
+ kind: "verification",
382
+ scope,
383
+ key: spec.id,
384
+ value: {
385
+ pass: false,
386
+ failingCheckIndex: verdict.failingCheckIndex,
387
+ reason: verdict.reason,
388
+ hint: verdict.hint,
389
+ ts,
390
+ },
391
+ },
392
+ ]);
393
+ return text(
394
+ JSON.stringify(
395
+ {
396
+ passed: false,
397
+ failingCheckIndex: verdict.failingCheckIndex,
398
+ reason: verdict.reason,
399
+ hint: verdict.hint,
400
+ },
401
+ null,
402
+ 2,
403
+ ),
404
+ );
405
+ },
406
+ },
407
+ ];
@@ -0,0 +1,86 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+ import { getDb } from "./db/client.ts";
3
+ import { progressEntries } from "./db/schema.ts";
4
+ import { publishLearner } from "./progressBus.ts";
5
+
6
+ export type ProgressKind =
7
+ | "step"
8
+ | "checkpoint"
9
+ | "quiz"
10
+ | "pref"
11
+ | "lastVisited"
12
+ | "tutorial"
13
+ | "verification";
14
+
15
+ export interface ProgressEntryWrite {
16
+ kind: ProgressKind;
17
+ scope: string;
18
+ key: string;
19
+ /** Use `null` to delete the row (the tombstone signal). */
20
+ value: unknown;
21
+ }
22
+
23
+ /**
24
+ * Shared writer used by both the cookie-authed POST /api/progress
25
+ * handler and the bearer-authed MCP write tools. Splits entries into
26
+ * deletes (value === null) and upserts, then runs them with the same
27
+ * UPSERT semantics the API handler uses.
28
+ *
29
+ * Returns the count of rows written/deleted so the caller can echo a
30
+ * sensible response back.
31
+ */
32
+ export async function writeProgressEntries(
33
+ learnerId: string,
34
+ entries: ProgressEntryWrite[],
35
+ ): Promise<number> {
36
+ if (entries.length === 0) return 0;
37
+ const db = getDb();
38
+ const now = new Date();
39
+ const deletes = entries.filter((e) => e.value === null);
40
+ const upserts = entries.filter((e) => e.value !== null);
41
+
42
+ for (const d of deletes) {
43
+ await db
44
+ .delete(progressEntries)
45
+ .where(
46
+ and(
47
+ eq(progressEntries.learnerId, learnerId),
48
+ eq(progressEntries.kind, d.kind),
49
+ eq(progressEntries.scope, d.scope),
50
+ eq(progressEntries.key, d.key),
51
+ ),
52
+ );
53
+ }
54
+
55
+ if (upserts.length > 0) {
56
+ const rows = upserts.map((b) => ({
57
+ learnerId,
58
+ kind: b.kind,
59
+ scope: b.scope,
60
+ key: b.key,
61
+ value: b.value,
62
+ updatedAt: now,
63
+ }));
64
+ await db
65
+ .insert(progressEntries)
66
+ .values(rows)
67
+ .onConflictDoUpdate({
68
+ target: [
69
+ progressEntries.learnerId,
70
+ progressEntries.kind,
71
+ progressEntries.scope,
72
+ progressEntries.key,
73
+ ],
74
+ set: {
75
+ value: sql`excluded.value`,
76
+ updatedAt: sql`excluded.updated_at`,
77
+ },
78
+ });
79
+ }
80
+
81
+ // Fan out to any open SSE subscriber for this learner so the
82
+ // browser tab catches MCP-driven writes without a polling loop.
83
+ publishLearner(learnerId, entries);
84
+
85
+ return entries.length;
86
+ }
@@ -0,0 +1,51 @@
1
+ import type { ProgressEntryWrite } from "./progress.ts";
2
+
3
+ /**
4
+ * Process-local pub/sub for progress writes. Both the cookie-authed
5
+ * POST /api/progress handler and the MCP write tools call publish()
6
+ * after persisting; the SSE endpoint subscribes per learner so the
7
+ * open browser tab sees MCP-driven writes within one event-loop
8
+ * tick.
9
+ *
10
+ * Multi-instance caveat: on a horizontally-scaled deploy, a write
11
+ * landing on instance A is invisible to a subscriber on instance B.
12
+ * v1 is single-instance; v2 swaps this for Postgres LISTEN/NOTIFY on
13
+ * a "learner_<uuid>" channel (Postgres is already in the stack).
14
+ */
15
+
16
+ export interface ProgressBusMessage extends ProgressEntryWrite {
17
+ ts: number;
18
+ }
19
+
20
+ type Listener = (msg: ProgressBusMessage) => void;
21
+
22
+ const channels = new Map<string, Set<Listener>>();
23
+
24
+ export function subscribeLearner(learnerId: string, listener: Listener): () => void {
25
+ let set = channels.get(learnerId);
26
+ if (!set) {
27
+ set = new Set();
28
+ channels.set(learnerId, set);
29
+ }
30
+ set.add(listener);
31
+ return () => {
32
+ set?.delete(listener);
33
+ if (set && set.size === 0) channels.delete(learnerId);
34
+ };
35
+ }
36
+
37
+ export function publishLearner(learnerId: string, entries: ProgressEntryWrite[]): void {
38
+ const subs = channels.get(learnerId);
39
+ if (!subs || subs.size === 0) return;
40
+ const ts = Date.now();
41
+ for (const entry of entries) {
42
+ const msg: ProgressBusMessage = { ...entry, ts };
43
+ for (const fn of subs) {
44
+ try {
45
+ fn(msg);
46
+ } catch (e) {
47
+ console.warn("[handzon] progress bus listener threw:", e);
48
+ }
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,80 @@
1
+ import { and, desc, eq } from "drizzle-orm";
2
+ import { generatePat, hashPat } from "./auth.ts";
3
+ import { getDb } from "./db/client.ts";
4
+ import { learnerApiTokens } from "./db/schema.ts";
5
+
6
+ /** Known scopes. The MCP endpoint gates writes on progress:write;
7
+ * catalog reads need any valid token. We keep the strings as
8
+ * comma-joined values in DB to mirror what an OAuth 2.1 token would
9
+ * carry in the v2 proxy.
10
+ */
11
+ export const KNOWN_SCOPES = ["progress:read", "progress:write"] as const;
12
+ export type Scope = (typeof KNOWN_SCOPES)[number];
13
+
14
+ export interface TokenRow {
15
+ id: string;
16
+ name: string;
17
+ scopes: string[];
18
+ createdAt: Date;
19
+ lastUsedAt: Date | null;
20
+ expiresAt: Date | null;
21
+ }
22
+
23
+ export async function listTokens(userId: string): Promise<TokenRow[]> {
24
+ const db = getDb();
25
+ const rows = await db
26
+ .select({
27
+ id: learnerApiTokens.id,
28
+ name: learnerApiTokens.name,
29
+ scopes: learnerApiTokens.scopes,
30
+ createdAt: learnerApiTokens.createdAt,
31
+ lastUsedAt: learnerApiTokens.lastUsedAt,
32
+ expiresAt: learnerApiTokens.expiresAt,
33
+ })
34
+ .from(learnerApiTokens)
35
+ .where(eq(learnerApiTokens.userId, userId))
36
+ .orderBy(desc(learnerApiTokens.createdAt));
37
+ return rows.map((r) => ({
38
+ ...r,
39
+ scopes: r.scopes
40
+ .split(",")
41
+ .map((s) => s.trim())
42
+ .filter(Boolean),
43
+ }));
44
+ }
45
+
46
+ /**
47
+ * Mint a new PAT for the user. Returns the raw token *once* — it is
48
+ * never stored, and there is no path to recover it. Caller is
49
+ * responsible for showing it to the user a single time.
50
+ */
51
+ export async function createToken(opts: {
52
+ userId: string;
53
+ name: string;
54
+ scopes: Scope[];
55
+ expiresAt?: Date | null;
56
+ }): Promise<{ id: string; raw: string }> {
57
+ const db = getDb();
58
+ const raw = generatePat();
59
+ const tokenHash = await hashPat(raw);
60
+ const [row] = await db
61
+ .insert(learnerApiTokens)
62
+ .values({
63
+ userId: opts.userId,
64
+ name: opts.name,
65
+ tokenHash,
66
+ scopes: opts.scopes.join(","),
67
+ expiresAt: opts.expiresAt ?? null,
68
+ })
69
+ .returning({ id: learnerApiTokens.id });
70
+ return { id: row!.id, raw };
71
+ }
72
+
73
+ export async function revokeToken(userId: string, tokenId: string): Promise<boolean> {
74
+ const db = getDb();
75
+ const result = await db
76
+ .delete(learnerApiTokens)
77
+ .where(and(eq(learnerApiTokens.userId, userId), eq(learnerApiTokens.id, tokenId)))
78
+ .returning({ id: learnerApiTokens.id });
79
+ return result.length > 0;
80
+ }