handzon-core 0.7.0 → 0.8.1

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/collections.ts +97 -3
  3. package/src/components/Sidebar.astro +5 -2
  4. package/src/components/ai/ChatButton.tsx +51 -3
  5. package/src/components/ai/ChatPanel.tsx +86 -23
  6. package/src/components/ai/CopyStep.tsx +44 -0
  7. package/src/components/ai/OpenInAgent.tsx +55 -0
  8. package/src/components/ai/SelectionAsk.tsx +98 -0
  9. package/src/components/ai/StepHelp.tsx +31 -0
  10. package/src/components/home/Hero.astro +4 -3
  11. package/src/components/mdx/Checkpoint.tsx +66 -2
  12. package/src/components/mdx/CopyPrompt.astro +10 -0
  13. package/src/components/mdx/CopyPrompt.tsx +56 -0
  14. package/src/components/mdx/HelpMe.astro +10 -0
  15. package/src/components/mdx/HelpMe.tsx +29 -0
  16. package/src/components/mdx/Playground.tsx +61 -9
  17. package/src/components/mdx/Quiz.tsx +18 -0
  18. package/src/index.ts +5 -0
  19. package/src/layouts/BaseLayout.astro +6 -0
  20. package/src/layouts/TutorialLayout.astro +37 -9
  21. package/src/lib/ai/assist.ts +81 -0
  22. package/src/lib/ai/prompts.ts +126 -0
  23. package/src/lib/ai/stepData.ts +74 -0
  24. package/src/lib/mdx-components.ts +4 -0
  25. package/src/lib/progress/remote.ts +86 -25
  26. package/src/lib/progress/types.ts +23 -0
  27. package/src/lib/progress/useProgress.ts +8 -4
  28. package/src/pages/Home.astro +7 -1
  29. package/src/pages/TutorialLanding.astro +6 -4
  30. package/src/pages/TutorialStep.astro +13 -1
  31. package/src/server/auth.ts +84 -1
  32. package/src/server/db/schema.ts +53 -0
  33. package/src/server/handlers/helpInbox.ts +45 -0
  34. package/src/server/handlers/mcp.ts +72 -0
  35. package/src/server/handlers/progress.ts +7 -51
  36. package/src/server/handlers/progressEvents.ts +68 -0
  37. package/src/server/mcp/protocol.ts +99 -0
  38. package/src/server/mcp/server.ts +94 -0
  39. package/src/server/mcp/tools.ts +175 -0
  40. package/src/server/mcp/writeTools.ts +407 -0
  41. package/src/server/progress.ts +86 -0
  42. package/src/server/progressBus.ts +51 -0
  43. package/src/server/tokens.ts +80 -0
  44. package/src/server/verify/evaluator.ts +134 -0
  45. package/src/types/ai.ts +6 -0
  46. package/styles/base.css +16 -12
  47. package/styles/components/assist.css +101 -0
  48. package/styles/components/checkpoint.css +29 -0
  49. package/styles/components.css +1 -0
@@ -0,0 +1,74 @@
1
+ import type { AssistantContext } from "./context";
2
+
3
+ /**
4
+ * Reads the per-step JSON payload that TutorialLayout emits into
5
+ * `<script id="tt-step-data" type="application/json">`. Family B
6
+ * touchpoints (CopyPrompt, deep-link row, copy-step button, …) need
7
+ * the raw MDX source and tutorial/step titles at click time without
8
+ * having every island accept them as props.
9
+ */
10
+ export interface StepData {
11
+ tutorialSlug: string;
12
+ tutorialTitle: string;
13
+ stepSlug: string;
14
+ stepTitle: string;
15
+ stepSource: string;
16
+ }
17
+
18
+ export function readStepData(): StepData | null {
19
+ if (typeof document === "undefined") return null;
20
+ const node = document.getElementById("tt-step-data");
21
+ if (!node?.textContent) return null;
22
+ try {
23
+ return JSON.parse(node.textContent) as StepData;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Render a CopyPrompt template by substituting `{{placeholder}}`
31
+ * tokens with the values from step data. Unknown placeholders are
32
+ * left untouched so authors notice typos.
33
+ */
34
+ export function renderTemplate(template: string, data: StepData): string {
35
+ const map: Record<string, string> = {
36
+ tutorialTitle: data.tutorialTitle,
37
+ tutorialSlug: data.tutorialSlug,
38
+ stepTitle: data.stepTitle,
39
+ stepSlug: data.stepSlug,
40
+ stepSource: data.stepSource,
41
+ };
42
+ return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (raw, key) => {
43
+ return key in map ? map[key] : raw;
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Construct a minimal AssistantContext from client-side step data
49
+ * for Family B touchpoints that need to call buildAssistantPrompt
50
+ * without the build-time context that ChatButton holds. Fields we
51
+ * don't know client-side (difficulty, tags, outline, prior steps,
52
+ * progress) come back empty; the intents that Family B uses
53
+ * (explainStep, recap) only read tutorial + currentStep.
54
+ */
55
+ export function contextFromStepData(data: StepData): AssistantContext {
56
+ return {
57
+ tutorial: {
58
+ slug: data.tutorialSlug,
59
+ title: data.tutorialTitle,
60
+ description: "",
61
+ difficulty: "",
62
+ tags: [],
63
+ },
64
+ outline: [],
65
+ currentStep: {
66
+ slug: data.stepSlug,
67
+ title: data.stepTitle,
68
+ source: data.stepSource,
69
+ },
70
+ priorSteps: [],
71
+ progress: { completed: [], quizzes: [], checkpoints: [] },
72
+ references: [],
73
+ };
74
+ }
@@ -1,10 +1,12 @@
1
1
  import Callout from "../components/mdx/Callout.astro";
2
2
  import Checkpoint from "../components/mdx/Checkpoint.astro";
3
+ import CopyPrompt from "../components/mdx/CopyPrompt.astro";
3
4
  import Diff from "../components/mdx/Diff.astro";
4
5
  import Download from "../components/mdx/Download.astro";
5
6
  import Embed from "../components/mdx/Embed.astro";
6
7
  import File from "../components/mdx/File.astro";
7
8
  import FileTree from "../components/mdx/FileTree.astro";
9
+ import HelpMe from "../components/mdx/HelpMe.astro";
8
10
  import Hint from "../components/mdx/Hint.astro";
9
11
  import Mermaid from "../components/mdx/Mermaid.astro";
10
12
  import Playground from "../components/mdx/Playground.astro";
@@ -43,5 +45,7 @@ export function mdxComponents() {
43
45
  Quiz,
44
46
  Checkpoint,
45
47
  Playground,
48
+ HelpMe,
49
+ CopyPrompt,
46
50
  };
47
51
  }
@@ -70,6 +70,14 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
70
70
  for (const id of Object.keys(prev.checkpoints)) {
71
71
  if (!next.checkpoints[id]) {
72
72
  out.push({ kind: "checkpoint", scope: "global", key: id, value: null });
73
+ // Family D: drop the matching kind:"verification" telemetry
74
+ // row so a re-attempt isn't pre-poisoned by the previous
75
+ // failure feedback. Scope comes from the feedback entry
76
+ // populated by SSE.
77
+ const feedback = prev.verificationFeedback[id];
78
+ if (feedback) {
79
+ out.push({ kind: "verification", scope: feedback.scope, key: id, value: null });
80
+ }
73
81
  }
74
82
  }
75
83
  for (const [k, value] of Object.entries(next.prefs)) {
@@ -95,6 +103,59 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
95
103
  return out;
96
104
  }
97
105
 
106
+ /**
107
+ * Apply one server-side progress entry to a mutable state object.
108
+ * Shared by the initial snapshot fetch and the SSE per-event path.
109
+ * Mutates in place — callers replace the store atom after batching.
110
+ */
111
+ function applyEntryInto(state: ProgressState, e: ProgressEntry): void {
112
+ if (e.kind === "step") {
113
+ state.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
114
+ | "incomplete"
115
+ | "complete";
116
+ } else if (e.kind === "quiz") {
117
+ state.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
118
+ } else if (e.kind === "checkpoint") {
119
+ if (e.value == null) {
120
+ delete state.checkpoints[e.key];
121
+ return;
122
+ }
123
+ state.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
124
+ } else if (e.kind === "pref") {
125
+ (state.prefs as Record<string, unknown>)[e.key] = e.value;
126
+ } else if (e.kind === "lastVisited") {
127
+ const v = e.value as unknown;
128
+ state.lastVisited[e.scope] =
129
+ typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
130
+ } else if (e.kind === "tutorial") {
131
+ const v = (e.value as { ts?: number }) ?? {};
132
+ const marker = state.tutorials[e.scope] ?? {};
133
+ if (e.key === "started") marker.started = v.ts;
134
+ else if (e.key === "completed") marker.completed = v.ts;
135
+ state.tutorials[e.scope] = marker;
136
+ } else if (e.kind === "verification") {
137
+ if (e.value == null) {
138
+ delete state.verificationFeedback[e.key];
139
+ return;
140
+ }
141
+ const v = e.value as {
142
+ pass: boolean;
143
+ failingCheckIndex?: number;
144
+ reason?: string;
145
+ hint?: string;
146
+ ts?: number;
147
+ };
148
+ state.verificationFeedback[e.key] = {
149
+ scope: e.scope as `${string}/${string}`,
150
+ pass: !!v.pass,
151
+ failingCheckIndex: v.failingCheckIndex,
152
+ reason: v.reason,
153
+ hint: v.hint,
154
+ ts: v.ts ?? Date.now(),
155
+ };
156
+ }
157
+ }
158
+
98
159
  /**
99
160
  * Same shape as the local store, but mirrors writes to /api/progress with
100
161
  * debounced batching and an offline queue.
@@ -149,31 +210,7 @@ export function createRemoteStore(): ProgressStore {
149
210
  entries: Array<{ kind: string; scope: string; key: string; value: unknown }>;
150
211
  };
151
212
  const merged: ProgressState = { ...emptyState(), ...state };
152
- for (const e of entries) {
153
- if (e.kind === "step") {
154
- merged.steps[`${e.scope}/${e.key}` as `${string}/${string}`] = e.value as
155
- | "incomplete"
156
- | "complete";
157
- } else if (e.kind === "quiz") {
158
- merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
159
- } else if (e.kind === "checkpoint") {
160
- if (e.value == null) continue;
161
- merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
162
- } else if (e.kind === "pref") {
163
- (merged.prefs as Record<string, unknown>)[e.key] = e.value;
164
- } else if (e.kind === "lastVisited") {
165
- // Tolerate both new ({step, ts}) and legacy (string) shapes.
166
- const v = e.value as unknown;
167
- merged.lastVisited[e.scope] =
168
- typeof v === "string" ? { step: v, ts: 0 } : (v as { step: string; ts: number });
169
- } else if (e.kind === "tutorial") {
170
- const v = (e.value as { ts?: number }) ?? {};
171
- const marker = merged.tutorials[e.scope] ?? {};
172
- if (e.key === "started") marker.started = v.ts;
173
- else if (e.key === "completed") marker.completed = v.ts;
174
- merged.tutorials[e.scope] = marker;
175
- }
176
- }
213
+ for (const e of entries) applyEntryInto(merged, e);
177
214
  state = merged;
178
215
  writeStorage(state);
179
216
  for (const fn of subscribers) fn(state);
@@ -181,6 +218,30 @@ export function createRemoteStore(): ProgressStore {
181
218
  // ignore — local data still drives the UI
182
219
  }
183
220
  })();
221
+
222
+ // Live sync: subscribe to per-learner SSE so MCP-driven writes
223
+ // (or another tab via the cookie POST) show up immediately. The
224
+ // standard EventSource auto-reconnects on transient drops.
225
+ if (typeof EventSource !== "undefined") {
226
+ try {
227
+ const es = new EventSource("/api/progress/events", { withCredentials: true });
228
+ es.addEventListener("message", (ev) => {
229
+ try {
230
+ const entry = JSON.parse(ev.data) as ProgressEntry;
231
+ const next: ProgressState = { ...state };
232
+ applyEntryInto(next, entry);
233
+ state = next;
234
+ writeStorage(state);
235
+ for (const fn of subscribers) fn(state);
236
+ channel?.postMessage({ type: "set", state });
237
+ } catch (e) {
238
+ console.warn("[handzon] sse parse failed:", e);
239
+ }
240
+ });
241
+ } catch {
242
+ // ignore — polling-via-mount still keeps things eventually consistent
243
+ }
244
+ }
184
245
  }
185
246
 
186
247
  return {
@@ -16,10 +16,32 @@ export interface TutorialMarker {
16
16
  completed?: number;
17
17
  }
18
18
 
19
+ /**
20
+ * Family D verification feedback delivered via SSE from
21
+ * `submit_verification` failures. Keyed by the checkpoint id (which
22
+ * equals `verify.id`). Carries the step scope so the diff can emit
23
+ * a tombstone with the right scope when the learner unchecks.
24
+ */
25
+ export interface VerificationFeedbackEntry {
26
+ scope: StepKey;
27
+ pass: boolean;
28
+ failingCheckIndex?: number;
29
+ reason?: string;
30
+ hint?: string;
31
+ ts: number;
32
+ }
33
+
19
34
  export type ProgressState = {
20
35
  steps: Record<StepKey, "incomplete" | "complete">;
21
36
  quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
22
37
  checkpoints: Record<string, { ts: number }>;
38
+ /**
39
+ * Latest verification verdict per checkpoint id. `pass: true`
40
+ * entries hang around as evidence; the Family D UI only renders
41
+ * the inline hint block on `pass: false`. Cleared when the
42
+ * learner unchecks the matching checkpoint.
43
+ */
44
+ verificationFeedback: Record<string, VerificationFeedbackEntry>;
23
45
  prefs: {
24
46
  packageManager?: "npm" | "pnpm" | "yarn" | "bun";
25
47
  os?: "macos" | "linux" | "windows";
@@ -50,6 +72,7 @@ export const emptyState = (): ProgressState => ({
50
72
  steps: {},
51
73
  quizzes: {},
52
74
  checkpoints: {},
75
+ verificationFeedback: {},
53
76
  prefs: {},
54
77
  lastVisited: {},
55
78
  tutorials: {},
@@ -59,10 +59,14 @@ export function useProgress(): ProgressApi {
59
59
  })),
60
60
  removeCheckpoint: (checkpointId: string) =>
61
61
  store.set((s) => {
62
- if (!s.checkpoints[checkpointId]) return s;
63
- const next = { ...s.checkpoints };
64
- delete next[checkpointId];
65
- return { ...s, checkpoints: next };
62
+ const hadCheckpoint = !!s.checkpoints[checkpointId];
63
+ const hadFeedback = !!s.verificationFeedback[checkpointId];
64
+ if (!hadCheckpoint && !hadFeedback) return s;
65
+ const nextCheckpoints = { ...s.checkpoints };
66
+ delete nextCheckpoints[checkpointId];
67
+ const nextFeedback = { ...s.verificationFeedback };
68
+ delete nextFeedback[checkpointId];
69
+ return { ...s, checkpoints: nextCheckpoints, verificationFeedback: nextFeedback };
66
70
  }),
67
71
  setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
68
72
  store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
@@ -60,6 +60,7 @@ for (const t of tutorials) {
60
60
  repoUrl={repoUrl}
61
61
  nav="userMenu"
62
62
  >
63
+ <slot name="head" slot="head" />
63
64
  <div class="home">
64
65
  <Hero title={hero?.title} subtitle={hero?.subtitle} logoUrl={logoUrl} />
65
66
 
@@ -238,7 +239,12 @@ for (const t of tutorials) {
238
239
  }
239
240
  .grid {
240
241
  display: grid;
241
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
242
+ /* Cap at 3 columns max: the lower-bound of each track is the larger
243
+ * of 280px or one-third of the row (minus the two 1rem gaps). On
244
+ * wide viewports the (100% - 2rem)/3 term wins and forces exactly
245
+ * 3 columns; on narrow viewports 280px wins and the grid wraps to
246
+ * 2 or 1 columns as usual. */
247
+ grid-template-columns: repeat(auto-fill, minmax(max(280px, (100% - 2rem) / 3), 1fr));
242
248
  gap: 1rem;
243
249
  margin-top: 0.75rem;
244
250
  }
@@ -26,6 +26,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
26
26
  faviconUrl={faviconUrl}
27
27
  repoUrl={repoUrl}
28
28
  >
29
+ <slot name="head" slot="head" />
29
30
  <div class="landing">
30
31
  <a class="back" href="/">← All tutorials</a>
31
32
 
@@ -58,7 +59,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
58
59
 
59
60
  {tutorial.data.tags.length > 0 && (
60
61
  <div class="hero-tags" aria-label="Topics">
61
- {tutorial.data.tags.map((tag) => (
62
+ {tutorial.data.tags.map((tag: string) => (
62
63
  <a class="hero-tag" href={`/?tag=${encodeURIComponent(tag)}`}>#{tag}</a>
63
64
  ))}
64
65
  </div>
@@ -68,7 +69,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
68
69
  {tutorial.data.prerequisites.length > 0 && (
69
70
  <section class="block">
70
71
  <h2>Prerequisites</h2>
71
- <ul class="prereqs">{tutorial.data.prerequisites.map((p) => <li>{p}</li>)}</ul>
72
+ <ul class="prereqs">{tutorial.data.prerequisites.map((p: string) => <li>{p}</li>)}</ul>
72
73
  </section>
73
74
  )}
74
75
 
@@ -188,10 +189,11 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
188
189
  }
189
190
  .hero-tag:hover { color: var(--color-accent); }
190
191
  h1 {
192
+ font-family: var(--font-display, var(--font-sans));
191
193
  font-size: clamp(2.1rem, 4.5vw, 3rem);
192
- font-weight: 800;
194
+ font-weight: var(--font-weight-display, 800);
193
195
  margin: 0 0 0.75rem;
194
- letter-spacing: -0.025em;
196
+ letter-spacing: var(--tracking-display, -0.025em);
195
197
  line-height: 1.05;
196
198
  }
197
199
  .desc {
@@ -2,6 +2,9 @@
2
2
  import { render } from "astro:content";
3
3
  import TutorialLayout from "../layouts/TutorialLayout.astro";
4
4
  import ChatButton from "../components/ai/ChatButton.tsx";
5
+ import OpenInAgent from "../components/ai/OpenInAgent.tsx";
6
+ import SelectionAsk from "../components/ai/SelectionAsk.tsx";
7
+ import StepHelp from "../components/ai/StepHelp.tsx";
5
8
  import { parseStepId } from "../lib/content.ts";
6
9
  import type { StepEntry, TutorialEntry } from "../lib/content.ts";
7
10
  import { mdxComponents } from "../lib/mdx-components.ts";
@@ -60,8 +63,17 @@ const initialContext = buildContext({
60
63
  faviconUrl={faviconUrl}
61
64
  repoUrl={repoUrl}
62
65
  >
66
+ <slot name="head" slot="head" />
63
67
  <Content components={components} />
68
+ {aiConfig.enabled && aiConfig.autoStepHelp && (
69
+ <StepHelp client:visible stepTitle={currentStep.data.title} />
70
+ )}
71
+ <OpenInAgent client:visible />
72
+
64
73
  {aiConfig.enabled && (
65
- <ChatButton client:idle config={aiConfig} context={initialContext} />
74
+ <>
75
+ <ChatButton client:idle config={aiConfig} context={initialContext} />
76
+ <SelectionAsk client:idle />
77
+ </>
66
78
  )}
67
79
  </TutorialLayout>
@@ -2,7 +2,7 @@ import type { AstroCookieSetOptions, AstroCookies } from "astro";
2
2
  import { and, eq, isNull } from "drizzle-orm";
3
3
  import { getAuthedUser } from "./auth/session.ts";
4
4
  import { getDb } from "./db/client.ts";
5
- import { learners, progressEntries } from "./db/schema.ts";
5
+ import { learnerApiTokens, learners, progressEntries } from "./db/schema.ts";
6
6
 
7
7
  const COOKIE = "tt-device";
8
8
  const ONE_YEAR = 60 * 60 * 24 * 365;
@@ -121,3 +121,86 @@ async function maybeClaimDeviceProgress(
121
121
  await tx.delete(learners).where(eq(learners.id, orphan.id));
122
122
  });
123
123
  }
124
+
125
+ const PAT_PREFIX = "hzn_pat_";
126
+ const PAT_RANDOM_BYTES = 32;
127
+
128
+ /** Hash a raw PAT string to its database form. SHA-256 hex. */
129
+ export async function hashPat(raw: string): Promise<string> {
130
+ const data = new TextEncoder().encode(raw);
131
+ const digest = await crypto.subtle.digest("SHA-256", data);
132
+ return Array.from(new Uint8Array(digest))
133
+ .map((b) => b.toString(16).padStart(2, "0"))
134
+ .join("");
135
+ }
136
+
137
+ /**
138
+ * Generate a fresh PAT to show the learner once. The settings page is
139
+ * the only caller — the API surface never sees the raw token after
140
+ * mint, and only the hash hits the database.
141
+ */
142
+ export function generatePat(): string {
143
+ const bytes = new Uint8Array(PAT_RANDOM_BYTES);
144
+ crypto.getRandomValues(bytes);
145
+ // Base64url without padding — agent config files want short, copy/pasteable tokens.
146
+ const b64 = btoa(String.fromCharCode(...bytes))
147
+ .replace(/\+/g, "-")
148
+ .replace(/\//g, "_")
149
+ .replace(/=+$/, "");
150
+ return `${PAT_PREFIX}${b64}`;
151
+ }
152
+
153
+ /**
154
+ * Resolve a bearer token presented on an incoming request to the
155
+ * learner that owns it. Returns null when no token, the token is
156
+ * unknown, or it has expired. `last_used_at` is touched
157
+ * asynchronously so a slow DB write doesn't add latency to MCP calls.
158
+ *
159
+ * Used by the MCP endpoint; the cookie-based progress endpoint stays
160
+ * on getOrCreateLearner so the same-origin guard remains effective.
161
+ */
162
+ export async function resolveBearerLearner(
163
+ request: Request,
164
+ ): Promise<{ learnerId: string; scopes: string[] } | null> {
165
+ const header = request.headers.get("authorization") ?? request.headers.get("Authorization");
166
+ if (!header) return null;
167
+ const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
168
+ if (!match) return null;
169
+ const raw = match[1]!;
170
+ if (!raw.startsWith(PAT_PREFIX)) return null;
171
+
172
+ const db = getDb();
173
+ const hash = await hashPat(raw);
174
+ const rows = await db
175
+ .select()
176
+ .from(learnerApiTokens)
177
+ .where(eq(learnerApiTokens.tokenHash, hash))
178
+ .limit(1);
179
+ const token = rows[0];
180
+ if (!token) return null;
181
+ if (token.expiresAt && token.expiresAt.getTime() < Date.now()) return null;
182
+
183
+ const learnerRow = await db
184
+ .select({ id: learners.id })
185
+ .from(learners)
186
+ .where(eq(learners.userId, token.userId))
187
+ .limit(1);
188
+ const learner = learnerRow[0];
189
+ if (!learner) return null;
190
+
191
+ // Fire-and-forget last-used touch. Failures are logged-but-ignored;
192
+ // an audit log is more useful than blocking the call.
193
+ void db
194
+ .update(learnerApiTokens)
195
+ .set({ lastUsedAt: new Date() })
196
+ .where(eq(learnerApiTokens.id, token.id))
197
+ .catch((e) => {
198
+ console.warn("[handzon] failed to update PAT last_used_at:", e);
199
+ });
200
+
201
+ const scopes = token.scopes
202
+ .split(",")
203
+ .map((s) => s.trim())
204
+ .filter(Boolean);
205
+ return { learnerId: learner.id, scopes };
206
+ }
@@ -40,6 +40,59 @@ export const learners = pgTable(
40
40
  }),
41
41
  );
42
42
 
43
+ /**
44
+ * Personal access tokens for the per-user MCP surface.
45
+ *
46
+ * Created by the settings/tokens page in the scaffold template. Stored
47
+ * as SHA-256 hashes of `hzn_pat_<32 base64url bytes>` strings — the
48
+ * raw token is shown to the learner once at mint time and never
49
+ * persisted. The MCP v2 OAuth proxy reuses this table, so the row
50
+ * shape is forward-compatible with DCR-minted tokens.
51
+ *
52
+ * scopes: comma-separated; v1 known values are "progress:read" and
53
+ * "progress:write". Catalog reads don't require any scope beyond a
54
+ * valid token.
55
+ */
56
+ export const learnerApiTokens = pgTable("learner_api_tokens", {
57
+ id: uuid("id").primaryKey().defaultRandom(),
58
+ userId: uuid("user_id")
59
+ .notNull()
60
+ .references(() => users.id, { onDelete: "cascade" }),
61
+ name: text("name").notNull(),
62
+ tokenHash: text("token_hash").notNull().unique(),
63
+ scopes: text("scopes").notNull(),
64
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
65
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
66
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
67
+ });
68
+
69
+ /**
70
+ * Help-bridge inbox: pending help requests posted by the agent on
71
+ * the learner's machine (`request_help` MCP tool). ChatPanel reads
72
+ * pending rows on open, prepends them as a user turn, and marks
73
+ * them consumed so the next open doesn't re-replay them.
74
+ */
75
+ export const helpRequests = pgTable(
76
+ "help_requests",
77
+ {
78
+ id: uuid("id").primaryKey().defaultRandom(),
79
+ learnerId: uuid("learner_id")
80
+ .notNull()
81
+ .references(() => learners.id, { onDelete: "cascade" }),
82
+ tutorialSlug: text("tutorial_slug").notNull(),
83
+ stepSlug: text("step_slug").notNull(),
84
+ query: text("query").notNull(),
85
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
86
+ consumedAt: timestamp("consumed_at", { withTimezone: true }),
87
+ },
88
+ (table) => ({
89
+ byLearnerPending: index("help_requests_by_learner_pending").on(
90
+ table.learnerId,
91
+ table.consumedAt,
92
+ ),
93
+ }),
94
+ );
95
+
43
96
  export const progressEntries = pgTable(
44
97
  "progress_entries",
45
98
  {
@@ -0,0 +1,45 @@
1
+ import type { APIRoute } from "astro";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
+ import { getOrCreateLearner } from "../auth.ts";
4
+ import { getDb } from "../db/client.ts";
5
+ import { helpRequests } from "../db/schema.ts";
6
+ import { isSameOrigin, json } from "../http.ts";
7
+
8
+ /**
9
+ * Returns the learner's pending help-request inbox (rows where
10
+ * consumed_at IS NULL) and stamps them as consumed in one
11
+ * round-trip. ChatPanel calls this when it opens; the response
12
+ * becomes the seed user turns that get auto-streamed.
13
+ *
14
+ * Same-origin guarded — only the in-browser ChatPanel reads the
15
+ * inbox. The MCP-side `request_help` tool produces rows; this
16
+ * endpoint consumes them.
17
+ */
18
+ export const GET: APIRoute = async ({ cookies, request }) => {
19
+ if (!process.env.DATABASE_URL) return json({ requests: [] });
20
+ if (!isSameOrigin(request)) {
21
+ return json({ error: "Cross-origin read rejected." }, { status: 403 });
22
+ }
23
+ const learner = await getOrCreateLearner(cookies, request);
24
+ const db = getDb();
25
+ const rows = await db
26
+ .select()
27
+ .from(helpRequests)
28
+ .where(and(eq(helpRequests.learnerId, learner.id), isNull(helpRequests.consumedAt)));
29
+ if (rows.length > 0) {
30
+ const now = new Date();
31
+ await db
32
+ .update(helpRequests)
33
+ .set({ consumedAt: now })
34
+ .where(and(eq(helpRequests.learnerId, learner.id), isNull(helpRequests.consumedAt)));
35
+ }
36
+ return json({
37
+ requests: rows.map((r) => ({
38
+ id: r.id,
39
+ tutorialSlug: r.tutorialSlug,
40
+ stepSlug: r.stepSlug,
41
+ query: r.query,
42
+ createdAt: r.createdAt.toISOString(),
43
+ })),
44
+ });
45
+ };
@@ -0,0 +1,72 @@
1
+ import type { APIRoute } from "astro";
2
+ import { resolveBearerLearner } from "../auth.ts";
3
+ import type { JsonRpcRequest } from "../mcp/protocol.ts";
4
+ import { type DispatchOptions, dispatchMcp } from "../mcp/server.ts";
5
+ import { defaultTools } from "../mcp/tools.ts";
6
+
7
+ const MAX_BODY_BYTES = 64 * 1024;
8
+
9
+ /**
10
+ * Build an Astro POST handler that mounts the MCP JSON-RPC dispatcher
11
+ * with the given tool set. Scaffold templates re-export this from
12
+ * `src/pages/api/mcp/index.ts` so updates land for every site
13
+ * without a code change in the consumer.
14
+ *
15
+ * GET /api/mcp returns a tiny capability descriptor that some
16
+ * clients ping before issuing JSON-RPC.
17
+ */
18
+ export function createMcpHandler(
19
+ opts: DispatchOptions = { tools: defaultTools, resolveAuth: resolveBearerLearner },
20
+ ): {
21
+ GET: APIRoute;
22
+ POST: APIRoute;
23
+ } {
24
+ const GET: APIRoute = async () =>
25
+ new Response(
26
+ JSON.stringify({
27
+ ok: true,
28
+ transport: "http+json",
29
+ message: "POST a JSON-RPC 2.0 request to invoke MCP tools.",
30
+ }),
31
+ { headers: { "Content-Type": "application/json" } },
32
+ );
33
+
34
+ const POST: APIRoute = async ({ request }) => {
35
+ const lengthHeader = request.headers.get("content-length");
36
+ if (lengthHeader && Number(lengthHeader) > MAX_BODY_BYTES) {
37
+ return jsonRpcHttp({
38
+ jsonrpc: "2.0",
39
+ id: null,
40
+ error: { code: -32600, message: "Payload too large." },
41
+ });
42
+ }
43
+ const raw = await request.text();
44
+ if (raw.length > MAX_BODY_BYTES) {
45
+ return jsonRpcHttp({
46
+ jsonrpc: "2.0",
47
+ id: null,
48
+ error: { code: -32600, message: "Payload too large." },
49
+ });
50
+ }
51
+ let body: JsonRpcRequest;
52
+ try {
53
+ body = JSON.parse(raw) as JsonRpcRequest;
54
+ } catch {
55
+ return jsonRpcHttp({
56
+ jsonrpc: "2.0",
57
+ id: null,
58
+ error: { code: -32700, message: "Parse error." },
59
+ });
60
+ }
61
+ const response = await dispatchMcp(request, body, opts);
62
+ return jsonRpcHttp(response);
63
+ };
64
+
65
+ return { GET, POST };
66
+ }
67
+
68
+ function jsonRpcHttp(body: unknown): Response {
69
+ return new Response(JSON.stringify(body), {
70
+ headers: { "Content-Type": "application/json" },
71
+ });
72
+ }