create-ailk 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The hub's sign-in continuation module (#8146 D3; spec AC-3(b), AC-4).
7
+ * Pure functions over `seal.ts`; no mocks.
8
+ */
9
+ import { seal } from "../../../../../lib/hub-session/seal";
10
+ import {
11
+ CONTINUATION_REF_MAX,
12
+ CONTINUATION_TTL_SECONDS,
13
+ CONTINUE_PATH,
14
+ mintContinuation,
15
+ openContinuation,
16
+ } from "../continuation";
17
+
18
+ const SECRET = "k".repeat(43);
19
+ const T = 1_790_000_000_000;
20
+
21
+ const Q = new URLSearchParams({
22
+ response_type: "code",
23
+ client_id: "spoke_abc",
24
+ redirect_uri: "https://spoke.example.com/api/hub-session/callback",
25
+ state: "s".repeat(43),
26
+ code_challenge: "c".repeat(43),
27
+ code_challenge_method: "S256",
28
+ }).toString();
29
+
30
+ describe("constants", () => {
31
+ it("pins the path, the ref cap and the 600 s life", () => {
32
+ expect(CONTINUE_PATH).toBe("/api/auth/hub/continue");
33
+ expect(CONTINUATION_REF_MAX).toBe(4096);
34
+ expect(CONTINUATION_TTL_SECONDS).toBe(600);
35
+ });
36
+ });
37
+
38
+ describe("AC-4 — the continuation lives 600 s and round-trips", () => {
39
+ it("opens to the query it was minted from, up to the last millisecond", () => {
40
+ const ref = mintContinuation(Q, SECRET, T);
41
+ expect(openContinuation(ref, SECRET, T + 599_000)).toBe(Q);
42
+ expect(openContinuation(ref, SECRET, T + 599_999)).toBe(Q);
43
+ });
44
+
45
+ it("is gone at exactly 600 s (exp <= now)", () => {
46
+ const ref = mintContinuation(Q, SECRET, T);
47
+ expect(openContinuation(ref, SECRET, T + 600_000)).toBeNull();
48
+ expect(openContinuation(ref, SECRET, T + 600_001)).toBeNull();
49
+ });
50
+
51
+ it("is base64url only, so no decode can change it", () => {
52
+ const ref = mintContinuation(Q, SECRET, T);
53
+ expect(ref).toMatch(/^[A-Za-z0-9_-]+$/);
54
+ expect(decodeURIComponent(decodeURIComponent(ref))).toBe(ref);
55
+ expect(ref.length).toBeLessThan(CONTINUATION_REF_MAX);
56
+ });
57
+ });
58
+
59
+ describe("AC-3(b) — a ref that is not ours opens to null", () => {
60
+ const good = mintContinuation(Q, SECRET, T);
61
+
62
+ it("flips the first character", () => {
63
+ const flipped = (good[0] === "A" ? "B" : "A") + good.slice(1);
64
+ expect(openContinuation(flipped, SECRET, T)).toBeNull();
65
+ });
66
+
67
+ it("sealed under another secret", () => {
68
+ const other = mintContinuation(Q, "x".repeat(43), T);
69
+ expect(openContinuation(other, SECRET, T)).toBeNull();
70
+ });
71
+
72
+ it('a valid payload sealed with purpose "flow" and the SAME AAD (only the key differs)', () => {
73
+ const forged = seal(
74
+ { v: 1, q: Q, exp: T + 600_000 },
75
+ SECRET,
76
+ "flow",
77
+ "hub-continue",
78
+ );
79
+ expect(openContinuation(forged, SECRET, T)).toBeNull();
80
+ });
81
+
82
+ it("a value sealed under another name never opens as a continuation", () => {
83
+ const forged = seal(
84
+ { v: 1, q: Q, exp: T + 600_000 },
85
+ SECRET,
86
+ "continue",
87
+ "ailk-hub-flow",
88
+ );
89
+ expect(openContinuation(forged, SECRET, T)).toBeNull();
90
+ });
91
+
92
+ it("exp in the past", () => {
93
+ const expired = seal(
94
+ { v: 1, q: Q, exp: T - 1 },
95
+ SECRET,
96
+ "continue",
97
+ "hub-continue",
98
+ );
99
+ expect(openContinuation(expired, SECRET, T)).toBeNull();
100
+ });
101
+
102
+ it("4097 characters", () => {
103
+ expect(openContinuation("A".repeat(4097), SECRET, T)).toBeNull();
104
+ });
105
+
106
+ it("a character outside base64url", () => {
107
+ expect(openContinuation(`${good}%`, SECRET, T)).toBeNull();
108
+ expect(openContinuation(`${good}?x=1`, SECRET, T)).toBeNull();
109
+ expect(openContinuation("", SECRET, T)).toBeNull();
110
+ });
111
+
112
+ it("the base64url of plain JSON, unsealed", () => {
113
+ const plain = Buffer.from(
114
+ JSON.stringify({ v: 1, q: Q, exp: T + 600_000 }),
115
+ ).toString("base64url");
116
+ expect(openContinuation(plain, SECRET, T)).toBeNull();
117
+ });
118
+
119
+ it.each([
120
+ ["a wrong version", { v: 2, q: Q, exp: T + 1000 }],
121
+ ["an empty q", { v: 1, q: "", exp: T + 1000 }],
122
+ ["a non-string q", { v: 1, q: 5, exp: T + 1000 }],
123
+ ["a non-number exp", { v: 1, q: Q, exp: "later" }],
124
+ ["a bare string", "hello"],
125
+ ["null", null],
126
+ ])("a sealed value with %s", (_name, value) => {
127
+ const ref = seal(value, SECRET, "continue", "hub-continue");
128
+ expect(openContinuation(ref, SECRET, T)).toBeNull();
129
+ });
130
+
131
+ it("re-serializes a forged q so a CR/LF is percent-encoded and never throws", () => {
132
+ const ref = seal(
133
+ { v: 1, q: "a=1\r\nX-Injected: 1", exp: T + 600_000 },
134
+ SECRET,
135
+ "continue",
136
+ "hub-continue",
137
+ );
138
+ const opened = openContinuation(ref, SECRET, T);
139
+ expect(opened).not.toBeNull();
140
+ expect(opened).not.toMatch(/[\r\n]/);
141
+ expect(() => new Headers({ Location: `/x?${opened}` })).not.toThrow();
142
+ });
143
+ });
@@ -15,12 +15,14 @@
15
15
  // --- Mocks set up BEFORE imports ---
16
16
 
17
17
  const mockGetSession = jest.fn();
18
+ const mockSecret = "k".repeat(43);
18
19
  // eslint-disable-next-line no-restricted-syntax -- auth.ts is the Better Auth singleton (its adapter needs a live database); this stand-in for the session read is the repo-standard double for that boundary (see the sibling suites under app/[locale]/(authed)/account/__tests__).
19
20
  jest.mock("../../../../../../auth", () => ({
20
21
  __esModule: true,
21
22
  auth: {
22
23
  api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
23
24
  },
25
+ resolveSecret: () => mockSecret,
24
26
  }));
25
27
 
26
28
  const mockCreateCode = jest.fn();
@@ -30,9 +32,11 @@ jest.mock("@working-theory/api-client", () => ({
30
32
  }));
31
33
 
32
34
  import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
35
+ import { openContinuation } from "../../continuation";
33
36
  import { GET } from "../route";
34
37
 
35
38
  const HUB = "https://hub.example.com";
39
+ const CONTINUE = "/api/auth/hub/continue";
36
40
  const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
37
41
  const CHALLENGE = "c".repeat(43);
38
42
  const STATE = "s".repeat(43);
@@ -102,34 +106,82 @@ describe("a malformed query is a 400 and never a redirect", () => {
102
106
  });
103
107
 
104
108
  describe("no Better Auth session", () => {
105
- it("AC-4: 303 to /sign-in?next= followed by the encoded authorize path and query", async () => {
109
+ /** The `next` value of the 303, and the ref inside it. */
110
+ function continuationOf(res: Response): { next: string; ref: string } {
111
+ const location = res.headers.get("location") ?? "";
112
+ const next = new URL(location, HUB).searchParams.get("next") ?? "";
113
+ return { next, ref: next.slice(`${CONTINUE}/`.length) };
114
+ }
115
+
116
+ it("#8146 AC-1(b): 303 to /sign-in?next= followed by the encoded continue path and a sealed ref", async () => {
106
117
  mockGetSession.mockResolvedValue(null);
107
118
  const search = query();
108
119
 
109
120
  const res = await GET(request(search));
110
121
 
111
122
  expect(res.status).toBe(303);
112
- const location = res.headers.get("location") ?? "";
113
- expect(location).toBe(
114
- `/sign-in?next=${encodeURIComponent(`/api/auth/hub/authorize?${search}`)}`,
123
+ const { next, ref } = continuationOf(res);
124
+ expect(res.headers.get("location")).toBe(
125
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
115
126
  );
127
+ expect(next).toBe(`${CONTINUE}/${ref}`);
128
+ expect(openContinuation(ref, mockSecret)).toBe(search);
129
+ expect(res.headers.get("cache-control")).toBe("no-store");
130
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
116
131
  expect(mockCreateCode).not.toHaveBeenCalled();
117
132
  });
118
133
 
119
- it("AC-6: the next value is a same-origin path the sign-in card keeps (resolveNextPath)", async () => {
134
+ it("#8146 AC-1(b): the next value is a same-origin path the sign-in card keeps (resolveNextPath) and a second decode cannot change", async () => {
120
135
  mockGetSession.mockResolvedValue(null);
121
136
 
122
137
  const res = await GET(request(query()));
123
138
 
124
- const next =
125
- new URL(res.headers.get("location") ?? "", HUB).searchParams.get(
126
- "next",
127
- ) ?? "";
128
- expect(next.startsWith("/api/auth/hub/authorize?")).toBe(true);
139
+ const { next } = continuationOf(res);
140
+ expect(next.startsWith(`${CONTINUE}/`)).toBe(true);
129
141
  // Unchanged — not the fallback — so Better Auth is handed it as the callback
130
142
  // for Google and for the emailed link alike.
131
143
  expect(resolveNextPath(next, "/account")).toBe(next);
132
144
  expect(next).not.toMatch(/^https?:/);
145
+ // Better Auth decodes the callback a second time: nothing may change.
146
+ expect(next).toMatch(/^[A-Za-z0-9_/-]+$/);
147
+ expect(decodeURIComponent(decodeURIComponent(next))).toBe(next);
148
+ });
149
+
150
+ it("#8146 AC-1(b): the ref holds the canonical six-key query — a reversed query with an extra key opens to the same string, without it", async () => {
151
+ mockGetSession.mockResolvedValue(null);
152
+ const canonical = query();
153
+ const reversed = [...new URLSearchParams(canonical).entries()]
154
+ .reverse()
155
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
156
+ .join("&");
157
+
158
+ const res = await GET(request(`${reversed}&extra=1`));
159
+
160
+ expect(res.status).toBe(303);
161
+ const opened = openContinuation(continuationOf(res).ref, mockSecret);
162
+ expect(opened).toBe(canonical);
163
+ expect(opened).not.toContain("extra");
164
+ });
165
+
166
+ it("#8146 AC-3(b): a redirect_uri whose ref would pass 4096 characters is a 400 with NO Location", async () => {
167
+ mockGetSession.mockResolvedValue(null);
168
+ const redirectUri = "https://spoke.example.com/".padEnd(2048, "/");
169
+
170
+ const res = await GET(request(query({ redirect_uri: redirectUri })));
171
+
172
+ expect(res.status).toBe(400);
173
+ expect(res.headers.get("location")).toBeNull();
174
+ expect(await res.json()).toEqual({ error: "invalid_request" });
175
+ });
176
+
177
+ it("#8146 AC-3(b): a 2048-character redirect_uri whose ref stays under the cap is a normal 303", async () => {
178
+ mockGetSession.mockResolvedValue(null);
179
+ const redirectUri = "https://spoke.example.com/".padEnd(2048, "a");
180
+
181
+ const res = await GET(request(query({ redirect_uri: redirectUri })));
182
+
183
+ expect(res.status).toBe(303);
184
+ expect(res.headers.get("location")).toMatch(/^\/sign-in\?next=/);
133
185
  });
134
186
  });
135
187
 
@@ -13,11 +13,15 @@
13
13
  * &code_challenge=…&code_challenge_method=S256
14
14
  *
15
15
  * - a malformed query is a 400 and NEVER a redirect;
16
- * - no Better Auth session: 303 to `/sign-in?next=<this path and query>` —
17
- * a same-origin path, so `resolveNextPath` keeps it and the existing
18
- * sign-in card hands it to Better Auth as the callback for Google and for
19
- * the emailed link alike (the hub's own `magicLink` sends the link, and it
20
- * returns through this step);
16
+ * - no Better Auth session: the six validated keys are sealed into a short
17
+ * ref and the answer is a 303 to `/sign-in?next=<the same-origin path
18
+ * /api/auth/hub/continue/<ref>>` (#8146). `resolveNextPath` keeps that
19
+ * path and the existing sign-in card hands it to Better Auth as the
20
+ * callback for Google and for the emailed link alike; the continue route
21
+ * then returns the visitor here. This route's own URL is NOT the callback:
22
+ * Better Auth's magic-link verify decodes a callback twice, which turns the
23
+ * encoded `redirect_uri` inside it into a URL its origin check refuses. A
24
+ * ref longer than `CONTINUATION_REF_MAX` is a 400, never a redirect;
21
25
  * - a session: the API mints a single-use code, and this route redirects to
22
26
  * the REGISTERED redirect URI the API echoes back, with `code` and the
23
27
  * same `state` — never to the query's own `redirect_uri` until the API has
@@ -40,7 +44,12 @@
40
44
  import { hubSignIn } from "@working-theory/api-client";
41
45
  import { hubAuthorizeQuerySchema } from "@working-theory/validation";
42
46
 
43
- import { auth } from "../../../../../auth";
47
+ import { auth, resolveSecret } from "../../../../../auth";
48
+ import {
49
+ CONTINUATION_REF_MAX,
50
+ CONTINUE_PATH,
51
+ mintContinuation,
52
+ } from "../continuation";
44
53
 
45
54
  /** The six query keys, each read exactly once. */
46
55
  const QUERY_KEYS = [
@@ -106,7 +115,13 @@ export async function GET(request: Request): Promise<Response> {
106
115
 
107
116
  const session = await auth.api.getSession({ headers: request.headers });
108
117
  if (!session) {
109
- const next = `${url.pathname}${url.search}`;
118
+ // Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
119
+ // that did not pass the schema, and extra or reordered keys are dropped.
120
+ const canonical = new URLSearchParams();
121
+ for (const key of QUERY_KEYS) canonical.set(key, query[key]);
122
+ const ref = mintContinuation(canonical.toString(), resolveSecret());
123
+ if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
124
+ const next = `${CONTINUE_PATH}/${ref}`;
110
125
  return see(`/sign-in?next=${encodeURIComponent(next)}`);
111
126
  }
112
127
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The hub's sign-in continuation (#8146, spec
3
+ * docs/specs/8146-email-link-sign-in-from-spec.md D2, D3).
4
+ *
5
+ * Better Auth's magic-link verify decodes `callbackURL` a second time, so a
6
+ * callback that holds an encoded `redirect_uri` is refused as
7
+ * INVALID_CALLBACK_URL. The authorize step therefore does not hand the sign-in
8
+ * page its own URL. It seals the validated authorize query into a short,
9
+ * opaque ref and hands over the same-origin path `/api/auth/hub/continue/<ref>`
10
+ * instead. A base64url string has no `%`, `?`, `&`, `=` or `:`, so no number of
11
+ * decodes changes it.
12
+ *
13
+ * Sealed (AES-256-GCM, key derived from the caller's secret under its own
14
+ * purpose), with its expiry inside. The ref is opaque but is NOT a secret: it
15
+ * carries what the cleartext `next` carried before (spec § 9 SEC-8146-1).
16
+ * Nothing here logs a ref.
17
+ */
18
+ import { seal, unseal } from "../../../../lib/hub-session/seal";
19
+
20
+ /** The same-origin path a ref is appended to. */
21
+ export const CONTINUE_PATH = "/api/auth/hub/continue";
22
+
23
+ /** The longest ref the authorize step will mint, and the continue route open. */
24
+ export const CONTINUATION_REF_MAX = 4096;
25
+
26
+ /**
27
+ * 600 s, matching the spoke's flow cookie: a longer life is useless (the spoke
28
+ * refuses the callback once its flow expires) and it covers the 300 s emailed
29
+ * link plus time on the sign-in page.
30
+ */
31
+ export const CONTINUATION_TTL_SECONDS = 600;
32
+
33
+ /** A fixed name: a value sealed for a cookie never opens here, nor the reverse. */
34
+ const AAD = "hub-continue";
35
+
36
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
37
+
38
+ /** `search` (the canonical authorize query, no leading `?`) as a sealed ref. */
39
+ export function mintContinuation(
40
+ search: string,
41
+ secret: string,
42
+ now: number = Date.now(),
43
+ ): string {
44
+ return seal(
45
+ { v: 1, q: search, exp: now + CONTINUATION_TTL_SECONDS * 1000 },
46
+ secret,
47
+ "continue",
48
+ AAD,
49
+ );
50
+ }
51
+
52
+ /**
53
+ * The authorize query a ref holds, or `null` when the ref is too long, is not
54
+ * base64url, does not open, is not the expected shape, or has expired.
55
+ *
56
+ * The query is re-serialized through `URLSearchParams`: for what
57
+ * `mintContinuation` wrote that is the same string, and for a `q` forged under
58
+ * a known secret it percent-encodes CR/LF so a `Location` can never be split
59
+ * and never throws (spec § 9 SEC-8146-7).
60
+ */
61
+ export function openContinuation(
62
+ ref: string,
63
+ secret: string,
64
+ now: number = Date.now(),
65
+ ): string | null {
66
+ if (ref.length > CONTINUATION_REF_MAX || !BASE64URL.test(ref)) return null;
67
+ const value = unseal(ref, secret, "continue", AAD);
68
+ if (typeof value !== "object" || value === null) return null;
69
+ const { v, q, exp } = value as Record<string, unknown>;
70
+ if (v !== 1 || typeof q !== "string" || q === "") return null;
71
+ if (typeof exp !== "number" || exp <= now) return null;
72
+ return new URLSearchParams(q).toString();
73
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * Tests for the hub's continue step (#8146, spec
7
+ * docs/specs/8146-email-link-sign-in-from-spec.md D4; AC-1(c), AC-3(b)).
8
+ *
9
+ * `auth.ts` (the Better Auth singleton: `resolveSecret`, the session read) and
10
+ * `@working-theory/api-client`'s `hubSignIn` are the external boundaries; both
11
+ * are mocked, exactly as the authorize suite does. The route's own decisions
12
+ * are under test: where a ref sends the visitor, and what it never does.
13
+ */
14
+
15
+ // --- Mocks set up BEFORE imports ---
16
+
17
+ const mockGetSession = jest.fn();
18
+ const mockSecret = "k".repeat(43);
19
+ // eslint-disable-next-line no-restricted-syntax -- auth.ts is the Better Auth singleton (its adapter needs a live database); this stand-in for the secret and the session read is the repo-standard double for that boundary (see the authorize suite beside this one).
20
+ jest.mock("../../../../../../../auth", () => ({
21
+ __esModule: true,
22
+ auth: {
23
+ api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
24
+ },
25
+ resolveSecret: () => mockSecret,
26
+ }));
27
+
28
+ const mockCreateCode = jest.fn();
29
+ jest.mock("@working-theory/api-client", () => ({
30
+ __esModule: true,
31
+ hubSignIn: { createCode: (...args: unknown[]) => mockCreateCode(...args) },
32
+ }));
33
+
34
+ import { seal } from "../../../../../../../lib/hub-session/seal";
35
+ import { GET as authorize } from "../../../authorize/route";
36
+ import { mintContinuation } from "../../../continuation";
37
+ import { GET } from "../route";
38
+
39
+ const HUB = "https://hub.example.com";
40
+ const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
41
+ const STATE = "s".repeat(43);
42
+ const CODE = "k".repeat(43);
43
+
44
+ const ORIGINAL_ENV = process.env;
45
+
46
+ const QUERY = new URLSearchParams({
47
+ response_type: "code",
48
+ client_id: "spoke_abc",
49
+ redirect_uri: SPOKE_CALLBACK,
50
+ state: STATE,
51
+ code_challenge: "c".repeat(43),
52
+ code_challenge_method: "S256",
53
+ }).toString();
54
+
55
+ function continueTo(ref: string): Promise<Response> {
56
+ return GET(new Request(`${HUB}/api/auth/hub/continue/${ref}`), {
57
+ params: Promise.resolve({ ref }),
58
+ });
59
+ }
60
+
61
+ beforeEach(() => {
62
+ jest.resetAllMocks();
63
+ process.env = { ...ORIGINAL_ENV, BETTER_AUTH_URL: HUB };
64
+ });
65
+
66
+ afterAll(() => {
67
+ process.env = ORIGINAL_ENV;
68
+ });
69
+
70
+ describe("AC-1(c) — a valid ref returns the visitor to the authorize step", () => {
71
+ it("answers 303 to /api/auth/hub/authorize with the same six-key query", async () => {
72
+ const res = await continueTo(mintContinuation(QUERY, mockSecret));
73
+
74
+ expect(res.status).toBe(303);
75
+ expect(res.headers.get("location")).toBe(
76
+ `/api/auth/hub/authorize?${QUERY}`,
77
+ );
78
+ expect(res.headers.get("cache-control")).toBe("no-store");
79
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
80
+ });
81
+
82
+ it("chained into the authorize step with a session: 303 to the registered spoke URI with the code and the original state", async () => {
83
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
84
+ mockCreateCode.mockResolvedValue({
85
+ ok: true,
86
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
87
+ });
88
+
89
+ const step = await continueTo(mintContinuation(QUERY, mockSecret));
90
+ const res = await authorize(
91
+ new Request(`${HUB}${step.headers.get("location")}`, {
92
+ headers: { cookie: "better-auth.session_token=tok" },
93
+ }),
94
+ );
95
+
96
+ expect(res.status).toBe(303);
97
+ const target = new URL(res.headers.get("location") ?? "");
98
+ expect(`${target.origin}${target.pathname}`).toBe(SPOKE_CALLBACK);
99
+ expect(target.searchParams.get("code")).toBe(CODE);
100
+ expect(target.searchParams.get("state")).toBe(STATE);
101
+ });
102
+
103
+ it("reads no session itself: the authorize step decides every code", async () => {
104
+ await continueTo(mintContinuation(QUERY, mockSecret));
105
+ expect(mockGetSession).not.toHaveBeenCalled();
106
+ expect(mockCreateCode).not.toHaveBeenCalled();
107
+ });
108
+ });
109
+
110
+ describe("AC-3(b) — any other ref answers 303 /account", () => {
111
+ const good = mintContinuation(QUERY, mockSecret);
112
+ const T = Date.now();
113
+
114
+ it.each([
115
+ [
116
+ "the first character flipped",
117
+ (good[0] === "A" ? "B" : "A") + good.slice(1),
118
+ ],
119
+ [
120
+ "sealed under another secret",
121
+ mintContinuation(QUERY, "x".repeat(43)),
122
+ ],
123
+ [
124
+ 'a valid payload sealed with purpose "flow" and the SAME name',
125
+ seal(
126
+ { v: 1, q: QUERY, exp: T + 600_000 },
127
+ mockSecret,
128
+ "flow",
129
+ "hub-continue",
130
+ ),
131
+ ],
132
+ ["exp in the past", mintContinuation(QUERY, mockSecret, T - 601_000)],
133
+ ["4097 characters", "A".repeat(4097)],
134
+ ["a character outside base64url", `${good}%2Fevil`],
135
+ [
136
+ "plain base64url JSON, unsealed",
137
+ Buffer.from(
138
+ JSON.stringify({ v: 1, q: QUERY, exp: T + 600_000 }),
139
+ ).toString("base64url"),
140
+ ],
141
+ ["an empty-looking ref", "-"],
142
+ ])("%s", async (_name, ref) => {
143
+ const res = await continueTo(ref);
144
+
145
+ expect(res.status).toBe(303);
146
+ expect(res.headers.get("location")).toBe("/account");
147
+ expect(res.headers.get("cache-control")).toBe("no-store");
148
+ });
149
+
150
+ it("a ref sealed under the test secret whose q holds CR/LF answers 303 without throwing, with no CR or LF in Location", async () => {
151
+ const ref = seal(
152
+ { v: 1, q: "a=1\r\nX-Injected: 1", exp: T + 600_000 },
153
+ mockSecret,
154
+ "continue",
155
+ "hub-continue",
156
+ );
157
+
158
+ const res = await continueTo(ref);
159
+
160
+ expect(res.status).toBe(303);
161
+ expect(res.headers.get("location")).not.toMatch(/[\r\n]/);
162
+ expect(res.headers.get("x-injected")).toBeNull();
163
+ });
164
+
165
+ it("every Location is /account or starts with /api/auth/hub/authorize? — never `//` or a scheme", async () => {
166
+ const refs = [
167
+ good,
168
+ "garbage",
169
+ mintContinuation("redirect_uri=https://evil.example/x", mockSecret),
170
+ mintContinuation("//evil.example/x", mockSecret),
171
+ seal(
172
+ { v: 1, q: "https://evil.example/x", exp: T + 600_000 },
173
+ mockSecret,
174
+ "continue",
175
+ "hub-continue",
176
+ ),
177
+ ];
178
+ for (const ref of refs) {
179
+ const location = (await continueTo(ref)).headers.get("location") ?? "";
180
+ expect(
181
+ location === "/account" ||
182
+ location.startsWith("/api/auth/hub/authorize?"),
183
+ ).toBe(true);
184
+ expect(location.startsWith("//")).toBe(false);
185
+ expect(location).not.toMatch(/^[a-z][a-z0-9+.-]*:/i);
186
+ }
187
+ });
188
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The hub's sign-in continuation (#8146, spec
3
+ * docs/specs/8146-email-link-sign-in-from-spec.md D4).
4
+ *
5
+ * GET /api/auth/hub/continue/<ref>
6
+ *
7
+ * The authorize step, for a signed-out visitor, seals the validated authorize
8
+ * query into `<ref>` and sends the visitor to sign in with THIS path as the
9
+ * destination (Better Auth decodes a callback twice, so the path must hold
10
+ * nothing a decode can change). After sign-in — by the emailed link or by
11
+ * Google — the visitor lands here, and this route sends them back to the
12
+ * authorize step with the original query.
13
+ *
14
+ * - a valid, unexpired ref: 303 to `/api/auth/hub/authorize?<the query>`;
15
+ * - any other ref (forged, expired, replayed past its life, unreadable): 303
16
+ * to the hub's own `/account`. By then the spoke's flow cookie (600 s) has
17
+ * expired too, so returning to the spoke could not finish.
18
+ *
19
+ * Both targets are fixed relative paths: this route redirects nowhere else,
20
+ * and never to a URL carried in the ref. It reads no session — the authorize
21
+ * step does that and mints the code, so every decision about a code stays in
22
+ * one place. It logs nothing.
23
+ */
24
+ import { resolveSecret } from "../../../../../../auth";
25
+ import { openContinuation } from "../../continuation";
26
+
27
+ /** Where an unreadable ref goes: the destination the hub's own sign-in uses. */
28
+ const FALLBACK_PATH = "/account";
29
+
30
+ const AUTHORIZE_PATH = "/api/auth/hub/authorize";
31
+
32
+ /** A 303 to a fixed same-origin path. */
33
+ function see(location: string): Response {
34
+ return new Response(null, {
35
+ status: 303,
36
+ headers: {
37
+ Location: location,
38
+ "Cache-Control": "no-store",
39
+ // The authorize query rides this URL; no page may leak it through a Referer.
40
+ "Referrer-Policy": "no-referrer",
41
+ },
42
+ });
43
+ }
44
+
45
+ // platform-decisions-waiver: api-versioning — sign-in redirect step, not a versioned API contract (spec 8146 §7)
46
+ export async function GET(
47
+ _request: Request,
48
+ { params }: { params: Promise<{ ref: string }> },
49
+ ): Promise<Response> {
50
+ const { ref } = await params;
51
+ const query = openContinuation(ref, resolveSecret());
52
+ return see(query === null ? FALLBACK_PATH : `${AUTHORIZE_PATH}?${query}`);
53
+ }
@@ -6,6 +6,9 @@
6
6
  * - Sitemap location declared
7
7
  * - Optional path disallow via AILK_ROBOTS_DISALLOW env var
8
8
  * (comma-separated paths, e.g. "/admin/,/private/")
9
+ * - STEALTH=true disallows every path for every crawler — layer 1 of the
10
+ * three-layer stealth posture (#884, #7374); it subsumes any
11
+ * AILK_ROBOTS_DISALLOW paths
9
12
  */
10
13
 
11
14
  import { resolveBaseUrl } from "../../src/lib/routes";
@@ -14,10 +17,13 @@ export function GET(): Response {
14
17
  const baseUrl = resolveBaseUrl();
15
18
  const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
16
19
 
17
- const disallowPaths = disallowEnv
18
- .split(",")
19
- .map((p) => p.trim())
20
- .filter((p) => p.length > 0);
20
+ const disallowPaths =
21
+ process.env["STEALTH"] === "true"
22
+ ? ["/"]
23
+ : disallowEnv
24
+ .split(",")
25
+ .map((p) => p.trim())
26
+ .filter((p) => p.length > 0);
21
27
 
22
28
  const lines = [
23
29
  "User-agent: *",