@mafianacho/nacholens 0.0.0-stage → 0.3.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 (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.de.md +46 -0
  4. package/README.fr.md +46 -0
  5. package/README.ja.md +46 -0
  6. package/README.ko.md +46 -0
  7. package/README.md +45 -2
  8. package/README.zh.md +46 -0
  9. package/demo/shop/v1/README.md +7 -0
  10. package/demo/shop/v1/api.js +78 -0
  11. package/demo/shop/v1/db.js +38 -0
  12. package/demo/shop/v1/gateway.js +50 -0
  13. package/demo/shop/v1/mock-external.js +17 -0
  14. package/demo/shop/v1/public/app.js +32 -0
  15. package/demo/shop/v1/public/format.js +2 -0
  16. package/demo/shop/v1/public/index.html +13 -0
  17. package/demo/shop/v1/public/styles.css +4 -0
  18. package/demo/shop/v2/README.md +7 -0
  19. package/demo/shop/v2/api.js +88 -0
  20. package/demo/shop/v2/db.js +38 -0
  21. package/demo/shop/v2/gateway.js +50 -0
  22. package/demo/shop/v2/mock-external.js +17 -0
  23. package/demo/shop/v2/public/app.js +33 -0
  24. package/demo/shop/v2/public/format.js +3 -0
  25. package/demo/shop/v2/public/index.html +13 -0
  26. package/demo/shop/v2/public/styles.css +5 -0
  27. package/docs/guide.ko.md +97 -0
  28. package/docs/guide.md +142 -0
  29. package/docs/model-schema.md +137 -0
  30. package/docs/review.md +78 -0
  31. package/examples/demo-shop.artifact.html +1535 -0
  32. package/examples/demo-shop.artifact.json +7 -0
  33. package/examples/demo-shop.html +1535 -0
  34. package/examples/external/README.md +12 -0
  35. package/examples/external/full-stack-fastapi-template/LICENSE +21 -0
  36. package/examples/external/full-stack-fastapi-template/changes.json +56 -0
  37. package/examples/external/full-stack-fastapi-template/full-stack-fastapi-template-backend.html +1535 -0
  38. package/models/demo-shop/changes.auto.json +121 -0
  39. package/models/demo-shop/changes.example.json +16 -0
  40. package/models/demo-shop/intent.example.json +75 -0
  41. package/models/demo-shop/model.json +3151 -0
  42. package/package.json +60 -4
  43. package/skills/nacholens/SKILL.md +235 -0
  44. package/src/cli.js +377 -0
  45. package/src/demo.js +42 -0
  46. package/src/diff.js +254 -0
  47. package/src/gitio.js +67 -0
  48. package/src/install.js +94 -0
  49. package/src/model.js +434 -0
  50. package/src/page.js +87 -0
  51. package/src/player/fonts/FONTLOG.md +30 -0
  52. package/src/player/fonts/OFL-Anton.txt +93 -0
  53. package/src/player/fonts/OFL-IBMPlex.txt +94 -0
  54. package/src/player/fonts/anton-400.woff2 +0 -0
  55. package/src/player/fonts/nacho-mono-500.woff2 +0 -0
  56. package/src/player/fonts/nacho-mono-600.woff2 +0 -0
  57. package/src/player/fonts/nacho-sans-400.woff2 +0 -0
  58. package/src/player/fonts/nacho-sans-500.woff2 +0 -0
  59. package/src/player/fonts/nacho-sans-600.woff2 +0 -0
  60. package/src/player/fonts/nacho-sans-700.woff2 +0 -0
  61. package/src/player/player.css +318 -0
  62. package/src/player/player.js +872 -0
  63. package/src/player/review.css +49 -0
  64. package/src/player/review.js +230 -0
  65. package/src/player/template.html +58 -0
  66. package/src/review.js +321 -0
  67. package/src/scan.js +255 -0
  68. package/src/template.js +36 -0
  69. package/src/verify.js +90 -0
@@ -0,0 +1,3151 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "product": {
4
+ "id": "demo-shop",
5
+ "name": "Demo shop",
6
+ "description": "Browser → gateway → order API → SQLite, with three third-party services. A tiny app that ships with nacholens, so every feature can be tried without a private repository."
7
+ },
8
+ "source": {
9
+ "repo": "demo/shop (built by nacholens demo)",
10
+ "commit": "e9f308776fd0d64ac269e02a5581415c2dfabf43"
11
+ },
12
+ "components": [
13
+ {
14
+ "id": "browser",
15
+ "kind": "client",
16
+ "label": "Browser\nvanilla JS",
17
+ "detail": "Static page from public/. Calls the API with same-origin fetch under /api and redirects the user to the login provider.",
18
+ "evidence": [
19
+ {
20
+ "file": "public/app.js",
21
+ "lines": [
22
+ 6,
23
+ 6
24
+ ],
25
+ "contains": "fetch(\"/api\" + path"
26
+ }
27
+ ]
28
+ },
29
+ {
30
+ "id": "gateway",
31
+ "kind": "gateway",
32
+ "label": "Gateway\nNode http",
33
+ "detail": "Serves public/ and forwards /api/* to the order API after a per-IP rate limit (120 requests per minute). Adds an x-request-id header.",
34
+ "evidence": [
35
+ {
36
+ "file": "gateway.js",
37
+ "lines": [
38
+ 10,
39
+ 10
40
+ ],
41
+ "contains": "const LIMIT_PER_MINUTE"
42
+ },
43
+ {
44
+ "file": "gateway.js",
45
+ "lines": [
46
+ 26,
47
+ 26
48
+ ],
49
+ "contains": "if (req.url.startsWith(\"/api/\"))"
50
+ }
51
+ ]
52
+ },
53
+ {
54
+ "id": "api",
55
+ "kind": "service",
56
+ "label": "Order API\nNode http",
57
+ "detail": "Handles sign-in, orders and the sales report. Every route except the two auth routes needs a valid session cookie; writes check the Origin header.",
58
+ "evidence": [
59
+ {
60
+ "file": "api.js",
61
+ "lines": [
62
+ 36,
63
+ 36
64
+ ],
65
+ "contains": "async function route(req, res)"
66
+ }
67
+ ],
68
+ "parts": [
69
+ {
70
+ "id": "auth-start",
71
+ "label": "GET /api/auth/start",
72
+ "role": "route",
73
+ "detail": "Starts sign-in: stores a one-time state and sends the user to the sign-in provider.",
74
+ "evidence": [
75
+ {
76
+ "file": "api.js",
77
+ "lines": [
78
+ 40,
79
+ 40
80
+ ],
81
+ "contains": "if (key === \"GET /api/auth/start\")"
82
+ }
83
+ ]
84
+ },
85
+ {
86
+ "id": "auth-callback",
87
+ "label": "GET /api/auth/callback",
88
+ "role": "route",
89
+ "detail": "Where the sign-in provider sends the user back: checks the state, exchanges the code, issues a session.",
90
+ "evidence": [
91
+ {
92
+ "file": "api.js",
93
+ "lines": [
94
+ 49,
95
+ 49
96
+ ],
97
+ "contains": "if (key === \"GET /api/auth/callback\")"
98
+ }
99
+ ]
100
+ },
101
+ {
102
+ "id": "session",
103
+ "label": "sessionOf (cookie → session)",
104
+ "role": "middleware",
105
+ "detail": "Finds the session from the sid cookie. Without one it ends with 401.",
106
+ "evidence": [
107
+ {
108
+ "file": "api.js",
109
+ "lines": [
110
+ 31,
111
+ 34
112
+ ],
113
+ "contains": "function sessionOf(req)"
114
+ },
115
+ {
116
+ "file": "api.js",
117
+ "lines": [
118
+ 59,
119
+ 60
120
+ ],
121
+ "contains": "const me = sessionOf(req);"
122
+ }
123
+ ]
124
+ },
125
+ {
126
+ "id": "orders-get",
127
+ "label": "GET /api/orders",
128
+ "role": "route",
129
+ "detail": "My orders, returned with their delivery status.",
130
+ "evidence": [
131
+ {
132
+ "file": "api.js",
133
+ "lines": [
134
+ 62,
135
+ 62
136
+ ],
137
+ "contains": "if (key === \"GET /api/orders\")"
138
+ }
139
+ ]
140
+ },
141
+ {
142
+ "id": "orders-post",
143
+ "label": "POST /api/orders",
144
+ "role": "route",
145
+ "detail": "Creates an order: checks the Origin and the input, then saves it.",
146
+ "evidence": [
147
+ {
148
+ "file": "api.js",
149
+ "lines": [
150
+ 68,
151
+ 68
152
+ ],
153
+ "contains": "if (key === \"POST /api/orders\")"
154
+ }
155
+ ]
156
+ },
157
+ {
158
+ "id": "sales-get",
159
+ "label": "GET /api/reports/sales",
160
+ "role": "route",
161
+ "detail": "Sales report, admins only. Passes on what the analytics service returns.",
162
+ "evidence": [
163
+ {
164
+ "file": "api.js",
165
+ "lines": [
166
+ 77,
167
+ 77
168
+ ],
169
+ "contains": "if (key === \"GET /api/reports/sales\")"
170
+ }
171
+ ]
172
+ },
173
+ {
174
+ "id": "track",
175
+ "label": "trackShipping",
176
+ "role": "service",
177
+ "detail": "Asks the carrier for the status of each order. On failure: unknown.",
178
+ "evidence": [
179
+ {
180
+ "file": "api.js",
181
+ "lines": [
182
+ 12,
183
+ 18
184
+ ],
185
+ "contains": "async function trackShipping(rows)"
186
+ }
187
+ ]
188
+ },
189
+ {
190
+ "id": "db-module",
191
+ "label": "db.js",
192
+ "role": "adapter",
193
+ "detail": "Functions that read and write SQLite through node:sqlite.",
194
+ "evidence": [
195
+ {
196
+ "file": "db.js",
197
+ "lines": [
198
+ 2,
199
+ 5
200
+ ],
201
+ "contains": "import { DatabaseSync } from \"node:sqlite\";"
202
+ }
203
+ ],
204
+ "connects": "db"
205
+ },
206
+ {
207
+ "id": "fetch-ext",
208
+ "label": "fetch(EXTERNAL)",
209
+ "role": "adapter",
210
+ "detail": "Calls the external services (sign-in, carrier, analytics) over HTTP. The address is EXTERNAL_URL.",
211
+ "evidence": [
212
+ {
213
+ "file": "api.js",
214
+ "lines": [
215
+ 6,
216
+ 6
217
+ ],
218
+ "contains": "const EXTERNAL = process.env.EXTERNAL_URL"
219
+ }
220
+ ]
221
+ }
222
+ ],
223
+ "wires": [
224
+ {
225
+ "from": "auth-start",
226
+ "to": "db-module",
227
+ "label": "saveState(state)",
228
+ "evidence": [
229
+ {
230
+ "file": "api.js",
231
+ "lines": [
232
+ 42,
233
+ 42
234
+ ],
235
+ "contains": "saveState(state);"
236
+ }
237
+ ]
238
+ },
239
+ {
240
+ "from": "auth-callback",
241
+ "to": "db-module",
242
+ "label": "consumeState · saveSession",
243
+ "evidence": [
244
+ {
245
+ "file": "api.js",
246
+ "lines": [
247
+ 50,
248
+ 50
249
+ ],
250
+ "contains": "if (!consumeState(url.searchParams.get(\"state\")"
251
+ },
252
+ {
253
+ "file": "api.js",
254
+ "lines": [
255
+ 55,
256
+ 55
257
+ ],
258
+ "contains": "saveSession(sid, email, SESSION_TTL_MS);"
259
+ }
260
+ ]
261
+ },
262
+ {
263
+ "from": "auth-callback",
264
+ "to": "fetch-ext",
265
+ "label": "POST /token(code)",
266
+ "evidence": [
267
+ {
268
+ "file": "api.js",
269
+ "lines": [
270
+ 51,
271
+ 51
272
+ ],
273
+ "contains": "const token = await fetch(EXTERNAL + \"/token\""
274
+ }
275
+ ]
276
+ },
277
+ {
278
+ "from": "session",
279
+ "to": "db-module",
280
+ "label": "findSession(sid)",
281
+ "evidence": [
282
+ {
283
+ "file": "api.js",
284
+ "lines": [
285
+ 33,
286
+ 33
287
+ ],
288
+ "contains": "return m ? findSession(m[1]) : null;"
289
+ }
290
+ ]
291
+ },
292
+ {
293
+ "from": "session",
294
+ "to": "orders-get",
295
+ "label": "me(email)",
296
+ "evidence": [
297
+ {
298
+ "file": "api.js",
299
+ "lines": [
300
+ 63,
301
+ 63
302
+ ],
303
+ "contains": "const rows = listOrders(me.email);"
304
+ }
305
+ ]
306
+ },
307
+ {
308
+ "from": "session",
309
+ "to": "orders-post",
310
+ "label": "me(email)",
311
+ "evidence": [
312
+ {
313
+ "file": "api.js",
314
+ "lines": [
315
+ 73,
316
+ 73
317
+ ],
318
+ "contains": "const id = insertOrder(me.email"
319
+ }
320
+ ]
321
+ },
322
+ {
323
+ "from": "session",
324
+ "to": "sales-get",
325
+ "label": "me(email)",
326
+ "evidence": [
327
+ {
328
+ "file": "api.js",
329
+ "lines": [
330
+ 78,
331
+ 78
332
+ ],
333
+ "contains": "if (!ADMINS.has(me.email))"
334
+ }
335
+ ]
336
+ },
337
+ {
338
+ "from": "orders-get",
339
+ "to": "db-module",
340
+ "label": "listOrders(email)",
341
+ "evidence": [
342
+ {
343
+ "file": "api.js",
344
+ "lines": [
345
+ 63,
346
+ 63
347
+ ],
348
+ "contains": "const rows = listOrders(me.email);"
349
+ }
350
+ ]
351
+ },
352
+ {
353
+ "from": "orders-get",
354
+ "to": "track",
355
+ "label": "trackShipping(rows)",
356
+ "evidence": [
357
+ {
358
+ "file": "api.js",
359
+ "lines": [
360
+ 64,
361
+ 64
362
+ ],
363
+ "contains": "const shipping = await trackShipping(rows);"
364
+ }
365
+ ]
366
+ },
367
+ {
368
+ "from": "track",
369
+ "to": "fetch-ext",
370
+ "label": "GET /track?code=",
371
+ "evidence": [
372
+ {
373
+ "file": "api.js",
374
+ "lines": [
375
+ 15,
376
+ 15
377
+ ],
378
+ "contains": "const r = await fetch(`${EXTERNAL}/track?code="
379
+ }
380
+ ]
381
+ },
382
+ {
383
+ "from": "orders-post",
384
+ "to": "db-module",
385
+ "label": "insertOrder(...)",
386
+ "evidence": [
387
+ {
388
+ "file": "api.js",
389
+ "lines": [
390
+ 73,
391
+ 73
392
+ ],
393
+ "contains": "const id = insertOrder(me.email"
394
+ }
395
+ ]
396
+ },
397
+ {
398
+ "from": "sales-get",
399
+ "to": "fetch-ext",
400
+ "label": "GET /sales",
401
+ "evidence": [
402
+ {
403
+ "file": "api.js",
404
+ "lines": [
405
+ 79,
406
+ 79
407
+ ],
408
+ "contains": "const r = await fetch(EXTERNAL + \"/sales\");"
409
+ }
410
+ ]
411
+ }
412
+ ]
413
+ },
414
+ {
415
+ "id": "db",
416
+ "kind": "datastore",
417
+ "label": "SQLite\nnode:sqlite",
418
+ "detail": "Tables oauth_state, sessions and orders. In memory unless SHOP_DB names a file.",
419
+ "evidence": [
420
+ {
421
+ "file": "db.js",
422
+ "lines": [
423
+ 8,
424
+ 8
425
+ ],
426
+ "contains": "CREATE TABLE IF NOT EXISTS orders"
427
+ }
428
+ ],
429
+ "row": 1,
430
+ "tables": [
431
+ {
432
+ "name": "oauth_state",
433
+ "detail": "One-time sign-in state. Deleted as soon as it is checked.",
434
+ "columns": [
435
+ {
436
+ "name": "state",
437
+ "type": "TEXT",
438
+ "note": "Primary key"
439
+ },
440
+ {
441
+ "name": "created_at",
442
+ "type": "INTEGER"
443
+ }
444
+ ],
445
+ "evidence": [
446
+ {
447
+ "file": "db.js",
448
+ "lines": [
449
+ 6,
450
+ 6
451
+ ],
452
+ "contains": "CREATE TABLE IF NOT EXISTS oauth_state"
453
+ }
454
+ ]
455
+ },
456
+ {
457
+ "name": "sessions",
458
+ "detail": "Sign-in sessions, found by the sid cookie.",
459
+ "columns": [
460
+ {
461
+ "name": "id",
462
+ "type": "TEXT",
463
+ "note": "sid"
464
+ },
465
+ {
466
+ "name": "email",
467
+ "type": "TEXT"
468
+ },
469
+ {
470
+ "name": "expires_at",
471
+ "type": "INTEGER"
472
+ }
473
+ ],
474
+ "evidence": [
475
+ {
476
+ "file": "db.js",
477
+ "lines": [
478
+ 7,
479
+ 7
480
+ ],
481
+ "contains": "CREATE TABLE IF NOT EXISTS sessions"
482
+ }
483
+ ]
484
+ },
485
+ {
486
+ "name": "orders",
487
+ "detail": "Orders, with the delivery tracking code (tracking).",
488
+ "columns": [
489
+ {
490
+ "name": "id",
491
+ "type": "INTEGER"
492
+ },
493
+ {
494
+ "name": "email",
495
+ "type": "TEXT"
496
+ },
497
+ {
498
+ "name": "item",
499
+ "type": "TEXT"
500
+ },
501
+ {
502
+ "name": "qty",
503
+ "type": "INTEGER"
504
+ },
505
+ {
506
+ "name": "tracking",
507
+ "type": "TEXT",
508
+ "note": "Delivery tracking code"
509
+ },
510
+ {
511
+ "name": "created_at",
512
+ "type": "INTEGER"
513
+ }
514
+ ],
515
+ "evidence": [
516
+ {
517
+ "file": "db.js",
518
+ "lines": [
519
+ 8,
520
+ 11
521
+ ],
522
+ "contains": "CREATE TABLE IF NOT EXISTS orders"
523
+ }
524
+ ]
525
+ }
526
+ ]
527
+ },
528
+ {
529
+ "id": "idp",
530
+ "kind": "external",
531
+ "label": "Login provider\nOAuth-style",
532
+ "detail": "Third-party sign-in. The demo ships a stand-in in mock-external.js with /authorize and /token.",
533
+ "evidence": [
534
+ {
535
+ "file": "mock-external.js",
536
+ "lines": [
537
+ 7,
538
+ 7
539
+ ],
540
+ "contains": "url.pathname === \"/authorize\""
541
+ }
542
+ ],
543
+ "row": 0,
544
+ "endpoints": [
545
+ {
546
+ "id": "authorize",
547
+ "method": "GET",
548
+ "path": "/authorize",
549
+ "auth": "None (browser redirect)",
550
+ "detail": "Sign-in consent screen. When done it redirects to redirect_uri with code and state.",
551
+ "request": [
552
+ {
553
+ "name": "state"
554
+ },
555
+ {
556
+ "name": "redirect_uri"
557
+ }
558
+ ],
559
+ "response": [
560
+ {
561
+ "name": "code",
562
+ "note": "Redirect query"
563
+ },
564
+ {
565
+ "name": "state",
566
+ "note": "Redirect query"
567
+ }
568
+ ],
569
+ "evidence": [
570
+ {
571
+ "file": "api.js",
572
+ "lines": [
573
+ 44,
574
+ 45
575
+ ],
576
+ "contains": "to.searchParams.set(\"state\", state);"
577
+ },
578
+ {
579
+ "file": "mock-external.js",
580
+ "lines": [
581
+ 7,
582
+ 9
583
+ ],
584
+ "contains": "if (url.pathname === \"/authorize\")"
585
+ }
586
+ ]
587
+ },
588
+ {
589
+ "id": "token",
590
+ "method": "POST",
591
+ "path": "/token",
592
+ "auth": "None (demo stand-in)",
593
+ "detail": "Exchanges the code for the user's details.",
594
+ "request": [
595
+ {
596
+ "name": "code"
597
+ }
598
+ ],
599
+ "response": [
600
+ {
601
+ "name": "email"
602
+ }
603
+ ],
604
+ "evidence": [
605
+ {
606
+ "file": "api.js",
607
+ "lines": [
608
+ 51,
609
+ 51
610
+ ],
611
+ "contains": "const token = await fetch(EXTERNAL + \"/token\""
612
+ },
613
+ {
614
+ "file": "mock-external.js",
615
+ "lines": [
616
+ 11,
617
+ 11
618
+ ],
619
+ "contains": "if (url.pathname === \"/token\")"
620
+ }
621
+ ]
622
+ }
623
+ ]
624
+ },
625
+ {
626
+ "id": "shipping",
627
+ "kind": "external",
628
+ "label": "Shipping tracker\nHTTP JSON",
629
+ "detail": "Third-party tracking lookup. Called once per order that has a tracking code (v2 and later).",
630
+ "evidence": [
631
+ {
632
+ "file": "api.js",
633
+ "lines": [
634
+ 12,
635
+ 12
636
+ ],
637
+ "contains": "async function trackShipping"
638
+ }
639
+ ],
640
+ "row": 2,
641
+ "endpoints": [
642
+ {
643
+ "id": "track",
644
+ "method": "GET",
645
+ "path": "/track?code=",
646
+ "auth": "None (demo stand-in)",
647
+ "detail": "Delivery status of a tracking code.",
648
+ "request": [
649
+ {
650
+ "name": "code",
651
+ "note": "Query"
652
+ }
653
+ ],
654
+ "response": [
655
+ {
656
+ "name": "status",
657
+ "note": "e.g. in_transit"
658
+ }
659
+ ],
660
+ "evidence": [
661
+ {
662
+ "file": "api.js",
663
+ "lines": [
664
+ 15,
665
+ 16
666
+ ],
667
+ "contains": "const r = await fetch(`${EXTERNAL}/track?code="
668
+ },
669
+ {
670
+ "file": "mock-external.js",
671
+ "lines": [
672
+ 12,
673
+ 12
674
+ ],
675
+ "contains": "if (url.pathname === \"/track\")"
676
+ }
677
+ ]
678
+ }
679
+ ]
680
+ },
681
+ {
682
+ "id": "analytics",
683
+ "kind": "external",
684
+ "label": "Analytics\nHTTP JSON",
685
+ "detail": "Third-party sales totals used by the report.",
686
+ "evidence": [
687
+ {
688
+ "file": "mock-external.js",
689
+ "lines": [
690
+ 13,
691
+ 13
692
+ ],
693
+ "contains": "url.pathname === \"/sales\""
694
+ }
695
+ ],
696
+ "row": 3,
697
+ "endpoints": [
698
+ {
699
+ "id": "sales",
700
+ "method": "GET",
701
+ "path": "/sales",
702
+ "auth": "None (demo stand-in)",
703
+ "detail": "Total sales and order count.",
704
+ "response": [
705
+ {
706
+ "name": "total"
707
+ },
708
+ {
709
+ "name": "orders"
710
+ }
711
+ ],
712
+ "evidence": [
713
+ {
714
+ "file": "api.js",
715
+ "lines": [
716
+ 79,
717
+ 79
718
+ ],
719
+ "contains": "const r = await fetch(EXTERNAL + \"/sales\");"
720
+ },
721
+ {
722
+ "file": "mock-external.js",
723
+ "lines": [
724
+ 13,
725
+ 13
726
+ ],
727
+ "contains": "if (url.pathname === \"/sales\")"
728
+ }
729
+ ]
730
+ }
731
+ ]
732
+ }
733
+ ],
734
+ "links": [
735
+ {
736
+ "a": "browser",
737
+ "b": "gateway",
738
+ "label": "HTTP, same origin /api/*",
739
+ "evidence": [
740
+ {
741
+ "file": "public/app.js",
742
+ "lines": [
743
+ 6,
744
+ 6
745
+ ],
746
+ "contains": "fetch(\"/api\" + path"
747
+ }
748
+ ]
749
+ },
750
+ {
751
+ "a": "gateway",
752
+ "b": "api",
753
+ "label": "HTTP forward with x-request-id",
754
+ "evidence": [
755
+ {
756
+ "file": "gateway.js",
757
+ "lines": [
758
+ 27,
759
+ 33
760
+ ],
761
+ "contains": "const upstream = await fetch(API + req.url"
762
+ }
763
+ ]
764
+ },
765
+ {
766
+ "a": "api",
767
+ "b": "db",
768
+ "label": "SQL (node:sqlite)",
769
+ "evidence": [
770
+ {
771
+ "file": "api.js",
772
+ "lines": [
773
+ 4,
774
+ 4
775
+ ],
776
+ "contains": "from \"./db.js\""
777
+ }
778
+ ]
779
+ },
780
+ {
781
+ "a": "api",
782
+ "b": "idp",
783
+ "label": "HTTPS: POST /token",
784
+ "evidence": [
785
+ {
786
+ "file": "api.js",
787
+ "lines": [
788
+ 51,
789
+ 51
790
+ ],
791
+ "contains": "fetch(EXTERNAL + \"/token\""
792
+ }
793
+ ]
794
+ },
795
+ {
796
+ "a": "api",
797
+ "b": "shipping",
798
+ "label": "HTTPS: GET /track",
799
+ "evidence": [
800
+ {
801
+ "file": "api.js",
802
+ "lines": [
803
+ 15,
804
+ 15
805
+ ],
806
+ "contains": "fetch(`${EXTERNAL}/track"
807
+ }
808
+ ]
809
+ },
810
+ {
811
+ "a": "api",
812
+ "b": "analytics",
813
+ "label": "HTTPS: GET /sales",
814
+ "evidence": [
815
+ {
816
+ "file": "api.js",
817
+ "lines": [
818
+ 79,
819
+ 79
820
+ ],
821
+ "contains": "fetch(EXTERNAL + \"/sales\")"
822
+ }
823
+ ]
824
+ },
825
+ {
826
+ "a": "browser",
827
+ "b": "idp",
828
+ "label": "Browser redirect to /authorize",
829
+ "evidence": [
830
+ {
831
+ "file": "api.js",
832
+ "lines": [
833
+ 46,
834
+ 46
835
+ ],
836
+ "contains": "location: to.toString()"
837
+ }
838
+ ]
839
+ }
840
+ ],
841
+ "useCases": [
842
+ {
843
+ "id": "sign-in",
844
+ "title": "Sign in with the login provider",
845
+ "summary": "The user is sent to a third-party provider and comes back with a one-time code that the API turns into a session cookie.",
846
+ "trigger": "GET /api/auth/start → provider → GET /api/auth/callback",
847
+ "steps": [
848
+ {
849
+ "id": "start",
850
+ "kind": "request",
851
+ "from": "browser",
852
+ "via": [
853
+ "gateway"
854
+ ],
855
+ "to": "api",
856
+ "label": "Start sign-in",
857
+ "narration": "The user clicks \"Sign in\". The browser asks /api/auth/start; the gateway rate-limits the request and forwards it to the order API.",
858
+ "request": "GET /api/auth/start",
859
+ "evidence": [
860
+ {
861
+ "file": "public/index.html",
862
+ "lines": [
863
+ 5,
864
+ 5
865
+ ],
866
+ "contains": "href=\"/api/auth/start\""
867
+ },
868
+ {
869
+ "file": "api.js",
870
+ "lines": [
871
+ 40,
872
+ 40
873
+ ],
874
+ "contains": "key === \"GET /api/auth/start\""
875
+ },
876
+ {
877
+ "file": "gateway.js",
878
+ "lines": [
879
+ 24,
880
+ 24
881
+ ],
882
+ "contains": "if (!allowed("
883
+ }
884
+ ],
885
+ "trace": [
886
+ "api/auth-start"
887
+ ]
888
+ },
889
+ {
890
+ "id": "make-state",
891
+ "kind": "internal",
892
+ "from": "api",
893
+ "to": "api",
894
+ "label": "Create a one-time state value",
895
+ "narration": "The API creates a random state value and builds the provider URL with that state and its own callback address.",
896
+ "evidence": [
897
+ {
898
+ "file": "api.js",
899
+ "lines": [
900
+ 41,
901
+ 46
902
+ ],
903
+ "contains": "const state = randomUUID();"
904
+ }
905
+ ],
906
+ "trace": [
907
+ "api/auth-start"
908
+ ]
909
+ },
910
+ {
911
+ "id": "save-state",
912
+ "kind": "call",
913
+ "from": "api",
914
+ "to": "db",
915
+ "label": "Remember the state",
916
+ "narration": "The state is written to oauth_state so the callback can prove it started here.",
917
+ "request": "INSERT INTO oauth_state (state, created_at)",
918
+ "response": "ok",
919
+ "evidence": [
920
+ {
921
+ "file": "api.js",
922
+ "lines": [
923
+ 42,
924
+ 42
925
+ ],
926
+ "contains": "saveState(state);"
927
+ },
928
+ {
929
+ "file": "db.js",
930
+ "lines": [
931
+ 14,
932
+ 16
933
+ ],
934
+ "contains": "export function saveState"
935
+ }
936
+ ],
937
+ "trace": [
938
+ "api/auth-start",
939
+ "api/db-module"
940
+ ],
941
+ "data": [
942
+ {
943
+ "store": "db",
944
+ "table": "oauth_state",
945
+ "op": "insert",
946
+ "columns": [
947
+ "state",
948
+ "created_at"
949
+ ],
950
+ "evidence": [
951
+ {
952
+ "file": "db.js",
953
+ "lines": [
954
+ 15,
955
+ 15
956
+ ],
957
+ "contains": "INSERT INTO oauth_state"
958
+ }
959
+ ]
960
+ }
961
+ ],
962
+ "payload": [
963
+ {
964
+ "leg": "request",
965
+ "format": "sql",
966
+ "title": "store the state",
967
+ "body": "INSERT INTO oauth_state (state, created_at) VALUES (?, ?)",
968
+ "bind": [
969
+ {
970
+ "name": "?1 state",
971
+ "value": "<uuid> randomUUID"
972
+ },
973
+ {
974
+ "name": "?2 created_at",
975
+ "value": "<int> Date.now(), ms"
976
+ }
977
+ ],
978
+ "evidence": [
979
+ {
980
+ "file": "db.js",
981
+ "lines": [
982
+ 15,
983
+ 15
984
+ ],
985
+ "contains": "db.prepare(\"INSERT INTO oauth_state (state, created_at) VALUES (?, ?)\")"
986
+ }
987
+ ]
988
+ }
989
+ ],
990
+ "transforms": [
991
+ {
992
+ "at": "api/auth-start",
993
+ "in": {
994
+ "type": "(nothing)"
995
+ },
996
+ "out": {
997
+ "type": "state: <uuid>"
998
+ },
999
+ "map": [
1000
+ {
1001
+ "from": "randomUUID()",
1002
+ "to": "state"
1003
+ }
1004
+ ],
1005
+ "evidence": [
1006
+ {
1007
+ "file": "api.js",
1008
+ "lines": [
1009
+ 41,
1010
+ 42
1011
+ ],
1012
+ "contains": "const state = randomUUID();"
1013
+ }
1014
+ ]
1015
+ },
1016
+ {
1017
+ "at": "api/db-module",
1018
+ "in": {
1019
+ "type": "state"
1020
+ },
1021
+ "out": {
1022
+ "type": "oauth_state row",
1023
+ "fields": [
1024
+ "state",
1025
+ "created_at"
1026
+ ]
1027
+ },
1028
+ "map": [
1029
+ {
1030
+ "from": "state",
1031
+ "to": "?1"
1032
+ },
1033
+ {
1034
+ "from": "Date.now()",
1035
+ "to": "created_at"
1036
+ }
1037
+ ],
1038
+ "evidence": [
1039
+ {
1040
+ "file": "db.js",
1041
+ "lines": [
1042
+ 14,
1043
+ 16
1044
+ ],
1045
+ "contains": "export function saveState(state) {"
1046
+ }
1047
+ ]
1048
+ }
1049
+ ]
1050
+ },
1051
+ {
1052
+ "id": "redirect",
1053
+ "kind": "response",
1054
+ "from": "api",
1055
+ "via": [
1056
+ "gateway"
1057
+ ],
1058
+ "to": "browser",
1059
+ "label": "Redirect to the provider",
1060
+ "narration": "The API answers 302 with the provider URL. The gateway passes status and headers through unchanged.",
1061
+ "response": "302 Location: provider /authorize?state=…",
1062
+ "evidence": [
1063
+ {
1064
+ "file": "api.js",
1065
+ "lines": [
1066
+ 46,
1067
+ 46
1068
+ ],
1069
+ "contains": "return send(res, 302, { redirect: to.toString() }"
1070
+ },
1071
+ {
1072
+ "file": "gateway.js",
1073
+ "lines": [
1074
+ 37,
1075
+ 37
1076
+ ],
1077
+ "contains": "res.writeHead(upstream.status"
1078
+ }
1079
+ ],
1080
+ "trace": [
1081
+ "api/auth-start"
1082
+ ],
1083
+ "payload": [
1084
+ {
1085
+ "leg": "response",
1086
+ "format": "http",
1087
+ "title": "302 to the login provider",
1088
+ "body": "HTTP 302\nlocation: <EXTERNAL>/authorize?state=<uuid>&redirect_uri=<PUBLIC_ORIGIN>/api/auth/callback\n\n{ \"redirect\": \"<same URL>\" }",
1089
+ "evidence": [
1090
+ {
1091
+ "file": "api.js",
1092
+ "lines": [
1093
+ 43,
1094
+ 46
1095
+ ],
1096
+ "contains": "const to = new URL(EXTERNAL + \"/authorize\");"
1097
+ }
1098
+ ]
1099
+ }
1100
+ ]
1101
+ },
1102
+ {
1103
+ "id": "consent",
1104
+ "kind": "request",
1105
+ "from": "browser",
1106
+ "to": "idp",
1107
+ "label": "Pick an account at the provider",
1108
+ "narration": "The browser talks to the provider directly, outside the gateway. The demo provider approves at once.",
1109
+ "request": "GET /authorize?state=…&redirect_uri=…",
1110
+ "response": "302 back to /api/auth/callback?code=…&state=…",
1111
+ "evidence": [
1112
+ {
1113
+ "file": "mock-external.js",
1114
+ "lines": [
1115
+ 7,
1116
+ 10
1117
+ ],
1118
+ "contains": "url.pathname === \"/authorize\""
1119
+ }
1120
+ ],
1121
+ "endpoints": [
1122
+ "idp/authorize"
1123
+ ]
1124
+ },
1125
+ {
1126
+ "id": "callback",
1127
+ "kind": "request",
1128
+ "from": "browser",
1129
+ "via": [
1130
+ "gateway"
1131
+ ],
1132
+ "to": "api",
1133
+ "label": "Come back with the code",
1134
+ "narration": "The provider sends the browser back to the callback address with the code and the state.",
1135
+ "request": "GET /api/auth/callback?code=…&state=…",
1136
+ "evidence": [
1137
+ {
1138
+ "file": "mock-external.js",
1139
+ "lines": [
1140
+ 8,
1141
+ 8
1142
+ ],
1143
+ "contains": "location: `${url.searchParams.get(\"redirect_uri\")}"
1144
+ },
1145
+ {
1146
+ "file": "api.js",
1147
+ "lines": [
1148
+ 49,
1149
+ 49
1150
+ ],
1151
+ "contains": "key === \"GET /api/auth/callback\""
1152
+ }
1153
+ ],
1154
+ "trace": [
1155
+ "api/auth-callback"
1156
+ ]
1157
+ },
1158
+ {
1159
+ "id": "consume-state",
1160
+ "kind": "call",
1161
+ "from": "api",
1162
+ "to": "db",
1163
+ "label": "Use the state once",
1164
+ "narration": "The state row is deleted in the same statement that reads it, so a replayed callback finds nothing and gets 400.",
1165
+ "request": "DELETE FROM oauth_state … RETURNING state",
1166
+ "response": "row or none",
1167
+ "evidence": [
1168
+ {
1169
+ "file": "api.js",
1170
+ "lines": [
1171
+ 50,
1172
+ 50
1173
+ ],
1174
+ "contains": "if (!consumeState("
1175
+ },
1176
+ {
1177
+ "file": "db.js",
1178
+ "lines": [
1179
+ 20,
1180
+ 20
1181
+ ],
1182
+ "contains": "DELETE FROM oauth_state WHERE state = ? RETURNING state"
1183
+ }
1184
+ ],
1185
+ "trace": [
1186
+ "api/auth-callback",
1187
+ "api/db-module"
1188
+ ],
1189
+ "data": [
1190
+ {
1191
+ "store": "db",
1192
+ "table": "oauth_state",
1193
+ "op": "delete",
1194
+ "columns": [
1195
+ "state"
1196
+ ],
1197
+ "evidence": [
1198
+ {
1199
+ "file": "db.js",
1200
+ "lines": [
1201
+ 20,
1202
+ 20
1203
+ ],
1204
+ "contains": "DELETE FROM oauth_state WHERE state = ? RETURNING state"
1205
+ }
1206
+ ]
1207
+ }
1208
+ ],
1209
+ "payload": [
1210
+ {
1211
+ "leg": "request",
1212
+ "format": "sql",
1213
+ "title": "use the state once (delete while reading)",
1214
+ "body": "DELETE FROM oauth_state WHERE state = ? RETURNING state",
1215
+ "bind": [
1216
+ {
1217
+ "name": "?1 state",
1218
+ "value": "<uuid> query state"
1219
+ }
1220
+ ],
1221
+ "evidence": [
1222
+ {
1223
+ "file": "db.js",
1224
+ "lines": [
1225
+ 20,
1226
+ 20
1227
+ ],
1228
+ "contains": "db.prepare(\"DELETE FROM oauth_state WHERE state = ? RETURNING state\")"
1229
+ }
1230
+ ]
1231
+ },
1232
+ {
1233
+ "leg": "response",
1234
+ "format": "row",
1235
+ "title": "deleted row (null → 400)",
1236
+ "columns": [
1237
+ {
1238
+ "name": "state",
1239
+ "value": "<uuid>"
1240
+ }
1241
+ ],
1242
+ "evidence": [
1243
+ {
1244
+ "file": "db.js",
1245
+ "lines": [
1246
+ 20,
1247
+ 20
1248
+ ],
1249
+ "contains": "db.prepare(\"DELETE FROM oauth_state WHERE state = ? RETURNING state\")"
1250
+ },
1251
+ {
1252
+ "file": "api.js",
1253
+ "lines": [
1254
+ 50,
1255
+ 50
1256
+ ],
1257
+ "contains": "if (!consumeState(url.searchParams.get(\"state\") ?? \"\")) return send(res, 400"
1258
+ }
1259
+ ]
1260
+ }
1261
+ ]
1262
+ },
1263
+ {
1264
+ "id": "exchange",
1265
+ "kind": "call",
1266
+ "from": "api",
1267
+ "to": "idp",
1268
+ "label": "Exchange the code",
1269
+ "narration": "The API posts the code to the provider token endpoint and reads the verified email from the answer.",
1270
+ "request": "POST /token {code}",
1271
+ "response": "{ email }",
1272
+ "evidence": [
1273
+ {
1274
+ "file": "api.js",
1275
+ "lines": [
1276
+ 51,
1277
+ 55
1278
+ ],
1279
+ "contains": "fetch(EXTERNAL + \"/token\""
1280
+ },
1281
+ {
1282
+ "file": "mock-external.js",
1283
+ "lines": [
1284
+ 11,
1285
+ 11
1286
+ ],
1287
+ "contains": "url.pathname === \"/token\""
1288
+ }
1289
+ ],
1290
+ "trace": [
1291
+ "api/auth-callback",
1292
+ "api/fetch-ext"
1293
+ ],
1294
+ "endpoints": [
1295
+ "idp/token"
1296
+ ],
1297
+ "payload": [
1298
+ {
1299
+ "leg": "request",
1300
+ "format": "http",
1301
+ "title": "POST /token",
1302
+ "body": "POST <EXTERNAL>/token\n\n{ \"code\": \"<text> query code\" }",
1303
+ "evidence": [
1304
+ {
1305
+ "file": "api.js",
1306
+ "lines": [
1307
+ 51,
1308
+ 51
1309
+ ],
1310
+ "contains": "const token = await fetch(EXTERNAL + \"/token\""
1311
+ }
1312
+ ]
1313
+ },
1314
+ {
1315
+ "leg": "response",
1316
+ "format": "json",
1317
+ "title": "token reply (demo stand-in)",
1318
+ "body": "{ \"email\": \"<email>\" }",
1319
+ "evidence": [
1320
+ {
1321
+ "file": "mock-external.js",
1322
+ "lines": [
1323
+ 11,
1324
+ 11
1325
+ ],
1326
+ "contains": "if (url.pathname === \"/token\")"
1327
+ },
1328
+ {
1329
+ "file": "api.js",
1330
+ "lines": [
1331
+ 53,
1332
+ 53
1333
+ ],
1334
+ "contains": "const { email } = await token.json();"
1335
+ }
1336
+ ]
1337
+ }
1338
+ ],
1339
+ "transforms": [
1340
+ {
1341
+ "at": "api/auth-callback",
1342
+ "in": {
1343
+ "type": "query code"
1344
+ },
1345
+ "out": {
1346
+ "type": "email"
1347
+ },
1348
+ "map": [
1349
+ {
1350
+ "from": "code",
1351
+ "to": "body.code"
1352
+ },
1353
+ {
1354
+ "from": "reply email",
1355
+ "to": "email",
1356
+ "how": "502 on failure"
1357
+ }
1358
+ ],
1359
+ "evidence": [
1360
+ {
1361
+ "file": "api.js",
1362
+ "lines": [
1363
+ 51,
1364
+ 53
1365
+ ],
1366
+ "contains": "const token = await fetch(EXTERNAL + \"/token\""
1367
+ }
1368
+ ]
1369
+ }
1370
+ ]
1371
+ },
1372
+ {
1373
+ "id": "save-session",
1374
+ "kind": "call",
1375
+ "from": "api",
1376
+ "to": "db",
1377
+ "label": "Create the session",
1378
+ "narration": "A random session id is stored with the email and an 8 hour expiry.",
1379
+ "request": "INSERT INTO sessions (id, email, expires_at)",
1380
+ "response": "ok",
1381
+ "evidence": [
1382
+ {
1383
+ "file": "api.js",
1384
+ "lines": [
1385
+ 55,
1386
+ 55
1387
+ ],
1388
+ "contains": "saveSession(sid, email, SESSION_TTL_MS)"
1389
+ },
1390
+ {
1391
+ "file": "db.js",
1392
+ "lines": [
1393
+ 23,
1394
+ 25
1395
+ ],
1396
+ "contains": "export function saveSession"
1397
+ }
1398
+ ],
1399
+ "trace": [
1400
+ "api/auth-callback",
1401
+ "api/db-module"
1402
+ ],
1403
+ "data": [
1404
+ {
1405
+ "store": "db",
1406
+ "table": "sessions",
1407
+ "op": "insert",
1408
+ "columns": [
1409
+ "id",
1410
+ "email",
1411
+ "expires_at"
1412
+ ],
1413
+ "evidence": [
1414
+ {
1415
+ "file": "db.js",
1416
+ "lines": [
1417
+ 24,
1418
+ 24
1419
+ ],
1420
+ "contains": "INSERT INTO sessions (id, email, expires_at)"
1421
+ }
1422
+ ]
1423
+ }
1424
+ ],
1425
+ "payload": [
1426
+ {
1427
+ "leg": "request",
1428
+ "format": "sql",
1429
+ "title": "create the session",
1430
+ "body": "INSERT INTO sessions (id, email, expires_at) VALUES (?, ?, ?)",
1431
+ "bind": [
1432
+ {
1433
+ "name": "?1 id",
1434
+ "value": "<uuid> randomUUID"
1435
+ },
1436
+ {
1437
+ "name": "?2 email",
1438
+ "value": "<email>"
1439
+ },
1440
+ {
1441
+ "name": "?3 expires_at",
1442
+ "value": "<int> now + 8 h, ms"
1443
+ }
1444
+ ],
1445
+ "evidence": [
1446
+ {
1447
+ "file": "db.js",
1448
+ "lines": [
1449
+ 24,
1450
+ 24
1451
+ ],
1452
+ "contains": "db.prepare(\"INSERT INTO sessions (id, email, expires_at) VALUES (?, ?, ?)\")"
1453
+ },
1454
+ {
1455
+ "file": "api.js",
1456
+ "lines": [
1457
+ 8,
1458
+ 8
1459
+ ],
1460
+ "contains": "const SESSION_TTL_MS = 8 * 60 * 60 * 1000;"
1461
+ }
1462
+ ]
1463
+ }
1464
+ ],
1465
+ "transforms": [
1466
+ {
1467
+ "at": "api/db-module",
1468
+ "in": {
1469
+ "type": "sid, email, ttlMs"
1470
+ },
1471
+ "out": {
1472
+ "type": "sessions row",
1473
+ "fields": [
1474
+ "id",
1475
+ "email",
1476
+ "expires_at"
1477
+ ]
1478
+ },
1479
+ "map": [
1480
+ {
1481
+ "from": "ttlMs",
1482
+ "to": "expires_at",
1483
+ "how": "Date.now() + ttlMs"
1484
+ }
1485
+ ],
1486
+ "evidence": [
1487
+ {
1488
+ "file": "db.js",
1489
+ "lines": [
1490
+ 23,
1491
+ 25
1492
+ ],
1493
+ "contains": "export function saveSession(id, email, ttlMs) {"
1494
+ }
1495
+ ]
1496
+ }
1497
+ ]
1498
+ },
1499
+ {
1500
+ "id": "done",
1501
+ "kind": "response",
1502
+ "from": "api",
1503
+ "via": [
1504
+ "gateway"
1505
+ ],
1506
+ "to": "browser",
1507
+ "label": "Back to the shop with a cookie",
1508
+ "narration": "302 to / with an HttpOnly, SameSite=Lax cookie that carries the session id.",
1509
+ "response": "302 Location: / Set-Cookie: sid=…",
1510
+ "evidence": [
1511
+ {
1512
+ "file": "api.js",
1513
+ "lines": [
1514
+ 56,
1515
+ 56
1516
+ ],
1517
+ "contains": "return send(res, 302, { ok: true }"
1518
+ }
1519
+ ],
1520
+ "trace": [
1521
+ "api/auth-callback"
1522
+ ],
1523
+ "payload": [
1524
+ {
1525
+ "leg": "response",
1526
+ "format": "http",
1527
+ "title": "302 to the shop with a session cookie",
1528
+ "body": "HTTP 302\nlocation: /\nset-cookie: sid=<uuid>; HttpOnly; SameSite=Lax; Path=/; Max-Age=<seconds>\n\n{ \"ok\": true }",
1529
+ "evidence": [
1530
+ {
1531
+ "file": "api.js",
1532
+ "lines": [
1533
+ 56,
1534
+ 56
1535
+ ],
1536
+ "contains": "return send(res, 302, { ok: true }, { location: \"/\", \"set-cookie\":"
1537
+ }
1538
+ ]
1539
+ }
1540
+ ],
1541
+ "transforms": [
1542
+ {
1543
+ "at": "api/auth-callback",
1544
+ "in": {
1545
+ "type": "sid"
1546
+ },
1547
+ "out": {
1548
+ "type": "set-cookie header"
1549
+ },
1550
+ "map": [
1551
+ {
1552
+ "from": "sid",
1553
+ "to": "sid=<uuid>",
1554
+ "how": "HttpOnly · SameSite=Lax"
1555
+ },
1556
+ {
1557
+ "from": "SESSION_TTL_MS",
1558
+ "to": "Max-Age",
1559
+ "how": "÷ 1000 (seconds)"
1560
+ }
1561
+ ],
1562
+ "evidence": [
1563
+ {
1564
+ "file": "api.js",
1565
+ "lines": [
1566
+ 56,
1567
+ 56
1568
+ ],
1569
+ "contains": "return send(res, 302, { ok: true }, { location: \"/\", \"set-cookie\":"
1570
+ }
1571
+ ]
1572
+ }
1573
+ ]
1574
+ }
1575
+ ]
1576
+ },
1577
+ {
1578
+ "id": "list-orders",
1579
+ "title": "List my orders",
1580
+ "summary": "The page loads the signed-in user's orders. For each order with a tracking code the API asks the shipping tracker for its status.",
1581
+ "trigger": "GET /api/orders",
1582
+ "steps": [
1583
+ {
1584
+ "id": "request",
1585
+ "kind": "request",
1586
+ "from": "browser",
1587
+ "via": [
1588
+ "gateway"
1589
+ ],
1590
+ "to": "api",
1591
+ "label": "Load the order list",
1592
+ "narration": "When the page opens, the browser asks /api/orders. The gateway checks the rate limit and forwards the request.",
1593
+ "request": "GET /api/orders",
1594
+ "evidence": [
1595
+ {
1596
+ "file": "public/app.js",
1597
+ "lines": [
1598
+ 12,
1599
+ 12
1600
+ ],
1601
+ "contains": "api(\"/orders\")"
1602
+ },
1603
+ {
1604
+ "file": "api.js",
1605
+ "lines": [
1606
+ 62,
1607
+ 62
1608
+ ],
1609
+ "contains": "key === \"GET /api/orders\""
1610
+ },
1611
+ {
1612
+ "file": "gateway.js",
1613
+ "lines": [
1614
+ 24,
1615
+ 24
1616
+ ],
1617
+ "contains": "if (!allowed("
1618
+ }
1619
+ ],
1620
+ "trace": [
1621
+ "api/session"
1622
+ ],
1623
+ "payload": [
1624
+ {
1625
+ "leg": "request",
1626
+ "format": "http",
1627
+ "title": "GET /api/orders",
1628
+ "body": "GET /api/orders\ncookie: sid=<uuid>",
1629
+ "evidence": [
1630
+ {
1631
+ "file": "public/app.js",
1632
+ "lines": [
1633
+ 12,
1634
+ 12
1635
+ ],
1636
+ "contains": "const { orders } = await api(\"/orders\");"
1637
+ },
1638
+ {
1639
+ "file": "public/app.js",
1640
+ "lines": [
1641
+ 6,
1642
+ 6
1643
+ ],
1644
+ "contains": "const res = await fetch(\"/api\" + path, { credentials: \"same-origin\""
1645
+ }
1646
+ ]
1647
+ }
1648
+ ]
1649
+ },
1650
+ {
1651
+ "id": "session",
1652
+ "kind": "call",
1653
+ "from": "api",
1654
+ "to": "db",
1655
+ "label": "Check the session",
1656
+ "narration": "The sid cookie is looked up in sessions. Missing or expired means 401 and nothing else runs.",
1657
+ "request": "SELECT email FROM sessions WHERE id = ? AND expires_at > now",
1658
+ "response": "email or none",
1659
+ "evidence": [
1660
+ {
1661
+ "file": "api.js",
1662
+ "lines": [
1663
+ 31,
1664
+ 34
1665
+ ],
1666
+ "contains": "function sessionOf(req)"
1667
+ },
1668
+ {
1669
+ "file": "api.js",
1670
+ "lines": [
1671
+ 60,
1672
+ 60
1673
+ ],
1674
+ "contains": "if (!me) return send(res, 401"
1675
+ },
1676
+ {
1677
+ "file": "db.js",
1678
+ "lines": [
1679
+ 27,
1680
+ 29
1681
+ ],
1682
+ "contains": "export function findSession"
1683
+ }
1684
+ ],
1685
+ "trace": [
1686
+ "api/session",
1687
+ "api/db-module"
1688
+ ],
1689
+ "data": [
1690
+ {
1691
+ "store": "db",
1692
+ "table": "sessions",
1693
+ "op": "read",
1694
+ "columns": [
1695
+ "id",
1696
+ "email",
1697
+ "expires_at"
1698
+ ],
1699
+ "evidence": [
1700
+ {
1701
+ "file": "db.js",
1702
+ "lines": [
1703
+ 28,
1704
+ 28
1705
+ ],
1706
+ "contains": "SELECT email FROM sessions WHERE id = ? AND expires_at > ?"
1707
+ }
1708
+ ]
1709
+ }
1710
+ ],
1711
+ "payload": [
1712
+ {
1713
+ "leg": "request",
1714
+ "format": "sql",
1715
+ "title": "find the session",
1716
+ "body": "SELECT email FROM sessions WHERE id = ? AND expires_at > ?",
1717
+ "bind": [
1718
+ {
1719
+ "name": "?1 id",
1720
+ "value": "<uuid> cookie sid"
1721
+ },
1722
+ {
1723
+ "name": "?2 now",
1724
+ "value": "<int> now, ms"
1725
+ }
1726
+ ],
1727
+ "evidence": [
1728
+ {
1729
+ "file": "db.js",
1730
+ "lines": [
1731
+ 28,
1732
+ 28
1733
+ ],
1734
+ "contains": "db.prepare(\"SELECT email FROM sessions WHERE id = ? AND expires_at > ?\")"
1735
+ }
1736
+ ]
1737
+ },
1738
+ {
1739
+ "leg": "response",
1740
+ "format": "row",
1741
+ "title": "session row (null if none)",
1742
+ "columns": [
1743
+ {
1744
+ "name": "email",
1745
+ "value": "<email>"
1746
+ }
1747
+ ],
1748
+ "evidence": [
1749
+ {
1750
+ "file": "db.js",
1751
+ "lines": [
1752
+ 28,
1753
+ 28
1754
+ ],
1755
+ "contains": "db.prepare(\"SELECT email FROM sessions WHERE id = ? AND expires_at > ?\")"
1756
+ }
1757
+ ]
1758
+ }
1759
+ ],
1760
+ "transforms": [
1761
+ {
1762
+ "at": "api/session",
1763
+ "in": {
1764
+ "type": "Cookie header",
1765
+ "fields": [
1766
+ "sid=<uuid>"
1767
+ ]
1768
+ },
1769
+ "out": {
1770
+ "type": "me | null",
1771
+ "fields": [
1772
+ "email"
1773
+ ]
1774
+ },
1775
+ "map": [
1776
+ {
1777
+ "from": "cookie",
1778
+ "to": "sid",
1779
+ "how": "regex sid=([\\w-]+)"
1780
+ },
1781
+ {
1782
+ "from": "sid",
1783
+ "to": "findSession(sid)",
1784
+ "how": "unexpired rows only"
1785
+ },
1786
+ {
1787
+ "from": "none",
1788
+ "to": "401 unauthenticated"
1789
+ }
1790
+ ],
1791
+ "evidence": [
1792
+ {
1793
+ "file": "api.js",
1794
+ "lines": [
1795
+ 31,
1796
+ 33
1797
+ ],
1798
+ "contains": "function sessionOf(req) {"
1799
+ },
1800
+ {
1801
+ "file": "api.js",
1802
+ "lines": [
1803
+ 59,
1804
+ 60
1805
+ ],
1806
+ "contains": "const me = sessionOf(req);"
1807
+ }
1808
+ ]
1809
+ }
1810
+ ]
1811
+ },
1812
+ {
1813
+ "id": "query",
1814
+ "kind": "call",
1815
+ "from": "api",
1816
+ "to": "db",
1817
+ "label": "Read the user's orders",
1818
+ "narration": "The latest 50 orders of the signed-in email are read, newest first.",
1819
+ "request": "SELECT … FROM orders WHERE email = ? ORDER BY id DESC LIMIT 50",
1820
+ "response": "order rows",
1821
+ "evidence": [
1822
+ {
1823
+ "file": "api.js",
1824
+ "lines": [
1825
+ 63,
1826
+ 63
1827
+ ],
1828
+ "contains": "const rows = listOrders(me.email);"
1829
+ },
1830
+ {
1831
+ "file": "db.js",
1832
+ "lines": [
1833
+ 31,
1834
+ 33
1835
+ ],
1836
+ "contains": "export function listOrders"
1837
+ }
1838
+ ],
1839
+ "trace": [
1840
+ "api/session",
1841
+ "api/orders-get",
1842
+ "api/db-module"
1843
+ ],
1844
+ "data": [
1845
+ {
1846
+ "store": "db",
1847
+ "table": "orders",
1848
+ "op": "read",
1849
+ "columns": [
1850
+ "id",
1851
+ "item",
1852
+ "qty",
1853
+ "tracking",
1854
+ "created_at",
1855
+ "email"
1856
+ ],
1857
+ "evidence": [
1858
+ {
1859
+ "file": "db.js",
1860
+ "lines": [
1861
+ 32,
1862
+ 32
1863
+ ],
1864
+ "contains": "SELECT id, item, qty, tracking, created_at FROM orders WHERE"
1865
+ }
1866
+ ]
1867
+ }
1868
+ ],
1869
+ "payload": [
1870
+ {
1871
+ "leg": "request",
1872
+ "format": "sql",
1873
+ "title": "read my orders",
1874
+ "body": "SELECT id, item, qty, tracking, created_at FROM orders\nWHERE email = ? ORDER BY id DESC LIMIT 50",
1875
+ "bind": [
1876
+ {
1877
+ "name": "?1 email",
1878
+ "value": "<email> from the session"
1879
+ }
1880
+ ],
1881
+ "evidence": [
1882
+ {
1883
+ "file": "db.js",
1884
+ "lines": [
1885
+ 32,
1886
+ 32
1887
+ ],
1888
+ "contains": "db.prepare(\"SELECT id, item, qty, tracking, created_at FROM orders WHERE email = ?"
1889
+ }
1890
+ ]
1891
+ },
1892
+ {
1893
+ "leg": "response",
1894
+ "format": "row",
1895
+ "title": "orders rows (up to 50)",
1896
+ "columns": [
1897
+ {
1898
+ "name": "id",
1899
+ "value": "INTEGER"
1900
+ },
1901
+ {
1902
+ "name": "item",
1903
+ "value": "TEXT"
1904
+ },
1905
+ {
1906
+ "name": "qty",
1907
+ "value": "INTEGER"
1908
+ },
1909
+ {
1910
+ "name": "tracking",
1911
+ "value": "TEXT | null"
1912
+ },
1913
+ {
1914
+ "name": "created_at",
1915
+ "value": "INTEGER ms"
1916
+ }
1917
+ ],
1918
+ "evidence": [
1919
+ {
1920
+ "file": "db.js",
1921
+ "lines": [
1922
+ 8,
1923
+ 11
1924
+ ],
1925
+ "contains": "CREATE TABLE IF NOT EXISTS orders ("
1926
+ }
1927
+ ]
1928
+ }
1929
+ ]
1930
+ },
1931
+ {
1932
+ "id": "shipping",
1933
+ "kind": "call",
1934
+ "from": "api",
1935
+ "to": "shipping",
1936
+ "label": "Ask the tracker for each status",
1937
+ "narration": "Orders that have a tracking code are looked up in parallel. A failed lookup becomes \"unknown\" instead of failing the list.",
1938
+ "request": "GET /track?code=TRK-… (one per order)",
1939
+ "response": "{ status } per order",
1940
+ "evidence": [
1941
+ {
1942
+ "file": "api.js",
1943
+ "lines": [
1944
+ 12,
1945
+ 18
1946
+ ],
1947
+ "contains": "async function trackShipping"
1948
+ }
1949
+ ],
1950
+ "trace": [
1951
+ "api/orders-get",
1952
+ "api/track",
1953
+ "api/fetch-ext"
1954
+ ],
1955
+ "endpoints": [
1956
+ "shipping/track"
1957
+ ],
1958
+ "payload": [
1959
+ {
1960
+ "leg": "request",
1961
+ "format": "http",
1962
+ "title": "GET /track (per order)",
1963
+ "body": "GET <EXTERNAL>/track?code=<tracking, URL-encoded>",
1964
+ "evidence": [
1965
+ {
1966
+ "file": "api.js",
1967
+ "lines": [
1968
+ 15,
1969
+ 15
1970
+ ],
1971
+ "contains": "const r = await fetch(`${EXTERNAL}/track?code=${encodeURIComponent(o.tracking)}`)"
1972
+ }
1973
+ ]
1974
+ },
1975
+ {
1976
+ "leg": "response",
1977
+ "format": "json",
1978
+ "title": "shipping status (demo stand-in)",
1979
+ "body": "{ \"status\": \"<text> e.g. in_transit\" }",
1980
+ "evidence": [
1981
+ {
1982
+ "file": "api.js",
1983
+ "lines": [
1984
+ 16,
1985
+ 16
1986
+ ],
1987
+ "contains": "return r && r.ok ? (await r.json()).status"
1988
+ }
1989
+ ]
1990
+ }
1991
+ ],
1992
+ "transforms": [
1993
+ {
1994
+ "at": "api/track",
1995
+ "in": {
1996
+ "type": "orders rows",
1997
+ "fields": [
1998
+ "tracking"
1999
+ ]
2000
+ },
2001
+ "out": {
2002
+ "type": "status strings"
2003
+ },
2004
+ "map": [
2005
+ {
2006
+ "from": "no tracking",
2007
+ "to": "\"none\""
2008
+ },
2009
+ {
2010
+ "from": "reply status",
2011
+ "to": "status"
2012
+ },
2013
+ {
2014
+ "from": "call failed",
2015
+ "to": "\"unknown\""
2016
+ }
2017
+ ],
2018
+ "evidence": [
2019
+ {
2020
+ "file": "api.js",
2021
+ "lines": [
2022
+ 12,
2023
+ 17
2024
+ ],
2025
+ "contains": "async function trackShipping(rows) {"
2026
+ }
2027
+ ]
2028
+ }
2029
+ ]
2030
+ },
2031
+ {
2032
+ "id": "shape",
2033
+ "kind": "internal",
2034
+ "from": "api",
2035
+ "to": "api",
2036
+ "label": "Build the response",
2037
+ "narration": "Each row becomes { id, item, qty, shipping, createdAt }. The database column names do not leave the server.",
2038
+ "evidence": [
2039
+ {
2040
+ "file": "api.js",
2041
+ "lines": [
2042
+ 65,
2043
+ 65
2044
+ ],
2045
+ "contains": "orders: rows.map((o, i) =>"
2046
+ }
2047
+ ],
2048
+ "trace": [
2049
+ "api/orders-get"
2050
+ ],
2051
+ "transforms": [
2052
+ {
2053
+ "at": "api/orders-get",
2054
+ "in": {
2055
+ "type": "orders rows + statuses",
2056
+ "fields": [
2057
+ "id",
2058
+ "item",
2059
+ "qty",
2060
+ "tracking",
2061
+ "created_at",
2062
+ "shipping[i]"
2063
+ ]
2064
+ },
2065
+ "out": {
2066
+ "type": "reply orders[]",
2067
+ "fields": [
2068
+ "id",
2069
+ "item",
2070
+ "qty",
2071
+ "shipping",
2072
+ "createdAt"
2073
+ ]
2074
+ },
2075
+ "map": [
2076
+ {
2077
+ "from": "shipping[i]",
2078
+ "to": "shipping"
2079
+ },
2080
+ {
2081
+ "from": "created_at",
2082
+ "to": "createdAt",
2083
+ "how": "renamed only"
2084
+ },
2085
+ {
2086
+ "from": "tracking",
2087
+ "to": "(dropped)",
2088
+ "how": "not in the reply"
2089
+ }
2090
+ ],
2091
+ "evidence": [
2092
+ {
2093
+ "file": "api.js",
2094
+ "lines": [
2095
+ 65,
2096
+ 65
2097
+ ],
2098
+ "contains": "return send(res, 200, { orders: rows.map((o, i) =>"
2099
+ }
2100
+ ]
2101
+ }
2102
+ ]
2103
+ },
2104
+ {
2105
+ "id": "response",
2106
+ "kind": "response",
2107
+ "from": "api",
2108
+ "via": [
2109
+ "gateway"
2110
+ ],
2111
+ "to": "browser",
2112
+ "label": "Show the list with status",
2113
+ "narration": "The page renders one line per order, including a readable shipping status.",
2114
+ "response": "200 { orders: [{ id, item, qty, shipping, createdAt }] }",
2115
+ "evidence": [
2116
+ {
2117
+ "file": "api.js",
2118
+ "lines": [
2119
+ 65,
2120
+ 65
2121
+ ],
2122
+ "contains": "return send(res, 200, { orders: rows.map"
2123
+ },
2124
+ {
2125
+ "file": "public/app.js",
2126
+ "lines": [
2127
+ 16,
2128
+ 16
2129
+ ],
2130
+ "contains": "li.textContent ="
2131
+ },
2132
+ {
2133
+ "file": "gateway.js",
2134
+ "lines": [
2135
+ 38,
2136
+ 38
2137
+ ],
2138
+ "contains": "return res.end(Buffer.from(await upstream.arrayBuffer()))"
2139
+ }
2140
+ ],
2141
+ "trace": [
2142
+ "api/orders-get"
2143
+ ],
2144
+ "payload": [
2145
+ {
2146
+ "leg": "response",
2147
+ "format": "http",
2148
+ "title": "200 order list",
2149
+ "body": "HTTP 200\ncontent-type: application/json\n\n{ \"orders\": [\n { \"id\": <int>, \"item\": \"<text>\", \"qty\": <int>,\n \"shipping\": \"<text>\", \"createdAt\": <int ms> }\n] }",
2150
+ "evidence": [
2151
+ {
2152
+ "file": "api.js",
2153
+ "lines": [
2154
+ 65,
2155
+ 65
2156
+ ],
2157
+ "contains": "return send(res, 200, { orders: rows.map((o, i) =>"
2158
+ },
2159
+ {
2160
+ "file": "api.js",
2161
+ "lines": [
2162
+ 20,
2163
+ 23
2164
+ ],
2165
+ "contains": "const send = (res, code, body, headers = {}) => {"
2166
+ }
2167
+ ]
2168
+ }
2169
+ ],
2170
+ "transforms": [
2171
+ {
2172
+ "at": "api/orders-get",
2173
+ "in": {
2174
+ "type": "reply object"
2175
+ },
2176
+ "out": {
2177
+ "type": "JSON body"
2178
+ },
2179
+ "map": [
2180
+ {
2181
+ "from": "object",
2182
+ "to": "JSON.stringify"
2183
+ }
2184
+ ],
2185
+ "evidence": [
2186
+ {
2187
+ "file": "api.js",
2188
+ "lines": [
2189
+ 20,
2190
+ 23
2191
+ ],
2192
+ "contains": "const send = (res, code, body, headers = {}) => {"
2193
+ }
2194
+ ]
2195
+ }
2196
+ ]
2197
+ }
2198
+ ]
2199
+ },
2200
+ {
2201
+ "id": "place-order",
2202
+ "title": "Place an order",
2203
+ "summary": "A signed-in user submits an item and a quantity. The API checks origin and input, stores the order and returns its id.",
2204
+ "trigger": "POST /api/orders",
2205
+ "steps": [
2206
+ {
2207
+ "id": "request",
2208
+ "kind": "request",
2209
+ "from": "browser",
2210
+ "via": [
2211
+ "gateway"
2212
+ ],
2213
+ "to": "api",
2214
+ "label": "Submit the form",
2215
+ "narration": "The browser posts JSON. The gateway forwards the body untouched.",
2216
+ "request": "POST /api/orders {item, qty}",
2217
+ "evidence": [
2218
+ {
2219
+ "file": "public/app.js",
2220
+ "lines": [
2221
+ 25,
2222
+ 25
2223
+ ],
2224
+ "contains": "method: \"POST\""
2225
+ },
2226
+ {
2227
+ "file": "api.js",
2228
+ "lines": [
2229
+ 68,
2230
+ 68
2231
+ ],
2232
+ "contains": "key === \"POST /api/orders\""
2233
+ },
2234
+ {
2235
+ "file": "gateway.js",
2236
+ "lines": [
2237
+ 24,
2238
+ 24
2239
+ ],
2240
+ "contains": "if (!allowed("
2241
+ }
2242
+ ],
2243
+ "trace": [
2244
+ "api/session"
2245
+ ],
2246
+ "payload": [
2247
+ {
2248
+ "leg": "request",
2249
+ "format": "http",
2250
+ "title": "POST /api/orders",
2251
+ "body": "POST /api/orders\ncontent-type: application/json\norigin: <PUBLIC_ORIGIN>\ncookie: sid=<uuid>\n\n{ \"item\": \"<text>\", \"qty\": <int> }",
2252
+ "evidence": [
2253
+ {
2254
+ "file": "public/app.js",
2255
+ "lines": [
2256
+ 24,
2257
+ 27
2258
+ ],
2259
+ "contains": "await api(\"/orders\", {"
2260
+ }
2261
+ ]
2262
+ }
2263
+ ]
2264
+ },
2265
+ {
2266
+ "id": "session",
2267
+ "kind": "call",
2268
+ "from": "api",
2269
+ "to": "db",
2270
+ "label": "Check the session",
2271
+ "narration": "The sid cookie is looked up in sessions. Missing or expired means 401 and nothing else runs.",
2272
+ "request": "SELECT email FROM sessions WHERE id = ? AND expires_at > now",
2273
+ "response": "email or none",
2274
+ "evidence": [
2275
+ {
2276
+ "file": "api.js",
2277
+ "lines": [
2278
+ 31,
2279
+ 34
2280
+ ],
2281
+ "contains": "function sessionOf(req)"
2282
+ },
2283
+ {
2284
+ "file": "api.js",
2285
+ "lines": [
2286
+ 60,
2287
+ 60
2288
+ ],
2289
+ "contains": "if (!me) return send(res, 401"
2290
+ },
2291
+ {
2292
+ "file": "db.js",
2293
+ "lines": [
2294
+ 27,
2295
+ 29
2296
+ ],
2297
+ "contains": "export function findSession"
2298
+ }
2299
+ ],
2300
+ "trace": [
2301
+ "api/session",
2302
+ "api/db-module"
2303
+ ],
2304
+ "data": [
2305
+ {
2306
+ "store": "db",
2307
+ "table": "sessions",
2308
+ "op": "read",
2309
+ "columns": [
2310
+ "id",
2311
+ "email",
2312
+ "expires_at"
2313
+ ],
2314
+ "evidence": [
2315
+ {
2316
+ "file": "db.js",
2317
+ "lines": [
2318
+ 28,
2319
+ 28
2320
+ ],
2321
+ "contains": "SELECT email FROM sessions WHERE id = ? AND expires_at > ?"
2322
+ }
2323
+ ]
2324
+ }
2325
+ ],
2326
+ "payload": [
2327
+ {
2328
+ "leg": "request",
2329
+ "format": "sql",
2330
+ "title": "find the session",
2331
+ "body": "SELECT email FROM sessions WHERE id = ? AND expires_at > ?",
2332
+ "bind": [
2333
+ {
2334
+ "name": "?1 id",
2335
+ "value": "<uuid> cookie sid"
2336
+ },
2337
+ {
2338
+ "name": "?2 now",
2339
+ "value": "<int> now, ms"
2340
+ }
2341
+ ],
2342
+ "evidence": [
2343
+ {
2344
+ "file": "db.js",
2345
+ "lines": [
2346
+ 28,
2347
+ 28
2348
+ ],
2349
+ "contains": "db.prepare(\"SELECT email FROM sessions WHERE id = ? AND expires_at > ?\")"
2350
+ }
2351
+ ]
2352
+ },
2353
+ {
2354
+ "leg": "response",
2355
+ "format": "row",
2356
+ "title": "session row (null if none)",
2357
+ "columns": [
2358
+ {
2359
+ "name": "email",
2360
+ "value": "<email>"
2361
+ }
2362
+ ],
2363
+ "evidence": [
2364
+ {
2365
+ "file": "db.js",
2366
+ "lines": [
2367
+ 28,
2368
+ 28
2369
+ ],
2370
+ "contains": "db.prepare(\"SELECT email FROM sessions WHERE id = ? AND expires_at > ?\")"
2371
+ }
2372
+ ]
2373
+ }
2374
+ ],
2375
+ "transforms": [
2376
+ {
2377
+ "at": "api/session",
2378
+ "in": {
2379
+ "type": "Cookie header",
2380
+ "fields": [
2381
+ "sid=<uuid>"
2382
+ ]
2383
+ },
2384
+ "out": {
2385
+ "type": "me | null",
2386
+ "fields": [
2387
+ "email"
2388
+ ]
2389
+ },
2390
+ "map": [
2391
+ {
2392
+ "from": "cookie",
2393
+ "to": "sid",
2394
+ "how": "regex sid=([\\w-]+)"
2395
+ },
2396
+ {
2397
+ "from": "sid",
2398
+ "to": "findSession(sid)",
2399
+ "how": "unexpired rows only"
2400
+ },
2401
+ {
2402
+ "from": "none",
2403
+ "to": "401 unauthenticated"
2404
+ }
2405
+ ],
2406
+ "evidence": [
2407
+ {
2408
+ "file": "api.js",
2409
+ "lines": [
2410
+ 31,
2411
+ 33
2412
+ ],
2413
+ "contains": "function sessionOf(req) {"
2414
+ },
2415
+ {
2416
+ "file": "api.js",
2417
+ "lines": [
2418
+ 59,
2419
+ 60
2420
+ ],
2421
+ "contains": "const me = sessionOf(req);"
2422
+ }
2423
+ ]
2424
+ }
2425
+ ]
2426
+ },
2427
+ {
2428
+ "id": "origin",
2429
+ "kind": "internal",
2430
+ "from": "api",
2431
+ "to": "api",
2432
+ "label": "Check the Origin header",
2433
+ "narration": "A write is accepted only when the Origin header equals the shop's public origin; otherwise 403.",
2434
+ "evidence": [
2435
+ {
2436
+ "file": "api.js",
2437
+ "lines": [
2438
+ 69,
2439
+ 69
2440
+ ],
2441
+ "contains": "req.headers.origin !== PUBLIC_ORIGIN"
2442
+ }
2443
+ ],
2444
+ "trace": [
2445
+ "api/session",
2446
+ "api/orders-post"
2447
+ ]
2448
+ },
2449
+ {
2450
+ "id": "validate",
2451
+ "kind": "internal",
2452
+ "from": "api",
2453
+ "to": "api",
2454
+ "label": "Validate the input",
2455
+ "narration": "item must be text (cut to 80 characters), qty a whole number from 1 to 99; anything else is 400.",
2456
+ "evidence": [
2457
+ {
2458
+ "file": "api.js",
2459
+ "lines": [
2460
+ 70,
2461
+ 72
2462
+ ],
2463
+ "contains": "const body = await readBody(req);"
2464
+ }
2465
+ ],
2466
+ "trace": [
2467
+ "api/orders-post"
2468
+ ],
2469
+ "transforms": [
2470
+ {
2471
+ "at": "api/orders-post",
2472
+ "in": {
2473
+ "type": "request body (JSON)",
2474
+ "fields": [
2475
+ "item",
2476
+ "qty"
2477
+ ]
2478
+ },
2479
+ "out": {
2480
+ "type": "validated order",
2481
+ "fields": [
2482
+ "item: string",
2483
+ "qty: integer 1-99"
2484
+ ]
2485
+ },
2486
+ "map": [
2487
+ {
2488
+ "from": "body",
2489
+ "to": "body",
2490
+ "how": "JSON.parse, null on failure"
2491
+ },
2492
+ {
2493
+ "from": "item not a string · qty out of range",
2494
+ "to": "400 invalid_order"
2495
+ }
2496
+ ],
2497
+ "evidence": [
2498
+ {
2499
+ "file": "api.js",
2500
+ "lines": [
2501
+ 70,
2502
+ 72
2503
+ ],
2504
+ "contains": "const body = await readBody(req);"
2505
+ },
2506
+ {
2507
+ "file": "api.js",
2508
+ "lines": [
2509
+ 25,
2510
+ 28
2511
+ ],
2512
+ "contains": "const readBody = (req) => new Promise((resolve) => {"
2513
+ }
2514
+ ]
2515
+ }
2516
+ ]
2517
+ },
2518
+ {
2519
+ "id": "insert",
2520
+ "kind": "call",
2521
+ "from": "api",
2522
+ "to": "db",
2523
+ "label": "Store the order",
2524
+ "narration": "The order is inserted with the user's email and, from v2 on, a generated tracking code.",
2525
+ "request": "INSERT INTO orders (email, item, qty, tracking, created_at)",
2526
+ "response": "new id",
2527
+ "evidence": [
2528
+ {
2529
+ "file": "api.js",
2530
+ "lines": [
2531
+ 73,
2532
+ 73
2533
+ ],
2534
+ "contains": "const id = insertOrder("
2535
+ },
2536
+ {
2537
+ "file": "db.js",
2538
+ "lines": [
2539
+ 35,
2540
+ 38
2541
+ ],
2542
+ "contains": "export function insertOrder"
2543
+ }
2544
+ ],
2545
+ "trace": [
2546
+ "api/orders-post",
2547
+ "api/db-module"
2548
+ ],
2549
+ "data": [
2550
+ {
2551
+ "store": "db",
2552
+ "table": "orders",
2553
+ "op": "insert",
2554
+ "columns": [
2555
+ "email",
2556
+ "item",
2557
+ "qty",
2558
+ "tracking",
2559
+ "created_at"
2560
+ ],
2561
+ "evidence": [
2562
+ {
2563
+ "file": "db.js",
2564
+ "lines": [
2565
+ 36,
2566
+ 36
2567
+ ],
2568
+ "contains": "INSERT INTO orders (email, item, qty, tracking, created_at)"
2569
+ }
2570
+ ]
2571
+ }
2572
+ ],
2573
+ "payload": [
2574
+ {
2575
+ "leg": "request",
2576
+ "format": "sql",
2577
+ "title": "store the order",
2578
+ "body": "INSERT INTO orders (email, item, qty, tracking, created_at)\nVALUES (?, ?, ?, ?, ?)",
2579
+ "bind": [
2580
+ {
2581
+ "name": "?1 email",
2582
+ "value": "<email> from the session"
2583
+ },
2584
+ {
2585
+ "name": "?2 item",
2586
+ "value": "<text> up to 80 chars"
2587
+ },
2588
+ {
2589
+ "name": "?3 qty",
2590
+ "value": "<int 1-99>"
2591
+ },
2592
+ {
2593
+ "name": "?4 tracking",
2594
+ "value": "TRK-<8 chars>"
2595
+ },
2596
+ {
2597
+ "name": "?5 created_at",
2598
+ "value": "<int> Date.now(), ms"
2599
+ }
2600
+ ],
2601
+ "evidence": [
2602
+ {
2603
+ "file": "db.js",
2604
+ "lines": [
2605
+ 36,
2606
+ 36
2607
+ ],
2608
+ "contains": "db.prepare(\"INSERT INTO orders (email, item, qty, tracking, created_at) VALUES (?, ?, ?, ?, ?)\")"
2609
+ }
2610
+ ]
2611
+ },
2612
+ {
2613
+ "leg": "response",
2614
+ "format": "row",
2615
+ "title": "insert result",
2616
+ "columns": [
2617
+ {
2618
+ "name": "lastInsertRowid",
2619
+ "value": "<int> new order id"
2620
+ }
2621
+ ],
2622
+ "evidence": [
2623
+ {
2624
+ "file": "db.js",
2625
+ "lines": [
2626
+ 37,
2627
+ 37
2628
+ ],
2629
+ "contains": "return Number(r.lastInsertRowid);"
2630
+ }
2631
+ ]
2632
+ }
2633
+ ],
2634
+ "transforms": [
2635
+ {
2636
+ "at": "api/orders-post",
2637
+ "in": {
2638
+ "type": "validated order",
2639
+ "fields": [
2640
+ "item",
2641
+ "qty",
2642
+ "me.email"
2643
+ ]
2644
+ },
2645
+ "out": {
2646
+ "type": "insertOrder arguments",
2647
+ "fields": [
2648
+ "email",
2649
+ "item (cut to 80)",
2650
+ "qty",
2651
+ "tracking"
2652
+ ]
2653
+ },
2654
+ "map": [
2655
+ {
2656
+ "from": "item",
2657
+ "to": "item.slice(0, 80)"
2658
+ },
2659
+ {
2660
+ "from": "(nothing)",
2661
+ "to": "tracking",
2662
+ "how": "\"TRK-\" + first 8 of randomUUID"
2663
+ }
2664
+ ],
2665
+ "evidence": [
2666
+ {
2667
+ "file": "api.js",
2668
+ "lines": [
2669
+ 73,
2670
+ 73
2671
+ ],
2672
+ "contains": "const id = insertOrder(me.email, body.item.slice(0, 80), body.qty, \"TRK-\" + randomUUID().slice(0, 8));"
2673
+ }
2674
+ ]
2675
+ },
2676
+ {
2677
+ "at": "api/db-module",
2678
+ "in": {
2679
+ "type": "email, item, qty, tracking"
2680
+ },
2681
+ "out": {
2682
+ "type": "orders row → id"
2683
+ },
2684
+ "map": [
2685
+ {
2686
+ "from": "Date.now()",
2687
+ "to": "created_at"
2688
+ },
2689
+ {
2690
+ "from": "lastInsertRowid",
2691
+ "to": "id",
2692
+ "how": "Number"
2693
+ }
2694
+ ],
2695
+ "evidence": [
2696
+ {
2697
+ "file": "db.js",
2698
+ "lines": [
2699
+ 35,
2700
+ 38
2701
+ ],
2702
+ "contains": "export function insertOrder(email, item, qty, tracking) {"
2703
+ }
2704
+ ]
2705
+ }
2706
+ ]
2707
+ },
2708
+ {
2709
+ "id": "response",
2710
+ "kind": "response",
2711
+ "from": "api",
2712
+ "via": [
2713
+ "gateway"
2714
+ ],
2715
+ "to": "browser",
2716
+ "label": "Return the new id",
2717
+ "narration": "201 with { id }; the page reloads the list.",
2718
+ "response": "201 { id }",
2719
+ "evidence": [
2720
+ {
2721
+ "file": "api.js",
2722
+ "lines": [
2723
+ 74,
2724
+ 74
2725
+ ],
2726
+ "contains": "return send(res, 201, { id })"
2727
+ },
2728
+ {
2729
+ "file": "public/app.js",
2730
+ "lines": [
2731
+ 29,
2732
+ 29
2733
+ ],
2734
+ "contains": "await loadOrders();"
2735
+ }
2736
+ ],
2737
+ "trace": [
2738
+ "api/orders-post"
2739
+ ],
2740
+ "payload": [
2741
+ {
2742
+ "leg": "response",
2743
+ "format": "http",
2744
+ "title": "201 new order id",
2745
+ "body": "HTTP 201\ncontent-type: application/json\n\n{ \"id\": <int> }",
2746
+ "evidence": [
2747
+ {
2748
+ "file": "api.js",
2749
+ "lines": [
2750
+ 74,
2751
+ 74
2752
+ ],
2753
+ "contains": "return send(res, 201, { id });"
2754
+ }
2755
+ ]
2756
+ }
2757
+ ]
2758
+ }
2759
+ ]
2760
+ },
2761
+ {
2762
+ "id": "sales-report",
2763
+ "title": "Sales report (admin only)",
2764
+ "summary": "Only emails on the admin list may read totals, which come from a third-party analytics service.",
2765
+ "trigger": "GET /api/reports/sales",
2766
+ "steps": [
2767
+ {
2768
+ "id": "request",
2769
+ "kind": "request",
2770
+ "from": "browser",
2771
+ "via": [
2772
+ "gateway"
2773
+ ],
2774
+ "to": "api",
2775
+ "label": "Ask for the totals",
2776
+ "narration": "The page requests the report after loading.",
2777
+ "request": "GET /api/reports/sales",
2778
+ "evidence": [
2779
+ {
2780
+ "file": "public/app.js",
2781
+ "lines": [
2782
+ 33,
2783
+ 33
2784
+ ],
2785
+ "contains": "api(\"/reports/sales\")"
2786
+ },
2787
+ {
2788
+ "file": "api.js",
2789
+ "lines": [
2790
+ 77,
2791
+ 77
2792
+ ],
2793
+ "contains": "key === \"GET /api/reports/sales\""
2794
+ },
2795
+ {
2796
+ "file": "gateway.js",
2797
+ "lines": [
2798
+ 24,
2799
+ 24
2800
+ ],
2801
+ "contains": "if (!allowed("
2802
+ }
2803
+ ],
2804
+ "trace": [
2805
+ "api/session"
2806
+ ]
2807
+ },
2808
+ {
2809
+ "id": "session",
2810
+ "kind": "call",
2811
+ "from": "api",
2812
+ "to": "db",
2813
+ "label": "Check the session",
2814
+ "narration": "The sid cookie is looked up in sessions. Missing or expired means 401 and nothing else runs.",
2815
+ "request": "SELECT email FROM sessions WHERE id = ? AND expires_at > now",
2816
+ "response": "email or none",
2817
+ "evidence": [
2818
+ {
2819
+ "file": "api.js",
2820
+ "lines": [
2821
+ 31,
2822
+ 34
2823
+ ],
2824
+ "contains": "function sessionOf(req)"
2825
+ },
2826
+ {
2827
+ "file": "api.js",
2828
+ "lines": [
2829
+ 60,
2830
+ 60
2831
+ ],
2832
+ "contains": "if (!me) return send(res, 401"
2833
+ },
2834
+ {
2835
+ "file": "db.js",
2836
+ "lines": [
2837
+ 27,
2838
+ 29
2839
+ ],
2840
+ "contains": "export function findSession"
2841
+ }
2842
+ ],
2843
+ "trace": [
2844
+ "api/session",
2845
+ "api/db-module"
2846
+ ],
2847
+ "data": [
2848
+ {
2849
+ "store": "db",
2850
+ "table": "sessions",
2851
+ "op": "read",
2852
+ "columns": [
2853
+ "id",
2854
+ "email",
2855
+ "expires_at"
2856
+ ],
2857
+ "evidence": [
2858
+ {
2859
+ "file": "db.js",
2860
+ "lines": [
2861
+ 28,
2862
+ 28
2863
+ ],
2864
+ "contains": "SELECT email FROM sessions WHERE id = ? AND expires_at > ?"
2865
+ }
2866
+ ]
2867
+ }
2868
+ ],
2869
+ "payload": [
2870
+ {
2871
+ "leg": "request",
2872
+ "format": "sql",
2873
+ "title": "find the session",
2874
+ "body": "SELECT email FROM sessions WHERE id = ? AND expires_at > ?",
2875
+ "bind": [
2876
+ {
2877
+ "name": "?1 id",
2878
+ "value": "<uuid> cookie sid"
2879
+ },
2880
+ {
2881
+ "name": "?2 now",
2882
+ "value": "<int> now, ms"
2883
+ }
2884
+ ],
2885
+ "evidence": [
2886
+ {
2887
+ "file": "db.js",
2888
+ "lines": [
2889
+ 28,
2890
+ 28
2891
+ ],
2892
+ "contains": "db.prepare(\"SELECT email FROM sessions WHERE id = ? AND expires_at > ?\")"
2893
+ }
2894
+ ]
2895
+ },
2896
+ {
2897
+ "leg": "response",
2898
+ "format": "row",
2899
+ "title": "session row (null if none)",
2900
+ "columns": [
2901
+ {
2902
+ "name": "email",
2903
+ "value": "<email>"
2904
+ }
2905
+ ],
2906
+ "evidence": [
2907
+ {
2908
+ "file": "db.js",
2909
+ "lines": [
2910
+ 28,
2911
+ 28
2912
+ ],
2913
+ "contains": "db.prepare(\"SELECT email FROM sessions WHERE id = ? AND expires_at > ?\")"
2914
+ }
2915
+ ]
2916
+ }
2917
+ ],
2918
+ "transforms": [
2919
+ {
2920
+ "at": "api/session",
2921
+ "in": {
2922
+ "type": "Cookie header",
2923
+ "fields": [
2924
+ "sid=<uuid>"
2925
+ ]
2926
+ },
2927
+ "out": {
2928
+ "type": "me | null",
2929
+ "fields": [
2930
+ "email"
2931
+ ]
2932
+ },
2933
+ "map": [
2934
+ {
2935
+ "from": "cookie",
2936
+ "to": "sid",
2937
+ "how": "regex sid=([\\w-]+)"
2938
+ },
2939
+ {
2940
+ "from": "sid",
2941
+ "to": "findSession(sid)",
2942
+ "how": "unexpired rows only"
2943
+ },
2944
+ {
2945
+ "from": "none",
2946
+ "to": "401 unauthenticated"
2947
+ }
2948
+ ],
2949
+ "evidence": [
2950
+ {
2951
+ "file": "api.js",
2952
+ "lines": [
2953
+ 31,
2954
+ 33
2955
+ ],
2956
+ "contains": "function sessionOf(req) {"
2957
+ },
2958
+ {
2959
+ "file": "api.js",
2960
+ "lines": [
2961
+ 59,
2962
+ 60
2963
+ ],
2964
+ "contains": "const me = sessionOf(req);"
2965
+ }
2966
+ ]
2967
+ }
2968
+ ]
2969
+ },
2970
+ {
2971
+ "id": "admin-check",
2972
+ "kind": "internal",
2973
+ "from": "api",
2974
+ "to": "api",
2975
+ "label": "Check the admin list",
2976
+ "narration": "The session email must be in ADMIN_EMAILS (default: the demo buyer); otherwise 403.",
2977
+ "evidence": [
2978
+ {
2979
+ "file": "api.js",
2980
+ "lines": [
2981
+ 9,
2982
+ 9
2983
+ ],
2984
+ "contains": "const ADMINS = new Set("
2985
+ },
2986
+ {
2987
+ "file": "api.js",
2988
+ "lines": [
2989
+ 78,
2990
+ 78
2991
+ ],
2992
+ "contains": "if (!ADMINS.has(me.email))"
2993
+ }
2994
+ ],
2995
+ "trace": [
2996
+ "api/session",
2997
+ "api/sales-get"
2998
+ ]
2999
+ },
3000
+ {
3001
+ "id": "analytics",
3002
+ "kind": "call",
3003
+ "from": "api",
3004
+ "to": "analytics",
3005
+ "label": "Fetch the totals",
3006
+ "narration": "The API calls the analytics service. If it fails the API answers 502 instead of guessing numbers.",
3007
+ "request": "GET /sales",
3008
+ "response": "{ total, orders }",
3009
+ "evidence": [
3010
+ {
3011
+ "file": "api.js",
3012
+ "lines": [
3013
+ 79,
3014
+ 80
3015
+ ],
3016
+ "contains": "const r = await fetch(EXTERNAL + \"/sales\");"
3017
+ },
3018
+ {
3019
+ "file": "mock-external.js",
3020
+ "lines": [
3021
+ 13,
3022
+ 13
3023
+ ],
3024
+ "contains": "url.pathname === \"/sales\""
3025
+ }
3026
+ ],
3027
+ "trace": [
3028
+ "api/sales-get",
3029
+ "api/fetch-ext"
3030
+ ],
3031
+ "endpoints": [
3032
+ "analytics/sales"
3033
+ ],
3034
+ "payload": [
3035
+ {
3036
+ "leg": "request",
3037
+ "format": "http",
3038
+ "title": "GET /sales",
3039
+ "body": "GET <EXTERNAL>/sales",
3040
+ "evidence": [
3041
+ {
3042
+ "file": "api.js",
3043
+ "lines": [
3044
+ 79,
3045
+ 79
3046
+ ],
3047
+ "contains": "const r = await fetch(EXTERNAL + \"/sales\");"
3048
+ }
3049
+ ]
3050
+ },
3051
+ {
3052
+ "leg": "response",
3053
+ "format": "json",
3054
+ "title": "sales totals (demo stand-in)",
3055
+ "body": "{ \"total\": <int>, \"orders\": <int> }",
3056
+ "evidence": [
3057
+ {
3058
+ "file": "mock-external.js",
3059
+ "lines": [
3060
+ 13,
3061
+ 13
3062
+ ],
3063
+ "contains": "if (url.pathname === \"/sales\")"
3064
+ }
3065
+ ]
3066
+ }
3067
+ ]
3068
+ },
3069
+ {
3070
+ "id": "response",
3071
+ "kind": "response",
3072
+ "from": "api",
3073
+ "via": [
3074
+ "gateway"
3075
+ ],
3076
+ "to": "browser",
3077
+ "label": "Show the totals",
3078
+ "narration": "The totals are passed through and shown under the order list.",
3079
+ "response": "200 { total, orders }",
3080
+ "evidence": [
3081
+ {
3082
+ "file": "api.js",
3083
+ "lines": [
3084
+ 81,
3085
+ 81
3086
+ ],
3087
+ "contains": "return send(res, 200, await r.json());"
3088
+ },
3089
+ {
3090
+ "file": "public/app.js",
3091
+ "lines": [
3092
+ 33,
3093
+ 33
3094
+ ],
3095
+ "contains": "$(\"sales\").textContent"
3096
+ }
3097
+ ],
3098
+ "trace": [
3099
+ "api/sales-get"
3100
+ ],
3101
+ "payload": [
3102
+ {
3103
+ "leg": "response",
3104
+ "format": "http",
3105
+ "title": "200 totals as received",
3106
+ "body": "HTTP 200\ncontent-type: application/json\n\n{ \"total\": <int>, \"orders\": <int> }",
3107
+ "evidence": [
3108
+ {
3109
+ "file": "api.js",
3110
+ "lines": [
3111
+ 81,
3112
+ 81
3113
+ ],
3114
+ "contains": "return send(res, 200, await r.json());"
3115
+ }
3116
+ ]
3117
+ }
3118
+ ],
3119
+ "transforms": [
3120
+ {
3121
+ "at": "api/sales-get",
3122
+ "in": {
3123
+ "type": "analytics JSON"
3124
+ },
3125
+ "out": {
3126
+ "type": "reply JSON"
3127
+ },
3128
+ "map": [
3129
+ {
3130
+ "from": "body",
3131
+ "to": "passed through",
3132
+ "how": "502 on failure"
3133
+ }
3134
+ ],
3135
+ "evidence": [
3136
+ {
3137
+ "file": "api.js",
3138
+ "lines": [
3139
+ 80,
3140
+ 81
3141
+ ],
3142
+ "contains": "if (!r.ok) return send(res, 502, { error: \"analytics_unavailable\" });"
3143
+ }
3144
+ ]
3145
+ }
3146
+ ]
3147
+ }
3148
+ ]
3149
+ }
3150
+ ]
3151
+ }