replay-labs 0.1.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.
- package/LICENSE +22 -0
- package/README.md +134 -0
- package/examples/password-reset-transcript.md +27 -0
- package/examples/password-reset.diff +101 -0
- package/package.json +47 -0
- package/scripts/capture-git-working-diff.js +56 -0
- package/scripts/create-added-files-diff.js +33 -0
- package/scripts/extract-claude-transcript.js +86 -0
- package/scripts/extract-codex-transcript.js +119 -0
- package/src/cli.js +316 -0
- package/src/discovery.js +715 -0
- package/src/generate.js +406 -0
- package/src/ingest.js +124 -0
- package/src/interaction.js +1161 -0
- package/src/lab-ui.js +1339 -0
- package/src/modules.js +643 -0
- package/src/overview.js +147 -0
- package/src/patterns.js +322 -0
- package/src/pipeline.js +68 -0
- package/src/report.js +516 -0
- package/src/review.js +238 -0
- package/src/server.js +199 -0
- package/src/storage.js +34 -0
package/src/modules.js
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
// Lab module catalog. Each rich module turns one session decision into a full
|
|
2
|
+
// four-stage lab: diagnose -> break (+ failure simulation) -> repair (editor +
|
|
3
|
+
// real review) -> transfer (plan + real review).
|
|
4
|
+
|
|
5
|
+
export const MODULES = {
|
|
6
|
+
"next-client-boundary": buildRuntimeBoundary,
|
|
7
|
+
"api-secret-boundary": buildSecretBoundary
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function buildLabModule(decision) {
|
|
11
|
+
const builder = MODULES[decision.id];
|
|
12
|
+
return builder ? builder(decision) : buildGenericModule(decision);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildRuntimeBoundary() {
|
|
16
|
+
return {
|
|
17
|
+
id: "runtime-boundary",
|
|
18
|
+
name: "Runtime Boundary",
|
|
19
|
+
minutes: 8,
|
|
20
|
+
why:
|
|
21
|
+
"The session used browser-only APIs such as speech synthesis, microphone input, and localStorage inside a Next.js app. That makes the client/server boundary the central decision.",
|
|
22
|
+
takeaway:
|
|
23
|
+
"When code depends on browser APIs, decide the runtime boundary before you design the component.",
|
|
24
|
+
naive:
|
|
25
|
+
"Use browser APIs directly in a component without deciding whether the component runs on the server or in the browser.",
|
|
26
|
+
naiveFile: "app/page.tsx",
|
|
27
|
+
naiveCode: `export default function Page() {
|
|
28
|
+
const recognition = new window.SpeechRecognition();
|
|
29
|
+
localStorage.setItem("goals", "[]");
|
|
30
|
+
}`,
|
|
31
|
+
breaks:
|
|
32
|
+
"`window`, microphone APIs, speech synthesis, and localStorage do not exist during server rendering. Even in the browser, unsupported APIs and denied permissions need a designed fallback.",
|
|
33
|
+
aiVersion:
|
|
34
|
+
"The AI put the main voice experience behind a client boundary with `'use client'` and kept browser behavior in `app/page.tsx`.",
|
|
35
|
+
production:
|
|
36
|
+
"Keep the client boundary, add capability checks, show unsupported-browser and permission-denied states, and consider server-driven voice if reliability matters more than browser-native speed.",
|
|
37
|
+
exercise:
|
|
38
|
+
"Create a scratch version without the client boundary, predict the failure, then add a browser-support fallback before restoring the working design.",
|
|
39
|
+
patternHref: "patterns/runtime-boundary.html",
|
|
40
|
+
challenge: {
|
|
41
|
+
pattern: "Runtime Boundary",
|
|
42
|
+
patternCopy: "A professional decision about which environment owns a behavior.",
|
|
43
|
+
smell: "Browser API leak",
|
|
44
|
+
smellCopy: "Server-rendered code reaches for window, localStorage, microphone, or speech APIs.",
|
|
45
|
+
proof: "Transfer, not recall",
|
|
46
|
+
proofCopy: "You pass only when you can apply the same rule to a different browser-capability feature."
|
|
47
|
+
},
|
|
48
|
+
criteria: {
|
|
49
|
+
diagnose: "Find the decision inside the real diff before any explanation appears.",
|
|
50
|
+
break: "Trace the naive version and click the line where execution dies.",
|
|
51
|
+
repair: "Real review. Required: client boundary, capability guards, designed unsupported state. Seal it with permission-denied handling or named verification.",
|
|
52
|
+
transfer: "Real review. Required: boundary isolation, capability checks, failure states. Seal it with ownership reasoning or verification beyond dev."
|
|
53
|
+
},
|
|
54
|
+
reviewCriteria: {
|
|
55
|
+
repair: ["Client boundary", "Capability guards", "Unsupported state", "Permission denial", "Verification"],
|
|
56
|
+
transfer: ["Boundary isolation", "Capability checks", "Failure states", "Ownership reasoning", "Verification"]
|
|
57
|
+
},
|
|
58
|
+
artifact: {
|
|
59
|
+
failure: "Server-rendered code touches browser globals such as window, speechSynthesis, microphone APIs, or localStorage.",
|
|
60
|
+
standard: "Keep browser behavior inside a client boundary, add capability and permission fallbacks, and verify those states."
|
|
61
|
+
},
|
|
62
|
+
nextPatterns: [
|
|
63
|
+
{ name: "Secret Boundary", copy: "Keep credentials behind API routes.", href: "secret-boundary.html" },
|
|
64
|
+
{ name: "Demo Persistence", copy: "When a JSON file is the honest choice.", href: null }
|
|
65
|
+
],
|
|
66
|
+
lenses: {
|
|
67
|
+
diagnose: {
|
|
68
|
+
title: "Look for the decision type",
|
|
69
|
+
items: ["Browser-only APIs", "Next.js runtime boundary", "Evidence that this is not only styling or data modeling"]
|
|
70
|
+
},
|
|
71
|
+
break: {
|
|
72
|
+
title: "Look for the first failure",
|
|
73
|
+
items: ["Any reference to window", "APIs that only exist in the browser", "Code that would execute before the page reaches a user"]
|
|
74
|
+
},
|
|
75
|
+
repair: {
|
|
76
|
+
title: "Look for the shipping gap",
|
|
77
|
+
items: ["Unsupported browser behavior", "Denied microphone permission", "A verification path for failure states"]
|
|
78
|
+
},
|
|
79
|
+
transfer: {
|
|
80
|
+
title: "Look for the reusable pattern",
|
|
81
|
+
items: ["New browser capabilities", "Client/server ownership", "Fallback states beyond local success"]
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
diagnose: {
|
|
85
|
+
prompt: "What kind of decision did the AI make when it added `'use client'` to the voice experience?",
|
|
86
|
+
choices: [
|
|
87
|
+
{
|
|
88
|
+
label: "Runtime boundary",
|
|
89
|
+
description: "It decided which parts of the feature must run in the browser instead of on the server.",
|
|
90
|
+
feedback: "Correct. This is a client/server runtime boundary decision, not just a Next.js syntax detail.",
|
|
91
|
+
correct: true
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
label: "Styling architecture",
|
|
95
|
+
description: "It chose how the UI should be organized visually.",
|
|
96
|
+
feedback: "Not quite. The evidence is about browser APIs and rendering environment, not visual structure.",
|
|
97
|
+
correct: false
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
label: "Database modeling",
|
|
101
|
+
description: "It chose how goals should be stored.",
|
|
102
|
+
feedback: "That is another decision in the session, but it does not explain `'use client'`.",
|
|
103
|
+
correct: false
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
break: {
|
|
108
|
+
prompt: "If this naive version runs during server rendering, what is the most important failure?",
|
|
109
|
+
choices: [
|
|
110
|
+
{
|
|
111
|
+
label: "`window` is undefined",
|
|
112
|
+
description: "Server rendering has no browser globals, so the code can fail before the user reaches the page.",
|
|
113
|
+
feedback: "Correct. The first failure is runtime ownership: the server cannot access browser globals.",
|
|
114
|
+
correct: true
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: "The CSS bundle gets larger",
|
|
118
|
+
description: "Bundle size may matter, but it is not the core failure here.",
|
|
119
|
+
feedback: "Not the primary issue. The problem happens before styling performance matters.",
|
|
120
|
+
correct: false
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
label: "The database loses goals",
|
|
124
|
+
description: "Persistence is a separate risk, not the reason this code needs a client boundary.",
|
|
125
|
+
feedback: "Different decision. Here the evidence points to browser-only APIs.",
|
|
126
|
+
correct: false
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
failureSim: {
|
|
131
|
+
terminal: `$ next build
|
|
132
|
+
|
|
133
|
+
Creating an optimized production build ...
|
|
134
|
+
✓ Compiled successfully
|
|
135
|
+
|
|
136
|
+
Generating static pages (0/3) ...
|
|
137
|
+
ReferenceError: window is not defined
|
|
138
|
+
at Page (app/page.tsx:2:28)
|
|
139
|
+
at renderToHTML (node_modules/next/dist/server/render.js:387:14)
|
|
140
|
+
|
|
141
|
+
> Export encountered an error on /page: /, exiting the build.`,
|
|
142
|
+
narration:
|
|
143
|
+
"Nothing about the code changed — the runtime executing it did. All afternoon this file ran in your browser. The build ran it in Node, where `window` has never existed. That is the whole pattern.",
|
|
144
|
+
arbitrate: {
|
|
145
|
+
intro: "Two engineers read the same stack trace. Click the review you would approve.",
|
|
146
|
+
comments: [
|
|
147
|
+
{
|
|
148
|
+
handle: "iyke.dev",
|
|
149
|
+
text: "Next.js must have broken `window` in this release. Pin the previous version, ship, and file an issue upstream.",
|
|
150
|
+
correct: false,
|
|
151
|
+
verdict: "Rejecting this one matters: `window` is fine — in the browser. The stack trace names where this actually ran: `renderToHTML`, on the server. Pinning versions would chase a ghost while every build keeps failing."
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
handle: "ada.builds",
|
|
155
|
+
text: "Nothing broke. The build prerenders this file in Node, where `window` has never existed. The fix is deciding the runtime boundary, not the framework version.",
|
|
156
|
+
correct: true,
|
|
157
|
+
verdict: "Approved — and that is the whole pattern. The code never changed; the runtime executing it did. Boundary first, then the component design."
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
},
|
|
161
|
+
prompt:
|
|
162
|
+
"This exact code worked all afternoon in the browser. Why does the error appear only at build time?",
|
|
163
|
+
choices: [
|
|
164
|
+
{
|
|
165
|
+
label: "The code now executes on the server first",
|
|
166
|
+
description: "Build-time prerendering runs the component in Node, where browser globals do not exist.",
|
|
167
|
+
feedback: "Correct. Nothing about the code changed — the runtime that executes it changed. That is the whole pattern.",
|
|
168
|
+
correct: true
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
label: "Next.js deprecated window in this version",
|
|
172
|
+
description: "The framework removed access to the window object.",
|
|
173
|
+
feedback: "No. window is fine — in the browser. The error names the place this ran: renderToHTML, on the server.",
|
|
174
|
+
correct: false
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
label: "The browser blocked the speech API for security",
|
|
178
|
+
description: "Permission policies stopped SpeechRecognition.",
|
|
179
|
+
feedback: "Permissions fail differently and at click-time. Read the stack trace: this never reached a browser.",
|
|
180
|
+
correct: false
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
},
|
|
184
|
+
spot: {
|
|
185
|
+
prompt: "Find the line in this diff that shows where the code is allowed to run.",
|
|
186
|
+
targetRe: "use client",
|
|
187
|
+
targets: [
|
|
188
|
+
{ re: "['\"]use client['\"]", note: "the directive itself — it grants every line below it a browser to run in. That is the decision: runtime ownership." },
|
|
189
|
+
{ re: "window\\.|SpeechRecognition|speechSynthesis|localStorage|navigator\\.", note: "a line inside the blast radius — it only works *because* of that directive. Move it server-side and it throws." }
|
|
190
|
+
],
|
|
191
|
+
hit: "You traced the whole decision: the `'use client'` directive AND the browser-only lines that depend on it. That dependency is the pattern — the directive is a promise the lines below rely on.",
|
|
192
|
+
misses: [
|
|
193
|
+
{ re: "SpeechRecognition|speechSynthesis|localStorage|window\\.", note: "That is a browser API — the consequence. The decision is the directive that grants this file a browser to run in." },
|
|
194
|
+
{ re: "useState|useEffect|import", note: "Framework plumbing. It works the same on either side of the boundary. Look for the line that chooses the side." }
|
|
195
|
+
],
|
|
196
|
+
missDefault: "That line rides on the decision. Look for the directive that changes WHERE this file runs."
|
|
197
|
+
},
|
|
198
|
+
investigate: {
|
|
199
|
+
prompt: "Now the boundary is gone. Trace it yourself: click the first line that throws during server rendering.",
|
|
200
|
+
targetLine: 2,
|
|
201
|
+
hit: "Line 2 — execution dies at `new window.SpeechRecognition()` before localStorage is ever reached. And notice what is missing above it: no `'use client'` directive, so the server owns this whole file.",
|
|
202
|
+
misses: {
|
|
203
|
+
"3": "`localStorage` would also throw — but execution never gets there. Which line dies first?",
|
|
204
|
+
"1": "The function signature is harmless on a server. The crash comes from the first touch of a browser global."
|
|
205
|
+
},
|
|
206
|
+
missDefault: "That line survives on a server. Look for the first touch of a browser-only global."
|
|
207
|
+
},
|
|
208
|
+
repairLab: {
|
|
209
|
+
filename: "app/page.tsx — your repair",
|
|
210
|
+
instructions:
|
|
211
|
+
"Edit until you would ship it. Comments and pseudo-code count for the design states (`// denied -> mic-help with retry`) — only the boundary and the guards must be real code. This is judgment, not typing practice.",
|
|
212
|
+
starter: `export default function Page() {
|
|
213
|
+
const recognition = new window.SpeechRecognition();
|
|
214
|
+
localStorage.setItem("goals", "[]");
|
|
215
|
+
}`,
|
|
216
|
+
blocks: [
|
|
217
|
+
{ code: "const recognition = new window.SpeechRecognition();\n// module scope — runs wherever the file loads, including the server", trap: true },
|
|
218
|
+
{ code: "'use client';\nimport { useEffect, useState } from \"react\";" },
|
|
219
|
+
{ code: "type VoiceState = \"ready\" | \"listening\" | \"unsupported\" | \"denied\";\n\nexport default function Page() {\n const [state, setState] = useState<VoiceState>(\"ready\");" },
|
|
220
|
+
{ code: " useEffect(() => {\n const R = window.SpeechRecognition ?? window.webkitSpeechRecognition;\n if (!R) setState(\"unsupported\");\n }, []);" },
|
|
221
|
+
{ code: "export const dynamic = \"force-static\";", trap: true },
|
|
222
|
+
{ code: " async function start() {\n try {\n await navigator.mediaDevices.getUserMedia({ audio: true });\n setState(\"listening\");\n } catch {\n setState(\"denied\");\n }\n }" },
|
|
223
|
+
{ code: " // denied is rare — skip the UI for it, a console.warn is enough", trap: true },
|
|
224
|
+
{ code: " if (state === \"unsupported\") return <TypedCheckin />;\n if (state === \"denied\") return <MicHelp onRetry={start} />;\n return <VoiceCheckin onStart={start} listening={state === \"listening\"} />;\n}" },
|
|
225
|
+
{ code: "// verify: e2e with mic denied + one run in Firefox (no SpeechRecognition)" }
|
|
226
|
+
],
|
|
227
|
+
solution: `'use client';
|
|
228
|
+
import { useEffect, useState } from "react";
|
|
229
|
+
|
|
230
|
+
type VoiceState = "ready" | "listening" | "unsupported" | "denied";
|
|
231
|
+
|
|
232
|
+
export default function Page() {
|
|
233
|
+
const [state, setState] = useState<VoiceState>("ready");
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const R = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
|
237
|
+
if (!R) setState("unsupported");
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
async function start() {
|
|
241
|
+
try {
|
|
242
|
+
await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
243
|
+
setState("listening");
|
|
244
|
+
} catch {
|
|
245
|
+
setState("denied");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (state === "unsupported") return <TypedCheckin />;
|
|
250
|
+
if (state === "denied") return <MicHelp onRetry={start} />;
|
|
251
|
+
return <VoiceCheckin onStart={start} listening={state === "listening"} />;
|
|
252
|
+
}
|
|
253
|
+
// verify: e2e with mic denied + one run in Firefox (no SpeechRecognition)`
|
|
254
|
+
},
|
|
255
|
+
repair: { prompt: "Repair it so you would ship it beyond a demo.", choices: [] },
|
|
256
|
+
transferLab: {
|
|
257
|
+
instructions:
|
|
258
|
+
"Capture the handoff rule — four quick answers, a sentence each is enough.",
|
|
259
|
+
placeholder: "",
|
|
260
|
+
fields: [
|
|
261
|
+
{ key: "boundary", label: "Where does the browser-only work live?", ph: "a client component that…",
|
|
262
|
+
chips: ["A client component isolates the geolocation/camera work; the dashboard page stays server-rendered.",
|
|
263
|
+
"An API route handles the camera and location work.",
|
|
264
|
+
"Keep it in the page — it already works in dev."] },
|
|
265
|
+
{ key: "checks", label: "What do you check before using the APIs?", ph: "feature-detect geolocation/camera, then…",
|
|
266
|
+
chips: ["Feature-detect geolocation and getUserMedia before use; query permissions where available.",
|
|
267
|
+
"Wrap everything in a try/catch and move on.",
|
|
268
|
+
"Nothing — modern browsers all support these."] },
|
|
269
|
+
{ key: "failures", label: "Denied or unsupported — what does the user see?", ph: "a designed state that…",
|
|
270
|
+
chips: ["Designed states: denied shows manual entry with a retry; unsupported shows the no-camera path.",
|
|
271
|
+
"An alert() explaining the error.",
|
|
272
|
+
"It will rarely happen, so a blank widget is fine."] },
|
|
273
|
+
{ key: "verify", label: "How do you verify before calling it shipped?", ph: "an e2e run with…",
|
|
274
|
+
chips: ["An e2e run with location and camera denied, plus one browser without the APIs.",
|
|
275
|
+
"Click through it once locally before merging.",
|
|
276
|
+
"The AI tested it while building."] }
|
|
277
|
+
]
|
|
278
|
+
},
|
|
279
|
+
transfer: {
|
|
280
|
+
prompt: "Apply the same judgment to a new feature.",
|
|
281
|
+
scenario:
|
|
282
|
+
"A future AI session adds geolocation, camera capture, and localStorage to a Next.js dashboard. The feature works in the browser during development.",
|
|
283
|
+
rule:
|
|
284
|
+
"If the feature depends on browser-only capabilities, isolate that behavior behind a client boundary, design fallback states, and keep server code free of browser globals.",
|
|
285
|
+
choices: []
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildSecretBoundary() {
|
|
291
|
+
return {
|
|
292
|
+
id: "secret-boundary",
|
|
293
|
+
name: "Secret Boundary",
|
|
294
|
+
minutes: 7,
|
|
295
|
+
why:
|
|
296
|
+
"The session needed Anthropic and Twilio calls. Both require credentials a browser must never hold, so the work went behind Next.js API routes — the boundary between demo UI and trusted server work.",
|
|
297
|
+
takeaway:
|
|
298
|
+
"A secret's home decides the architecture: anything the browser can read, every visitor owns.",
|
|
299
|
+
naive:
|
|
300
|
+
"Call the model provider directly from client code with the key in a NEXT_PUBLIC env var, because it feels simpler than adding a route.",
|
|
301
|
+
naiveFile: "app/lib/ask.ts",
|
|
302
|
+
naiveCode: `"use client";
|
|
303
|
+
const KEY = process.env.NEXT_PUBLIC_ANTHROPIC_KEY;
|
|
304
|
+
|
|
305
|
+
export async function askCoach(prompt: string) {
|
|
306
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: { "x-api-key": KEY ?? "" },
|
|
309
|
+
body: JSON.stringify({ model: "claude-sonnet-4-6",
|
|
310
|
+
max_tokens: 300, messages: [{ role: "user", content: prompt }] })
|
|
311
|
+
});
|
|
312
|
+
return res.json();
|
|
313
|
+
}`,
|
|
314
|
+
breaks:
|
|
315
|
+
"`NEXT_PUBLIC_` env vars are inlined into the client bundle at build time. The key ships to every visitor, and anyone with DevTools can spend your account.",
|
|
316
|
+
aiVersion:
|
|
317
|
+
"The AI wrapped provider calls in `app/api` routes so the browser calls your server, and only your server holds `ANTHROPIC_API_KEY`.",
|
|
318
|
+
production:
|
|
319
|
+
"Server-only env, validated request bodies, structured errors that never echo provider details, and a cap on request size and rate before strangers find the endpoint.",
|
|
320
|
+
exercise:
|
|
321
|
+
"Grep your own bundles: `npm run build && grep -r \"sk-\" .next/static` — then move anything you find behind a route.",
|
|
322
|
+
patternHref: "patterns/secret-boundary.html",
|
|
323
|
+
challenge: {
|
|
324
|
+
pattern: "Secret Boundary",
|
|
325
|
+
patternCopy: "A trust decision about which runtime is allowed to hold a credential.",
|
|
326
|
+
smell: "Key in the bundle",
|
|
327
|
+
smellCopy: "Provider credentials referenced anywhere client-reachable code can see them.",
|
|
328
|
+
proof: "Transfer, not recall",
|
|
329
|
+
proofCopy: "You pass only when you can apply the same rule to a different credentialed integration."
|
|
330
|
+
},
|
|
331
|
+
criteria: {
|
|
332
|
+
diagnose: "Find the decision inside the real diff before any explanation appears.",
|
|
333
|
+
break: "Trace the naive version and click the line where the key becomes public.",
|
|
334
|
+
repair: "Real review. Required: server route owns the call, secret is server-only, input validated. Seal it with safe errors or abuse limits.",
|
|
335
|
+
transfer: "Real review. Required: secret server-side, webhook verified, input validated. Seal it with idempotency thinking or verification."
|
|
336
|
+
},
|
|
337
|
+
reviewCriteria: {
|
|
338
|
+
repair: ["Server route owns the call", "Secret is server-only", "Input validation", "Safe errors", "Abuse limits"],
|
|
339
|
+
transfer: ["Secret server-side", "Webhook verified", "Validation", "Idempotency", "Verification"]
|
|
340
|
+
},
|
|
341
|
+
artifact: {
|
|
342
|
+
failure: "A provider key referenced anywhere client-reachable ends up in the shipped JS bundle.",
|
|
343
|
+
standard: "Secrets live in server-only env behind validated API routes with errors that never leak provider details."
|
|
344
|
+
},
|
|
345
|
+
nextPatterns: [
|
|
346
|
+
{ name: "Runtime Boundary", copy: "Decide which environment owns a behavior.", href: "runtime-boundary.html" },
|
|
347
|
+
{ name: "Model Output Protocol", copy: "A small protocol between LLM text and app state.", href: null }
|
|
348
|
+
],
|
|
349
|
+
lenses: {
|
|
350
|
+
diagnose: {
|
|
351
|
+
title: "Look for the decision type",
|
|
352
|
+
items: ["app/api routes in the diff", "ANTHROPIC_API_KEY handling", "Evidence this is about trust, not speed"]
|
|
353
|
+
},
|
|
354
|
+
break: {
|
|
355
|
+
title: "Look for the exposure",
|
|
356
|
+
items: ["Where the key string lives", "What the bundler inlines", "Who can read shipped JavaScript"]
|
|
357
|
+
},
|
|
358
|
+
repair: {
|
|
359
|
+
title: "Look for the shipping gap",
|
|
360
|
+
items: ["Which runtime makes the provider call", "What happens to bad input", "What an error response reveals"]
|
|
361
|
+
},
|
|
362
|
+
transfer: {
|
|
363
|
+
title: "Look for the reusable pattern",
|
|
364
|
+
items: ["New credentialed integrations", "Webhook trust", "Validation before money moves"]
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
diagnose: {
|
|
368
|
+
prompt: "The session put the Anthropic call behind `app/api/chat` instead of calling it from the page. What kind of decision is that?",
|
|
369
|
+
choices: [
|
|
370
|
+
{
|
|
371
|
+
label: "A trust boundary",
|
|
372
|
+
description: "It decided which runtime is allowed to hold the credential and perform trusted work.",
|
|
373
|
+
feedback: "Correct. The route exists so the browser can trigger the work without ever holding the key.",
|
|
374
|
+
correct: true
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
label: "A performance optimization",
|
|
378
|
+
description: "Server-side calls are faster than browser calls.",
|
|
379
|
+
feedback: "Not the driver. A round trip through your server is usually slower — the route exists for trust, not speed.",
|
|
380
|
+
correct: false
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
label: "A code-organization preference",
|
|
384
|
+
description: "Routes keep the codebase tidy.",
|
|
385
|
+
feedback: "Tidiness is incidental. The evidence is a credential that must never reach the client bundle.",
|
|
386
|
+
correct: false
|
|
387
|
+
}
|
|
388
|
+
]
|
|
389
|
+
},
|
|
390
|
+
break: {
|
|
391
|
+
prompt: "If this naive version ships, what is the most important failure?",
|
|
392
|
+
choices: [
|
|
393
|
+
{
|
|
394
|
+
label: "The API key ships to every visitor",
|
|
395
|
+
description: "The key is compiled into the JS bundle; anyone with DevTools or curl can spend your account.",
|
|
396
|
+
feedback: "Correct. The moment a secret is client-reachable, every visitor owns it. Billing and abuse follow.",
|
|
397
|
+
correct: true
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
label: "CORS will block the request",
|
|
401
|
+
description: "Browsers stop cross-origin calls, so this fails safely.",
|
|
402
|
+
feedback: "Some providers do block browser calls — but that is incidental protection. The key exposure is already done at build time.",
|
|
403
|
+
correct: false
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
label: "The fetch is too slow from the client",
|
|
407
|
+
description: "Latency makes this unusable.",
|
|
408
|
+
feedback: "Latency is fine. The failure is that your credential is now public infrastructure.",
|
|
409
|
+
correct: false
|
|
410
|
+
}
|
|
411
|
+
]
|
|
412
|
+
},
|
|
413
|
+
failureSim: {
|
|
414
|
+
terminal: `$ npm run build && grep -ro "REDACTED_PROVIDER_KEY" .next/static/ | head -2
|
|
415
|
+
.next/static/chunks/app/page-3f2a1c.js:REDACTED_PROVIDER_KEY
|
|
416
|
+
.next/static/chunks/app/page-3f2a1c.js:REDACTED_PROVIDER_KEY
|
|
417
|
+
|
|
418
|
+
$ # three weeks later, provider dashboard:
|
|
419
|
+
usage alert: 4,213,907 tokens today (daily avg: 41,000)
|
|
420
|
+
source: 1,882 distinct IPs`,
|
|
421
|
+
narration:
|
|
422
|
+
"`NEXT_PUBLIC_` is not a naming convention — it is an instruction to publish. The key was public the moment the bundle was built; every request after that just used what visitors already had. The billing followed.",
|
|
423
|
+
arbitrate: {
|
|
424
|
+
intro: "Two engineers read the same grep output. Click the review you would approve.",
|
|
425
|
+
comments: [
|
|
426
|
+
{
|
|
427
|
+
handle: "ada.builds",
|
|
428
|
+
text: "`NEXT_PUBLIC_` is an instruction to publish — the bundler inlined the key at build time, exactly as asked. Rotate the key AND move the call behind a route; the prefix can never hold a secret.",
|
|
429
|
+
correct: true,
|
|
430
|
+
verdict: "Approved. Env var does not mean secret — the prefix decides which side of the boundary the value lives on. Rotation without the boundary just publishes the next key."
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
handle: "iyke.dev",
|
|
434
|
+
text: "Looks like a bundler leak. Upgrade Next, rotate the key, and we can keep the env var where it is — it worked fine for weeks.",
|
|
435
|
+
correct: false,
|
|
436
|
+
verdict: "Rejecting this one matters: there is no bug. The prefix asked for exactly this. Upgrade plus rotation keeps the architecture that publishes the key — the next build leaks the new one."
|
|
437
|
+
}
|
|
438
|
+
]
|
|
439
|
+
},
|
|
440
|
+
prompt:
|
|
441
|
+
"The key was in an environment variable, not in the source code. Why did it end up in every visitor's bundle anyway?",
|
|
442
|
+
choices: [
|
|
443
|
+
{
|
|
444
|
+
label: "NEXT_PUBLIC_ vars are inlined at build time",
|
|
445
|
+
description: "That prefix is a promise to the bundler: this value is safe to compile into client JavaScript.",
|
|
446
|
+
feedback: "Correct. NEXT_PUBLIC_ is not a naming convention — it is an instruction to publish. Env var does not mean secret.",
|
|
447
|
+
correct: true
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
label: "The bundler leaked it by accident",
|
|
451
|
+
description: "A Next.js bug shipped the env var; upgrading fixes it.",
|
|
452
|
+
feedback: "No bug. The prefix asked for exactly this. The fix is a boundary, not a version bump.",
|
|
453
|
+
correct: false
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
label: "HTTPS was misconfigured",
|
|
457
|
+
description: "With proper TLS the key would have been protected.",
|
|
458
|
+
feedback: "TLS protects the wire, not the bundle. The key was public before any request was made.",
|
|
459
|
+
correct: false
|
|
460
|
+
}
|
|
461
|
+
]
|
|
462
|
+
},
|
|
463
|
+
spot: {
|
|
464
|
+
prompt: "Find the line in this diff that shows who is allowed to hold a credential.",
|
|
465
|
+
targetRe: "app/api|ANTHROPIC_API_KEY",
|
|
466
|
+
targets: [
|
|
467
|
+
{ re: "app/api|route\\.(ts|js)", note: "the route — the decision to make the *server* own the provider call, not the browser." },
|
|
468
|
+
{ re: "ANTHROPIC_API_KEY|process\\.env", note: "the credential that the route keeps server-side — the other half of the boundary. The browser triggers the work but never holds this." }
|
|
469
|
+
],
|
|
470
|
+
hit: "You traced both halves: the route that owns the call AND the secret it keeps server-side. The decision is the relationship between them — trigger from anywhere, hold the key in exactly one place.",
|
|
471
|
+
misses: [
|
|
472
|
+
{ re: "fetch|anthropic\\.com|messages", note: "That is the call itself. The decision is WHERE the call is allowed to happen — and who holds the credential when it does." },
|
|
473
|
+
{ re: "NextResponse|NextRequest|import", note: "Framework plumbing. Look for the line that decides which runtime owns the secret." }
|
|
474
|
+
],
|
|
475
|
+
missDefault: "That line rides on the decision. Look for where the credential lives — and which runtime gets to read it."
|
|
476
|
+
},
|
|
477
|
+
investigate: {
|
|
478
|
+
prompt: "Now the boundary is gone. Trace it yourself: click the line where the key becomes public.",
|
|
479
|
+
targetLine: 2,
|
|
480
|
+
hit: "Line 2 — `NEXT_PUBLIC_ANTHROPIC_KEY` is inlined into the bundle at build time. The leak is already done before any request is made; the fetch below just uses what every visitor now has.",
|
|
481
|
+
misses: {
|
|
482
|
+
"1": "Half-right — `'use client'` puts this file in the bundle, but the leak lands where the bundler inlines a value.",
|
|
483
|
+
"5": "The fetch sends the key at runtime — but it was already public at build time. Trace the credential, not the call."
|
|
484
|
+
},
|
|
485
|
+
missDefault: "Trace the credential, not the call. Where does the secret's value get baked in?"
|
|
486
|
+
},
|
|
487
|
+
repairLab: {
|
|
488
|
+
filename: "app/api/chat/route.ts — your repair",
|
|
489
|
+
instructions:
|
|
490
|
+
"Rewrite it so the server owns the secret. Comments count for error and abuse handling (`// 429 after 20 req/min`) — the route, the server-only env, and validation must be real. Sketch, don't polish.",
|
|
491
|
+
starter: `"use client";
|
|
492
|
+
const KEY = process.env.NEXT_PUBLIC_ANTHROPIC_KEY;
|
|
493
|
+
|
|
494
|
+
export async function askCoach(prompt: string) {
|
|
495
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers: { "x-api-key": KEY ?? "" },
|
|
498
|
+
body: JSON.stringify({ model: "claude-sonnet-4-6",
|
|
499
|
+
max_tokens: 300, messages: [{ role: "user", content: prompt }] })
|
|
500
|
+
});
|
|
501
|
+
return res.json();
|
|
502
|
+
}`,
|
|
503
|
+
blocks: [
|
|
504
|
+
{ code: "\"use client\";", trap: true },
|
|
505
|
+
{ code: "// app/api/chat/route.ts\nimport { NextResponse } from \"next/server\";\n\nexport async function POST(request: Request) {" },
|
|
506
|
+
{ code: "const KEY = process.env.NEXT_PUBLIC_ANTHROPIC_KEY;", trap: true },
|
|
507
|
+
{ code: " const { prompt } = await request.json().catch(() => ({}));\n if (typeof prompt !== \"string\" || prompt.length === 0 || prompt.length > 2000) {\n return NextResponse.json({ error: \"invalid prompt\" }, { status: 400 });\n }" },
|
|
508
|
+
{ code: " try {\n const res = await callProvider(process.env.ANTHROPIC_API_KEY!, prompt);\n return NextResponse.json(res);" },
|
|
509
|
+
{ code: " } catch (error) {\n return NextResponse.json({ error: String(error.stack) }, { status: 500 });\n }\n}", trap: true },
|
|
510
|
+
{ code: " } catch (error) {\n console.error(\"chat route:\", error); // details stay server-side\n return NextResponse.json({ error: \"temporarily unavailable\" }, { status: 502 });\n }\n}" },
|
|
511
|
+
{ code: "// 429 after 20 req/min per IP — add limiter before launch" },
|
|
512
|
+
{ code: "// client now calls fetch(\"/api/chat\", { method: \"POST\", body: JSON.stringify({ prompt }) })" }
|
|
513
|
+
],
|
|
514
|
+
solution: `// app/api/chat/route.ts
|
|
515
|
+
import { NextResponse } from "next/server";
|
|
516
|
+
|
|
517
|
+
export async function POST(request: Request) {
|
|
518
|
+
const { prompt } = await request.json().catch(() => ({}));
|
|
519
|
+
if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > 2000) {
|
|
520
|
+
return NextResponse.json({ error: "invalid prompt" }, { status: 400 });
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
const res = await callProvider(process.env.ANTHROPIC_API_KEY!, prompt);
|
|
524
|
+
return NextResponse.json(res);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
console.error("chat route:", error); // details stay server-side
|
|
527
|
+
return NextResponse.json({ error: "temporarily unavailable" }, { status: 502 });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// still missing for scale: rate limiting per IP — name it before launch
|
|
531
|
+
// client now calls fetch("/api/chat", { method: "POST", body: JSON.stringify({ prompt }) })`
|
|
532
|
+
},
|
|
533
|
+
repair: { prompt: "Move the trusted work to the runtime that should own it.", choices: [] },
|
|
534
|
+
transferLab: {
|
|
535
|
+
instructions:
|
|
536
|
+
"Capture the handoff rule — four quick answers, a sentence each is enough.",
|
|
537
|
+
placeholder: "",
|
|
538
|
+
fields: [
|
|
539
|
+
{ key: "secret", label: "Where does the Stripe secret key live?", ph: "server-only env, used in…",
|
|
540
|
+
chips: ["Server-only env; only the checkout route and webhook handler read it.",
|
|
541
|
+
"NEXT_PUBLIC_STRIPE_KEY so both sides can use it.",
|
|
542
|
+
"In the repo .env, committed so the team has it."] },
|
|
543
|
+
{ key: "webhook", label: "Why do you trust the webhook?", ph: "signature verification via…",
|
|
544
|
+
chips: ["constructEvent verifies the stripe-signature header against the webhook secret.",
|
|
545
|
+
"The URL is long and random — nobody will find it.",
|
|
546
|
+
"It comes from Stripe's IPs."] },
|
|
547
|
+
{ key: "validation", label: "What do you validate before marking an order paid?", ph: "event data, amount, order state…",
|
|
548
|
+
chips: ["Event type, amount matches the order, order not already paid (idempotent on event id).",
|
|
549
|
+
"That the request body parses as JSON.",
|
|
550
|
+
"Nothing — Stripe already validated it."] },
|
|
551
|
+
{ key: "verify", label: "How do you verify before calling it shipped?", ph: "stripe cli replay, a duplicate-event test…",
|
|
552
|
+
chips: ["stripe cli triggers the webhook locally, plus a replayed duplicate event test.",
|
|
553
|
+
"A real purchase with my own card in production.",
|
|
554
|
+
"Test keys worked in dev, so it ships."] }
|
|
555
|
+
]
|
|
556
|
+
},
|
|
557
|
+
transfer: {
|
|
558
|
+
prompt: "Apply the same judgment to a new integration.",
|
|
559
|
+
scenario:
|
|
560
|
+
"A future AI session adds Stripe checkout plus a webhook that marks orders as paid. It works end-to-end with test keys during development.",
|
|
561
|
+
rule:
|
|
562
|
+
"Credentials and trusted state changes live server-side: secret keys in server-only env, webhooks verified by signature, inputs validated before anything irreversible happens.",
|
|
563
|
+
choices: []
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function buildGenericModule(decision) {
|
|
569
|
+
return {
|
|
570
|
+
id: "decision-ownership",
|
|
571
|
+
name: decision.title,
|
|
572
|
+
why: decision.why,
|
|
573
|
+
takeaway: decision.seniorCheck,
|
|
574
|
+
naive: decision.beginnerMiss,
|
|
575
|
+
naiveFile: "session.diff",
|
|
576
|
+
naiveCode: "// Naive version: accept the first working implementation without naming the decision.",
|
|
577
|
+
breaks: "The human can ship the code but cannot evaluate, adapt, or debug the same decision later.",
|
|
578
|
+
aiVersion: "The AI produced a working implementation in the session.",
|
|
579
|
+
production: "Name the decision, compare alternatives, identify failure modes, and verify the code with evidence.",
|
|
580
|
+
exercise: "Name one alternative, one failure mode, and one line of evidence from the diff.",
|
|
581
|
+
patternHref: null,
|
|
582
|
+
challenge: {
|
|
583
|
+
pattern: "Decision Ownership",
|
|
584
|
+
patternCopy: "A professional decision is one the human can explain and reuse.",
|
|
585
|
+
smell: "Fluent but unowned code",
|
|
586
|
+
smellCopy: "The implementation exists, but the learner cannot name the tradeoff.",
|
|
587
|
+
proof: "Transfer, not summary",
|
|
588
|
+
proofCopy: "The learner must reuse the idea in a new context."
|
|
589
|
+
},
|
|
590
|
+
criteria: {
|
|
591
|
+
diagnose: "Identify the decision as a tradeoff, not a formatting detail.",
|
|
592
|
+
break: "Name what the learner cannot do if the decision is unowned.",
|
|
593
|
+
repair: "Choose a review standard that requires evidence and verification.",
|
|
594
|
+
transfer: "Reuse the decision in another session."
|
|
595
|
+
},
|
|
596
|
+
reviewCriteria: { repair: [], transfer: [] },
|
|
597
|
+
artifact: {
|
|
598
|
+
failure: "The learner can repeat the code but cannot adapt the decision when context changes.",
|
|
599
|
+
standard: "Name the decision, compare alternatives, identify a failure mode, and verify the behavior."
|
|
600
|
+
},
|
|
601
|
+
nextPatterns: [],
|
|
602
|
+
lenses: {
|
|
603
|
+
diagnose: { title: "Look for the decision type", items: ["Changed behavior", "A constraint", "A tradeoff"] },
|
|
604
|
+
break: { title: "Look for the failure", items: ["What would be hard to debug later", "What the learner could not adapt"] },
|
|
605
|
+
repair: { title: "Look for the review standard", items: ["Evidence", "Alternative", "Failure mode", "Verification"] },
|
|
606
|
+
transfer: { title: "Look for reuse", items: ["A future context", "The same judgment in different code"] }
|
|
607
|
+
},
|
|
608
|
+
diagnose: {
|
|
609
|
+
prompt: "What kind of decision is this?",
|
|
610
|
+
choices: [
|
|
611
|
+
{ label: "A design tradeoff", description: decision.why, feedback: "Correct. Start by naming the tradeoff.", correct: true },
|
|
612
|
+
{ label: "A formatting choice", description: "The code style changed.", feedback: "Too shallow. Look for behavior and constraints.", correct: false },
|
|
613
|
+
{ label: "A random implementation detail", description: "The AI just happened to write it this way.", feedback: "That assumption prevents learning. Ask what constraint shaped it.", correct: false }
|
|
614
|
+
]
|
|
615
|
+
},
|
|
616
|
+
break: {
|
|
617
|
+
prompt: "What breaks if the learner cannot explain this decision?",
|
|
618
|
+
choices: [
|
|
619
|
+
{ label: "They cannot adapt it later", description: "The next similar problem will still require blind delegation.", feedback: "Correct. Transfer is the real goal.", correct: true },
|
|
620
|
+
{ label: "Nothing breaks", description: "The code already runs.", feedback: "Running code can still fail as education.", correct: false },
|
|
621
|
+
{ label: "Only naming gets worse", description: "This is cosmetic.", feedback: "The issue is judgment, not vocabulary alone.", correct: false }
|
|
622
|
+
]
|
|
623
|
+
},
|
|
624
|
+
repair: {
|
|
625
|
+
prompt: "Which review standard would you apply before accepting this implementation?",
|
|
626
|
+
choices: [
|
|
627
|
+
{ label: "Name the decision, evidence, alternative, failure mode, and verification", description: "This standard shows the learner can reuse the decision.", feedback: "Correct.", correct: true },
|
|
628
|
+
{ label: "Ask the AI for a nicer explanation", description: "This may help reading, but it does not prove judgment.", feedback: "Too passive.", correct: false },
|
|
629
|
+
{ label: "Ship because the diff exists", description: "A diff is not evidence of understanding.", feedback: "That misses the point.", correct: false }
|
|
630
|
+
]
|
|
631
|
+
},
|
|
632
|
+
transfer: {
|
|
633
|
+
prompt: "How would you reuse this lesson in a new session?",
|
|
634
|
+
scenario: "A future AI session makes a similar implementation choice in a different feature.",
|
|
635
|
+
rule: "A decision is learned only when you can recognize and apply it in a new context.",
|
|
636
|
+
choices: [
|
|
637
|
+
{ label: "Name the decision and compare alternatives", description: "Use the prior session as a mental model.", feedback: "Correct.", correct: true },
|
|
638
|
+
{ label: "Ask for a summary", description: "Summaries help, but they do not prove transfer.", feedback: "Too passive.", correct: false },
|
|
639
|
+
{ label: "Accept the working code", description: "Working code is not proof of understanding.", feedback: "That is the anti-goal.", correct: false }
|
|
640
|
+
]
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|