@awsmbiz/create-inbis-app 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +28 -2
  2. package/dist/cli.mjs +850 -0
  3. package/dist/cli.mjs.map +1 -0
  4. package/package.json +43 -3
  5. package/template/.dev.vars.example +4 -0
  6. package/template/README.md +115 -0
  7. package/template/e2e/accessibility.spec.ts +33 -0
  8. package/template/e2e/axe.ts +47 -0
  9. package/template/e2e/shell.spec.ts +33 -0
  10. package/template/e2e/tsconfig.json +8 -0
  11. package/template/eslint.config.js +7 -0
  12. package/template/migrations/0001_app_initial.sql +8 -0
  13. package/template/modules/README.md +6 -0
  14. package/template/modules/access-control/files/src/access-control-routes.ts +36 -0
  15. package/template/modules/access-control/files/src/access-control.ts +46 -0
  16. package/template/modules/access-control/files/test/access-control.test.ts +78 -0
  17. package/template/modules/access-control/module.json +54 -0
  18. package/template/modules/access-control/wiring/admin/admin.imports.tsx +1 -0
  19. package/template/modules/access-control/wiring/admin/admin.nav.tsx +2 -0
  20. package/template/modules/access-control/wiring/admin/admin.pages.tsx +4 -0
  21. package/template/modules/access-control/wiring/admin/admin.userSections.tsx +1 -0
  22. package/template/modules/access-control/wiring/admin/readme.modules.md +3 -0
  23. package/template/modules/access-control/wiring/admin/web.messages.ts +4 -0
  24. package/template/modules/access-control/wiring/audit/audit.catalogues.imports.ts +1 -0
  25. package/template/modules/access-control/wiring/audit/audit.catalogues.ts +1 -0
  26. package/template/modules/access-control/wiring/audit/readme.modules.md +4 -0
  27. package/template/modules/access-control/wiring/audit/web.messages.ts +6 -0
  28. package/template/modules/access-control/wiring/d1.jsonc +8 -0
  29. package/template/modules/access-control/wiring/env.bindings.ts +2 -0
  30. package/template/modules/access-control/wiring/readme.modules.md +20 -0
  31. package/template/modules/access-control/wiring/test.env.ts +1 -0
  32. package/template/modules/access-control/wiring/test.migrate.ts +1 -0
  33. package/template/modules/access-control/wiring/vitest.bindings.ts +1 -0
  34. package/template/modules/access-control/wiring/worker.imports.ts +1 -0
  35. package/template/modules/access-control/wiring/worker.routes.ts +2 -0
  36. package/template/modules/access-control/with-admin/e2e/access-roles.spec.ts +51 -0
  37. package/template/modules/access-control/with-admin/web/src/admin-roles.tsx +38 -0
  38. package/template/modules/access-control/with-audit/test/access-control-audit.test.ts +25 -0
  39. package/template/modules/admin/files/e2e/admin-accessibility.spec.ts +86 -0
  40. package/template/modules/admin/files/e2e/admin-area.spec.ts +109 -0
  41. package/template/modules/admin/files/e2e/admin-helpers.ts +39 -0
  42. package/template/modules/admin/files/src/admin.ts +46 -0
  43. package/template/modules/admin/files/test/admin.test.ts +47 -0
  44. package/template/modules/admin/files/web/src/admin-area.tsx +140 -0
  45. package/template/modules/admin/files/web/src/admin-client.ts +49 -0
  46. package/template/modules/admin/files/web/src/admin-example.tsx +62 -0
  47. package/template/modules/admin/files/web/src/admin-gate.tsx +112 -0
  48. package/template/modules/admin/module.json +50 -0
  49. package/template/modules/admin/wiring/account.sections.tsx +16 -0
  50. package/template/modules/admin/wiring/audit/admin.authAudit.ts +2 -0
  51. package/template/modules/admin/wiring/audit/admin.imports.tsx +1 -0
  52. package/template/modules/admin/wiring/audit/admin.nav.tsx +1 -0
  53. package/template/modules/admin/wiring/audit/admin.pages.tsx +1 -0
  54. package/template/modules/admin/wiring/audit/admin.server.imports.ts +3 -0
  55. package/template/modules/admin/wiring/audit/admin.stats.ts +3 -0
  56. package/template/modules/admin/wiring/audit/admin.userSections.tsx +1 -0
  57. package/template/modules/admin/wiring/audit/readme.modules.md +4 -0
  58. package/template/modules/admin/wiring/audit/web.messages.ts +1 -0
  59. package/template/modules/admin/wiring/readme.modules.md +26 -0
  60. package/template/modules/admin/wiring/web.messages.ts +22 -0
  61. package/template/modules/admin/wiring/web.routes.imports.tsx +1 -0
  62. package/template/modules/admin/wiring/web.routes.tsx +2 -0
  63. package/template/modules/admin/wiring/web.styles.css +1 -0
  64. package/template/modules/admin/wiring/worker.imports.ts +1 -0
  65. package/template/modules/admin/wiring/worker.routes.ts +2 -0
  66. package/template/modules/admin/with-audit/e2e/admin-activity.spec.ts +37 -0
  67. package/template/modules/admin/with-audit/test/admin-audit.test.ts +37 -0
  68. package/template/modules/admin/with-audit/web/src/admin-activity.tsx +21 -0
  69. package/template/modules/audit/files/src/audit-actions.ts +20 -0
  70. package/template/modules/audit/files/src/audit-log.ts +33 -0
  71. package/template/modules/audit/files/test/audit.test.ts +47 -0
  72. package/template/modules/audit/module.json +36 -0
  73. package/template/modules/audit/wiring/audit.imports.ts +1 -0
  74. package/template/modules/audit/wiring/audit.sink.ts +2 -0
  75. package/template/modules/audit/wiring/auth/audit.catalogues.imports.ts +1 -0
  76. package/template/modules/audit/wiring/auth/audit.catalogues.ts +1 -0
  77. package/template/modules/audit/wiring/auth/readme.modules.md +7 -0
  78. package/template/modules/audit/wiring/auth/web.messages.ts +26 -0
  79. package/template/modules/audit/wiring/auth/web.routes.imports.tsx +1 -0
  80. package/template/modules/audit/wiring/auth/web.routes.tsx +1 -0
  81. package/template/modules/audit/wiring/auth/worker.imports.ts +1 -0
  82. package/template/modules/audit/wiring/auth/worker.routes.ts +2 -0
  83. package/template/modules/audit/wiring/d1.jsonc +8 -0
  84. package/template/modules/audit/wiring/env.bindings.ts +4 -0
  85. package/template/modules/audit/wiring/index.imports.ts +1 -0
  86. package/template/modules/audit/wiring/index.setup.ts +2 -0
  87. package/template/modules/audit/wiring/readme.modules.md +16 -0
  88. package/template/modules/audit/wiring/test.env.ts +1 -0
  89. package/template/modules/audit/wiring/test.migrate.ts +1 -0
  90. package/template/modules/audit/wiring/vars.deployed.jsonc +2 -0
  91. package/template/modules/audit/wiring/vars.dev.jsonc +2 -0
  92. package/template/modules/audit/wiring/vitest.bindings.ts +1 -0
  93. package/template/modules/audit/with-auth/e2e/my-activity.spec.ts +89 -0
  94. package/template/modules/audit/with-auth/src/audit-routes.ts +21 -0
  95. package/template/modules/audit/with-auth/test/audit-routes.test.ts +17 -0
  96. package/template/modules/audit/with-auth/web/src/my-activity.tsx +168 -0
  97. package/template/modules/auth/files/e2e/auth-accessibility.spec.ts +68 -0
  98. package/template/modules/auth/files/e2e/auth-bootstrap-admin.spec.ts +58 -0
  99. package/template/modules/auth/files/e2e/auth-helpers.ts +74 -0
  100. package/template/modules/auth/files/e2e/auth-invitations.spec.ts +51 -0
  101. package/template/modules/auth/files/e2e/auth-phone.spec.ts +41 -0
  102. package/template/modules/auth/files/e2e/auth-seeded.ts +97 -0
  103. package/template/modules/auth/files/e2e/auth-sign-in.spec.ts +57 -0
  104. package/template/modules/auth/files/e2e/auth-two-factor.spec.ts +44 -0
  105. package/template/modules/auth/files/scripts/seed-user.mjs +64 -0
  106. package/template/modules/auth/files/src/auth-choices.ts +33 -0
  107. package/template/modules/auth/files/src/auth-config.ts +55 -0
  108. package/template/modules/auth/files/src/auth.ts +68 -0
  109. package/template/modules/auth/files/src/local-test-mode.ts +49 -0
  110. package/template/modules/auth/files/src/notifications.ts +94 -0
  111. package/template/modules/auth/files/test/auth-fixtures.ts +135 -0
  112. package/template/modules/auth/files/test/auth.test.ts +210 -0
  113. package/template/modules/auth/files/test/totp.ts +49 -0
  114. package/template/modules/auth/files/test/turnstile-fake.ts +42 -0
  115. package/template/modules/auth/files/web/src/account-menu.tsx +25 -0
  116. package/template/modules/auth/files/web/src/account.tsx +87 -0
  117. package/template/modules/auth/files/web/src/auth-client.ts +60 -0
  118. package/template/modules/auth/files/web/src/auth-pages.tsx +152 -0
  119. package/template/modules/auth/module.json +56 -0
  120. package/template/modules/auth/wiring/bindings.deployed.jsonc +2 -0
  121. package/template/modules/auth/wiring/d1.jsonc +7 -0
  122. package/template/modules/auth/wiring/devvars.txt +12 -0
  123. package/template/modules/auth/wiring/env.bindings.ts +26 -0
  124. package/template/modules/auth/wiring/index.deps.ts +1 -0
  125. package/template/modules/auth/wiring/index.imports.ts +4 -0
  126. package/template/modules/auth/wiring/index.setup.ts +7 -0
  127. package/template/modules/auth/wiring/playwright.imports.ts +1 -0
  128. package/template/modules/auth/wiring/playwright.servers.ts +12 -0
  129. package/template/modules/auth/wiring/playwright.setup.ts +2 -0
  130. package/template/modules/auth/wiring/playwright.vars.ts +2 -0
  131. package/template/modules/auth/wiring/readme.modules.md +30 -0
  132. package/template/modules/auth/wiring/readme.provisioning.md +11 -0
  133. package/template/modules/auth/wiring/secrets.jsonc +2 -0
  134. package/template/modules/auth/wiring/test.env.ts +1 -0
  135. package/template/modules/auth/wiring/test.migrate.ts +1 -0
  136. package/template/modules/auth/wiring/vars.dev.jsonc +19 -0
  137. package/template/modules/auth/wiring/vars.production.jsonc +13 -0
  138. package/template/modules/auth/wiring/vars.staging.jsonc +13 -0
  139. package/template/modules/auth/wiring/vitest.bindings.ts +6 -0
  140. package/template/modules/auth/wiring/web.header.tsx +1 -0
  141. package/template/modules/auth/wiring/web.layout.imports.tsx +1 -0
  142. package/template/modules/auth/wiring/web.messages.ts +17 -0
  143. package/template/modules/auth/wiring/web.routes.imports.tsx +12 -0
  144. package/template/modules/auth/wiring/web.routes.tsx +11 -0
  145. package/template/modules/auth/wiring/web.styles.css +1 -0
  146. package/template/modules/auth/wiring/worker.deps.ts +2 -0
  147. package/template/modules/auth/wiring/worker.imports.ts +1 -0
  148. package/template/modules/auth/wiring/worker.routes.ts +2 -0
  149. package/template/modules/example/files/e2e/example-notes.spec.ts +39 -0
  150. package/template/modules/example/files/migrations/0002_example_notes.sql +12 -0
  151. package/template/modules/example/files/src/example-audit-actions.ts +12 -0
  152. package/template/modules/example/files/src/example-notes.ts +136 -0
  153. package/template/modules/example/files/test/example-notes-fixtures.ts +30 -0
  154. package/template/modules/example/files/test/example-notes.test.ts +52 -0
  155. package/template/modules/example/files/web/src/example-notes.tsx +176 -0
  156. package/template/modules/example/module.json +61 -0
  157. package/template/modules/example/wiring/access-control/access.permissions.ts +3 -0
  158. package/template/modules/example/wiring/access-control/access.roles.ts +2 -0
  159. package/template/modules/example/wiring/access-control/example.e2e.imports.ts +2 -0
  160. package/template/modules/example/wiring/access-control/example.e2e.setup.ts +18 -0
  161. package/template/modules/example/wiring/access-control/example.imports.ts +3 -0
  162. package/template/modules/example/wiring/access-control/example.policy.ts +10 -0
  163. package/template/modules/example/wiring/access-control/example.test.caller.ts +17 -0
  164. package/template/modules/example/wiring/access-control/example.test.imports.ts +1 -0
  165. package/template/modules/example/wiring/access-control/readme.modules.md +5 -0
  166. package/template/modules/example/wiring/admin/admin.imports.tsx +1 -0
  167. package/template/modules/example/wiring/admin/admin.nav.tsx +2 -0
  168. package/template/modules/example/wiring/admin/admin.pages.tsx +2 -0
  169. package/template/modules/example/wiring/admin/readme.modules.md +4 -0
  170. package/template/modules/example/wiring/admin/web.messages.ts +11 -0
  171. package/template/modules/example/wiring/admin/worker.imports.ts +1 -0
  172. package/template/modules/example/wiring/admin/worker.routes.ts +2 -0
  173. package/template/modules/example/wiring/audit/audit.catalogues.imports.ts +1 -0
  174. package/template/modules/example/wiring/audit/audit.catalogues.ts +2 -0
  175. package/template/modules/example/wiring/audit/readme.modules.md +4 -0
  176. package/template/modules/example/wiring/readme.modules.md +16 -0
  177. package/template/modules/example/wiring/web.messages.ts +19 -0
  178. package/template/modules/example/wiring/web.routes.imports.tsx +1 -0
  179. package/template/modules/example/wiring/web.routes.tsx +2 -0
  180. package/template/modules/example/wiring/worker.imports.ts +1 -0
  181. package/template/modules/example/wiring/worker.routes.ts +2 -0
  182. package/template/modules/example/with-access-control/test/example-notes-access.test.ts +51 -0
  183. package/template/modules/example/with-admin/e2e/example-notes-admin.spec.ts +55 -0
  184. package/template/modules/example/with-admin/src/example-notes-admin.ts +62 -0
  185. package/template/modules/example/with-admin/test/example-notes-admin.test.ts +42 -0
  186. package/template/modules/example/with-admin/web/src/admin-notes.tsx +99 -0
  187. package/template/modules/example/with-audit/test/example-notes-audit.test.ts +22 -0
  188. package/template/package.json +57 -0
  189. package/template/playwright.config.ts +38 -0
  190. package/template/renovate.json +12 -0
  191. package/template/src/app.ts +46 -0
  192. package/template/src/audit-catalogues.ts +9 -0
  193. package/template/src/audit-sink.ts +21 -0
  194. package/template/src/env.d.ts +21 -0
  195. package/template/src/index.ts +18 -0
  196. package/template/src/v1.ts +24 -0
  197. package/template/standalone/.github/workflows/ci.yml +21 -0
  198. package/template/standalone/.github/workflows/deploy.yml +32 -0
  199. package/template/standalone/.nvmrc +1 -0
  200. package/template/standalone/_gitignore +9 -0
  201. package/template/standalone/pnpm-workspace.yaml +11 -0
  202. package/template/template.json +56 -0
  203. package/template/test/env.d.ts +11 -0
  204. package/template/test/migrate.ts +11 -0
  205. package/template/test/migrations.test.ts +22 -0
  206. package/template/test/worker.test.ts +65 -0
  207. package/template/tsconfig.json +7 -0
  208. package/template/vitest.config.ts +24 -0
  209. package/template/web/index.html +12 -0
  210. package/template/web/src/app.tsx +44 -0
  211. package/template/web/src/home.tsx +51 -0
  212. package/template/web/src/layout.tsx +48 -0
  213. package/template/web/src/main.tsx +16 -0
  214. package/template/web/src/messages.ts +38 -0
  215. package/template/web/src/navigation.tsx +34 -0
  216. package/template/web/src/route-focus.ts +49 -0
  217. package/template/web/src/routes.tsx +38 -0
  218. package/template/web/src/styles.css +11 -0
  219. package/template/web/src/system-theme.ts +16 -0
  220. package/template/web/tsconfig.json +7 -0
  221. package/template/web/vite.config.ts +15 -0
  222. package/template/wrangler.jsonc +96 -0
@@ -0,0 +1,68 @@
1
+ import { createInbisAuth, type InbisAuthConfig } from "@awsmbiz/inbis-auth";
2
+ import { mountAuth, requireAuth, sessionMiddleware, type AuthEnv, type AuthResolver } from "@awsmbiz/inbis-auth/hono";
3
+ import type { ApiEnv } from "@awsmbiz/inbis-api";
4
+ import { requireBinding, requireSecret } from "@awsmbiz/inbis-cloudflare";
5
+ import { authEmailTemplates } from "@awsmbiz/inbis-notifications";
6
+ import type { Hono } from "hono";
7
+ import { auditSinkFor } from "./audit-sink.js";
8
+ import type { AppNotifications } from "./notifications.js";
9
+
10
+ /** What the auth wiring needs; `src/index.ts` builds it from `env`, tests inject fakes. */
11
+ export interface AuthDeps extends AppNotifications {
12
+ authConfig: InbisAuthConfig;
13
+ /** Outbound fetch for the breached-password check; defaults to the global `fetch`. */
14
+ fetch?: typeof fetch;
15
+ }
16
+
17
+ /**
18
+ * One `@awsmbiz/inbis-auth` instance per request, from the app's bindings and secrets
19
+ * (ADR-1). Auth events go to the app's audit trail when it has one (src/audit-sink.ts);
20
+ * without one, auth keeps its own interim history.
21
+ */
22
+ export function authResolver(deps: AuthDeps): AuthResolver<ApiEnv<Env>> {
23
+ return (c) => {
24
+ const audit = auditSinkFor(c.env, { logger: c.var.logger });
25
+ return createInbisAuth({
26
+ config: deps.authConfig,
27
+ env: { authSecret: requireSecret(c.env, "AUTH_SECRET"), turnstileSecretKey: requireSecret(c.env, "TURNSTILE_SECRET_KEY") },
28
+ db: requireBinding(c.env, "AUTH_DB"),
29
+ email: deps.email,
30
+ emailTemplates: authEmailTemplates,
31
+ sms: deps.sms,
32
+ logger: c.var.logger,
33
+ requestId: c.var.requestId,
34
+ ...(audit === undefined ? {} : { audit }),
35
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
36
+ });
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Mounts sign-in on the app: the session on every `/api/*` request, the auth routes
42
+ * under `/api/auth` (sign-in, sign-up, phone codes, 2FA, invitations, sessions),
43
+ * `GET /api/me` (the signed-in user), and the local email/SMS outboxes.
44
+ */
45
+ export function mountAppAuth(app: Hono<ApiEnv<Env>>, deps: AuthDeps | undefined): AuthResolver<ApiEnv<Env>> {
46
+ if (deps === undefined) throw new Error("createApp needs deps.auth (see src/index.ts).");
47
+ const authFor = authResolver(deps);
48
+ app.use("/api/*", sessionMiddleware(authFor));
49
+ mountAuth(app, deps.authConfig, authFor);
50
+ const signedIn = app as unknown as Hono<ApiEnv<Env> & AuthEnv>;
51
+ signedIn.get("/api/me", requireAuth(), (c) => c.json({ user: c.var.user, session: c.var.session }));
52
+
53
+ // Local development only (log transports on a local host): newest message first.
54
+ const { outbox, smsOutbox } = deps;
55
+ if (outbox !== undefined) {
56
+ app.get("/api/dev/outbox", (c) => {
57
+ c.header("cache-control", "no-store");
58
+ return c.json({ emails: [...outbox.recent()].reverse() });
59
+ });
60
+ }
61
+ if (smsOutbox !== undefined) {
62
+ app.get("/api/dev/sms-outbox", (c) => {
63
+ c.header("cache-control", "no-store");
64
+ return c.json({ messages: [...smsOutbox.recent()].reverse() });
65
+ });
66
+ }
67
+ return authFor;
68
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Local test mode (`LOCAL_TEST_MODE=true`, set by the Playwright suite): the Worker
3
+ * answers the two outside services auth calls itself, Cloudflare Turnstile's
4
+ * siteverify and the Pwned Passwords range API, as the real services answer for
5
+ * Cloudflare's Turnstile testing secrets and a password that is not breached, and
6
+ * auth rate limits are raised (src/auth-config.ts). So e2e runs never depend on, or
7
+ * send requests to, third parties. Honoured only when the app runs on a local host;
8
+ * a deployed Worker always calls the real services. The browser still loads the
9
+ * real Turnstile widget.
10
+ */
11
+
12
+ export const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
13
+
14
+ /** Whether the app runs on this machine (`AUTH_BASE_URL` is a local host). */
15
+ export function isLocal(vars: Pick<Env, "AUTH_BASE_URL">): boolean {
16
+ return LOCAL_HOSTS.has(new URL(vars.AUTH_BASE_URL).hostname);
17
+ }
18
+
19
+ export function localTestMode(vars: Pick<Env, "AUTH_BASE_URL" | "LOCAL_TEST_MODE">): boolean {
20
+ return vars.LOCAL_TEST_MODE === "true" && isLocal(vars);
21
+ }
22
+
23
+ const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
24
+ const PWNED_RANGE_PREFIX = "https://api.pwnedpasswords.com/range/";
25
+
26
+ /** Cloudflare's Turnstile testing secrets and what siteverify answers for each. */
27
+ const TESTING_SECRETS: Record<string, { success: boolean; "error-codes": string[] }> = {
28
+ "1x0000000000000000000000000000000AA": { success: true, "error-codes": [] },
29
+ "2x0000000000000000000000000000000AA": { success: false, "error-codes": ["invalid-input-response"] },
30
+ "3x0000000000000000000000000000000AA": { success: false, "error-codes": ["timeout-or-duplicate"] },
31
+ };
32
+
33
+ function siteverify(init: RequestInit | undefined): Response {
34
+ const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as { secret?: string; response?: string };
35
+ if ((body.response ?? "") === "") return Response.json({ success: false, "error-codes": ["missing-input-response"] });
36
+ const answer = TESTING_SECRETS[body.secret ?? ""] ?? { success: false, "error-codes": ["invalid-input-secret"] };
37
+ return Response.json({ ...answer, ...(answer.success ? { hostname: "localhost" } : {}) });
38
+ }
39
+
40
+ /** `base`, except that siteverify and the Pwned Passwords range API are answered locally. */
41
+ export function stubbedFetch(base: typeof fetch): typeof fetch {
42
+ return (input, init) => {
43
+ const url = input instanceof Request ? input.url : String(input);
44
+ if (url === SITEVERIFY_URL) return Promise.resolve(siteverify(init));
45
+ // An empty range: no breached password has this hash prefix.
46
+ if (url.startsWith(PWNED_RANGE_PREFIX)) return Promise.resolve(new Response("", { status: 200 }));
47
+ return base(input, init);
48
+ };
49
+ }
@@ -0,0 +1,94 @@
1
+ import { AUTH_SMS_TEMPLATES, requiredAuthSmsTemplates, type InbisAuthConfig } from "@awsmbiz/inbis-auth";
2
+ import { requireBinding } from "@awsmbiz/inbis-cloudflare";
3
+ import type { EmailSender, SmsSender } from "@awsmbiz/inbis-core";
4
+ import {
5
+ communicationServiceBinding,
6
+ createCommunicationServiceEmailSender,
7
+ createCommunicationServiceSmsSender,
8
+ createLogEmailSender,
9
+ createLogSmsSender,
10
+ type LoggedEmail,
11
+ type LoggedSms,
12
+ } from "@awsmbiz/inbis-notifications";
13
+ import { isLocal } from "./local-test-mode.js";
14
+
15
+ /** How auth emails and SMS leave the Worker, plus the local outboxes when it is safe to expose them. */
16
+ export interface AppNotifications {
17
+ email: EmailSender;
18
+ sms: SmsSender;
19
+ /** Recent emails for `GET /api/dev/outbox`: the log transport on a local host only. */
20
+ outbox?: { recent(): readonly LoggedEmail[] };
21
+ /** Recent SMS for `GET /api/dev/sms-outbox`: the log transport on a local host only. */
22
+ smsOutbox?: { recent(): readonly LoggedSms[] };
23
+ }
24
+
25
+ /** The Worker variables and bindings the notification wiring reads. */
26
+ export type NotificationsEnv = Pick<
27
+ Env,
28
+ | "EMAIL_TRANSPORT"
29
+ | "SMS_TRANSPORT"
30
+ | "AUTH_BASE_URL"
31
+ | "APP_ID"
32
+ | "COMMUNICATION_SERVICE"
33
+ | "SMS_HEADER"
34
+ | "SMS_OTP_TEMPLATE_ID"
35
+ | "SMS_INVITATION_TEMPLATE_ID"
36
+ >;
37
+
38
+ /**
39
+ * The text of each auth SMS exactly as registered with the SMS provider (through the
40
+ * communication service), with `{variable}` placeholders. Change a text only together
41
+ * with its registration, or the provider rejects every message.
42
+ */
43
+ export const SMS_TEXTS = {
44
+ [AUTH_SMS_TEMPLATES.phoneOtp]: "{code} is your {appName} sign-in code. It expires in {minutes} minutes.",
45
+ [AUTH_SMS_TEMPLATES.invitation]: "{inviter} invited you to {appName}. Sign in with this phone number to join: {url}",
46
+ } as const;
47
+
48
+ /**
49
+ * Picks the email and SMS adapters from `EMAIL_TRANSPORT` / `SMS_TRANSPORT`:
50
+ * - `log` (local development): prints every message and keeps an outbox, exposed
51
+ * only when the app runs on a local host (it holds live links and sign-in codes);
52
+ * - `communication-service`: the shared communication service through the
53
+ * `COMMUNICATION_SERVICE` service binding, as app `APP_ID`; SMS also need the
54
+ * sender header and the template IDs of the texts above.
55
+ *
56
+ * Throws at startup when a transport is unknown or its settings are missing.
57
+ */
58
+ export function appNotifications(env: NotificationsEnv, authConfig: InbisAuthConfig): AppNotifications {
59
+ const local = isLocal(env);
60
+ const binding = () => communicationServiceBinding(requireBinding(env, "COMMUNICATION_SERVICE"));
61
+ const result: Partial<AppNotifications> = {};
62
+
63
+ if (env.EMAIL_TRANSPORT === "communication-service") {
64
+ result.email = createCommunicationServiceEmailSender({ binding: binding(), appId: env.APP_ID });
65
+ } else if (env.EMAIL_TRANSPORT === "log") {
66
+ const log = createLogEmailSender();
67
+ result.email = log;
68
+ if (local) result.outbox = log;
69
+ } else {
70
+ throw new Error('EMAIL_TRANSPORT must be "log" or "communication-service".');
71
+ }
72
+
73
+ if (env.SMS_TRANSPORT === "communication-service") {
74
+ const { phoneOtp, invitation } = AUTH_SMS_TEMPLATES;
75
+ result.sms = createCommunicationServiceSmsSender({
76
+ binding: binding(),
77
+ appId: env.APP_ID,
78
+ smsHeader: env.SMS_HEADER,
79
+ templates: {
80
+ [phoneOtp]: { templateId: env.SMS_OTP_TEMPLATE_ID, text: SMS_TEXTS[phoneOtp] },
81
+ [invitation]: { templateId: env.SMS_INVITATION_TEMPLATE_ID, text: SMS_TEXTS[invitation] },
82
+ },
83
+ requiredTemplates: requiredAuthSmsTemplates(authConfig),
84
+ });
85
+ } else if (env.SMS_TRANSPORT === "log") {
86
+ const log = createLogSmsSender();
87
+ result.sms = log;
88
+ if (local) result.smsOutbox = log;
89
+ } else {
90
+ throw new Error('SMS_TRANSPORT must be "log" or "communication-service".');
91
+ }
92
+
93
+ return result as AppNotifications;
94
+ }
@@ -0,0 +1,135 @@
1
+ import { createCapturingEmailSender, createCapturingSmsSender } from "@awsmbiz/inbis-core/testing";
2
+ import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
3
+ import { env } from "cloudflare:workers";
4
+ import { createApp } from "../src/app.js";
5
+ import { AUTH_CHOICES, type AuthChoices } from "../src/auth-choices.js";
6
+ import { appAuthConfig } from "../src/auth-config.js";
7
+ import { migrate as migrateAll } from "./migrate.js";
8
+ import { secretOf, totpCode } from "./totp.js";
9
+ import { fakeSiteverify, TURNSTILE_TEST } from "./turnstile-fake.js";
10
+
11
+ /** Origin the tests call the Worker on; `AUTH_BASE_URL` in vitest.config.ts. */
12
+ export const ORIGIN = "https://app.example.test";
13
+
14
+ const TEST_RATE_LIMIT = { default: { window: 60, max: 5000 }, signIn: { window: 60, max: 5000 }, twoFactor: { window: 60, max: 5000 } };
15
+
16
+ /** Every migration (platform sources first, as `pnpm db:migrate`), and a fake Turnstile siteverify. */
17
+ export async function migrate(): Promise<void> {
18
+ fakeSiteverify();
19
+ await migrateAll();
20
+ }
21
+
22
+ /**
23
+ * The app Worker built as `src/index.ts` builds it, with `choices` instead of the app's
24
+ * own (src/auth-choices.ts), and capturing email/SMS senders, so tests read the links
25
+ * and codes the user would receive.
26
+ */
27
+ export function testApp(choices: AuthChoices = AUTH_CHOICES) {
28
+ const email = createCapturingEmailSender();
29
+ const sms = createCapturingSmsSender();
30
+ const config = appAuthConfig(env, choices);
31
+ const app = createApp({
32
+ auth: {
33
+ // Raised rate limits: every test signs in from the same (absent) client address.
34
+ authConfig: { ...config, security: { ...config.security, rateLimit: TEST_RATE_LIMIT } },
35
+ email,
36
+ sms,
37
+ // A Pwned Passwords range API that knows no breached password: tests never reach the network.
38
+ fetch: () => Promise.resolve(new Response("", { status: 200 })),
39
+ },
40
+ });
41
+
42
+ async function call(path: string, init: RequestInit = {}): Promise<Response> {
43
+ const ctx = createExecutionContext();
44
+ const response = await app.fetch(new Request(`${ORIGIN}${path}`, init), env, ctx);
45
+ await waitOnExecutionContext(ctx);
46
+ return response;
47
+ }
48
+
49
+ return { call, email, sms };
50
+ }
51
+
52
+ export type TestApp = ReturnType<typeof testApp>;
53
+
54
+ /** Every method on, and the given registration mode. */
55
+ export function choicesWith(registration: AuthChoices["registration"], phoneOtp: AuthChoices["phoneOtp"] = false): AuthChoices {
56
+ return { emailPassword: true, phoneOtp, twoFactor: true, registration };
57
+ }
58
+
59
+ /** A JSON POST as the browser sends it from the app's own origin, with the Turnstile widget's token. */
60
+ export function json(body: unknown, headers: Record<string, string> = {}): RequestInit {
61
+ return {
62
+ method: "POST",
63
+ headers: { "content-type": "application/json", origin: ORIGIN, "x-captcha-response": TURNSTILE_TEST.token, ...headers },
64
+ body: JSON.stringify(body),
65
+ };
66
+ }
67
+
68
+ /** The `name=value` pairs of every Set-Cookie header, as a Cookie request header. */
69
+ export function cookieHeader(response: Response): string {
70
+ return response.headers
71
+ .getSetCookie()
72
+ .map((cookie) => cookie.split(";")[0])
73
+ .join("; ");
74
+ }
75
+
76
+ /** The links in an email's text part. */
77
+ export function linksIn(email: { text: string } | undefined): string[] {
78
+ return email?.text.match(/https?:\/\/\S+/g) ?? [];
79
+ }
80
+
81
+ export interface TestUser {
82
+ name: string;
83
+ email: string;
84
+ password: string;
85
+ }
86
+
87
+ /** A user with a fresh email address (D1 state persists across the tests of a file). */
88
+ export function newUser(name = "Ada Lovelace"): TestUser {
89
+ return { name, email: `user-${crypto.randomUUID()}@example.test`, password: "correct horse battery" };
90
+ }
91
+
92
+ /** Creates a verified user through public sign-up (registration "open") and the emailed link, as a user would. */
93
+ export async function seedUser(user: TestUser): Promise<void> {
94
+ const open = testApp(choicesWith("open"));
95
+ const response = await open.call("/api/auth/sign-up/email", json(user));
96
+ if (response.status !== 200) throw new Error(`Seeding failed: ${response.status} ${await response.text()}`);
97
+ const [link] = linksIn(open.email.lastTo(user.email));
98
+ if (link === undefined) throw new Error("Sign-up sent no verification email.");
99
+ const url = new URL(link);
100
+ const verified = await open.call(`${url.pathname}${url.search}`);
101
+ if (verified.status >= 400) throw new Error(`Verifying the seeded user failed: ${verified.status}`);
102
+ }
103
+
104
+ /** Signs in with email and password; returns the session cookie. */
105
+ export async function signIn(app: TestApp, user: TestUser): Promise<string> {
106
+ const response = await app.call("/api/auth/sign-in/email", json({ email: user.email, password: user.password }));
107
+ if (response.status !== 200) throw new Error(`Sign-in failed: ${response.status} ${await response.text()}`);
108
+ return cookieHeader(response);
109
+ }
110
+
111
+ /**
112
+ * A verified user made an administrator directly in the auth database (test setup only;
113
+ * a real app gets its first administrator from `inbis-auth bootstrap-admin`), with an
114
+ * enrolled authenticator, because administrators need 2FA. Returns the 2FA-verified
115
+ * session cookie.
116
+ */
117
+ export async function seedAdmin(user: TestUser = newUser("Grace Admin")): Promise<string> {
118
+ await seedUser(user);
119
+ await env.AUTH_DB.prepare("UPDATE user SET role = 'admin' WHERE email = ?").bind(user.email).run();
120
+ const app = testApp(choicesWith("invite-only"));
121
+ const cookie = await signIn(app, user);
122
+ const enabled = await app.call("/api/auth/two-factor/enable", json({ password: user.password }, { cookie }));
123
+ if (enabled.status !== 200) throw new Error(`Admin 2FA enrolment failed: ${enabled.status}`);
124
+ const secret = secretOf((await enabled.json<{ totpURI: string }>()).totpURI);
125
+ const verified = await app.call("/api/auth/two-factor/verify-totp", json({ code: await totpCode(secret) }, { cookie }));
126
+ if (verified.status !== 200) throw new Error(`Admin 2FA verification failed: ${verified.status}`);
127
+ // Enrolment replaces the session; the last session cookie set is the new, verified one.
128
+ const session = verified.headers
129
+ .getSetCookie()
130
+ .map((c) => c.split(";")[0] ?? "")
131
+ .filter((pair) => pair.includes("session_token="))
132
+ .at(-1);
133
+ if (session === undefined) throw new Error("Admin 2FA enrolment set no session cookie.");
134
+ return session;
135
+ }
@@ -0,0 +1,210 @@
1
+ import { validateAuthConfig } from "@awsmbiz/inbis-auth";
2
+ import { env, exports } from "cloudflare:workers";
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { AUTH_CHOICES } from "../src/auth-choices.js";
5
+ import { appAuthConfig } from "../src/auth-config.js";
6
+ import { appNotifications } from "../src/notifications.js";
7
+ import {
8
+ choicesWith,
9
+ cookieHeader,
10
+ json,
11
+ linksIn,
12
+ migrate,
13
+ newUser,
14
+ ORIGIN,
15
+ seedAdmin,
16
+ seedUser,
17
+ signIn,
18
+ testApp,
19
+ } from "./auth-fixtures.js";
20
+ import { secretOf, totpCode } from "./totp.js";
21
+
22
+ // Sign-in wired into the app, over HTTP. The app's own choices (src/auth-choices.ts) are
23
+ // checked first; the journeys then run with explicit choices, so every registration mode
24
+ // and method is covered whatever this app chose. @awsmbiz/inbis-auth's own tests cover
25
+ // each rule in detail; these prove the wiring.
26
+
27
+ beforeEach(async () => {
28
+ await migrate();
29
+ });
30
+
31
+ describe("the app's auth config", () => {
32
+ it("is valid and is what the public config reports", async () => {
33
+ expect(() => validateAuthConfig(appAuthConfig(env))).not.toThrow();
34
+
35
+ const response = await exports.default.fetch(new Request(`${ORIGIN}/api/auth/public-config`));
36
+ const config = await response.json<{
37
+ registration: { mode: string };
38
+ methods: { emailPassword?: object; phoneOtp?: object };
39
+ twoFactor?: object;
40
+ }>();
41
+ expect(response.status).toBe(200);
42
+ expect(config.registration.mode).toBe(AUTH_CHOICES.registration);
43
+ expect(config.methods.emailPassword !== undefined).toBe(AUTH_CHOICES.emailPassword);
44
+ expect(config.methods.phoneOtp !== undefined).toBe(AUTH_CHOICES.phoneOtp !== false);
45
+ expect(config.twoFactor !== undefined).toBe(AUTH_CHOICES.twoFactor);
46
+ });
47
+
48
+ it("GET /api/me needs a session", async () => {
49
+ const response = await exports.default.fetch(new Request(`${ORIGIN}/api/me`));
50
+ expect(response.status).toBe(401);
51
+ });
52
+ });
53
+
54
+ describe("email and password", () => {
55
+ it("a verified user signs in, the app knows who it is, and signs out", async () => {
56
+ const user = newUser();
57
+ await seedUser(user);
58
+ const app = testApp(choicesWith("invite-only"));
59
+
60
+ const cookie = await signIn(app, user);
61
+ const me = await app.call("/api/me", { headers: { cookie } });
62
+ expect(await me.json()).toMatchObject({ user: { email: user.email, name: user.name, role: "user" } });
63
+
64
+ const out = await app.call("/api/auth/sign-out", json({}, { cookie }));
65
+ expect(out.status).toBe(200);
66
+ expect((await app.call("/api/me", { headers: { cookie } })).status).toBe(401);
67
+ });
68
+
69
+ it("a wrong password is refused", async () => {
70
+ const user = newUser();
71
+ await seedUser(user);
72
+ const response = await testApp().call("/api/auth/sign-in/email", json({ email: user.email, password: "not the password" }));
73
+ expect(response.status).toBe(401);
74
+ });
75
+
76
+ it("self sign-up works only when registration is open", async () => {
77
+ const closed = await testApp(choicesWith("invite-only")).call("/api/auth/sign-up/email", json(newUser()));
78
+ expect(closed.status).toBeGreaterThanOrEqual(400);
79
+ const open = await testApp(choicesWith("open")).call("/api/auth/sign-up/email", json(newUser()));
80
+ expect(open.status).toBe(200);
81
+ });
82
+ });
83
+
84
+ describe("invitations", () => {
85
+ it("invite-only: an administrator invites by email; the invitee accepts from the link and signs in", async () => {
86
+ const admin = await seedAdmin();
87
+ const app = testApp(choicesWith("invite-only"));
88
+ const invitee = newUser("Ivy Invitee");
89
+
90
+ const created = await app.call("/api/auth/admin/create-invitation", json({ email: invitee.email }, { cookie: admin }));
91
+ expect(created.status).toBe(200);
92
+ const [link] = linksIn(app.email.lastTo(invitee.email));
93
+ const url = new URL(link ?? "");
94
+ expect(`${url.origin}${url.pathname}`).toBe(`${ORIGIN}/accept-invitation`);
95
+
96
+ const token = url.searchParams.get("token") ?? "";
97
+ const accepted = await app.call("/api/auth/invitation/accept", json({ token, name: invitee.name, password: invitee.password }));
98
+ expect(accepted.status).toBe(200);
99
+ await signIn(app, invitee);
100
+ });
101
+
102
+ it.each(["admin-only", "closed"] as const)("%s: invitations are refused", async (mode) => {
103
+ const admin = await seedAdmin();
104
+ const app = testApp(choicesWith(mode));
105
+
106
+ const created = await app.call("/api/auth/admin/create-invitation", json({ email: newUser().email }, { cookie: admin }));
107
+ expect(created.status).toBe(403);
108
+ expect(await created.text()).toContain("REGISTRATION_NOT_ALLOWED");
109
+ expect(app.email.messages).toEqual([]);
110
+ });
111
+
112
+ it("admin-only: an administrator creates the account directly", async () => {
113
+ const admin = await seedAdmin();
114
+ const app = testApp(choicesWith("admin-only"));
115
+ const user = newUser();
116
+
117
+ const created = await app.call(
118
+ "/api/auth/admin/create-user",
119
+ json({ name: user.name, email: user.email, password: user.password, data: { emailVerified: true } }, { cookie: admin }),
120
+ );
121
+ expect(created.status).toBe(200);
122
+ await signIn(app, user);
123
+ });
124
+ });
125
+
126
+ describe("phone sign-in", () => {
127
+ const phone = { allowedCountries: ["+44"], blockedCountries: ["+4479"] };
128
+
129
+ it("an invited number signs in with the SMS code", async () => {
130
+ const admin = await seedAdmin();
131
+ const app = testApp(choicesWith("invite-only", phone));
132
+ const number = "+447700900321";
133
+
134
+ const invited = await app.call("/api/auth/admin/create-invitation", json({ phoneNumber: number }, { cookie: admin }));
135
+ expect(invited.status).toBe(200);
136
+ expect(app.sms.lastTo(number)?.text).toContain("invited you");
137
+
138
+ expect((await app.call("/api/auth/phone-number/send-otp", json({ phoneNumber: number }))).status).toBe(200);
139
+ const code = app.sms.lastTo(number)?.template?.variables["code"] ?? "";
140
+ const verified = await app.call("/api/auth/phone-number/verify", json({ phoneNumber: number, code }));
141
+ expect(verified.status).toBe(200);
142
+ const me = await app.call("/api/me", { headers: { cookie: cookieHeader(verified) } });
143
+ expect(await me.json()).toMatchObject({ user: { phoneNumber: number, emailIsPlaceholder: true } });
144
+ });
145
+
146
+ it.each([
147
+ ["outside the allowed countries", "+15550100123"],
148
+ ["in a blocked range", "+447900900321"],
149
+ ])("sends no code to a number %s", async (_, number) => {
150
+ const app = testApp(choicesWith("open", phone));
151
+ const response = await app.call("/api/auth/phone-number/send-otp", json({ phoneNumber: number }));
152
+ expect(response.status).toBeGreaterThanOrEqual(400);
153
+ expect(app.sms.messages).toEqual([]);
154
+ });
155
+ });
156
+
157
+ describe("two-factor authentication", () => {
158
+ it("a user enrols an authenticator; the next sign-in needs its code", async () => {
159
+ const user = newUser();
160
+ await seedUser(user);
161
+ const app = testApp(choicesWith("invite-only"));
162
+ const cookie = await signIn(app, user);
163
+
164
+ const enabled = await app.call("/api/auth/two-factor/enable", json({ password: user.password }, { cookie }));
165
+ expect(enabled.status).toBe(200);
166
+ const secret = secretOf((await enabled.json<{ totpURI: string }>()).totpURI);
167
+ expect((await app.call("/api/auth/two-factor/verify-totp", json({ code: await totpCode(secret) }, { cookie }))).status).toBe(200);
168
+
169
+ const challenged = await app.call("/api/auth/sign-in/email", json({ email: user.email, password: user.password }));
170
+ expect(await challenged.json()).toMatchObject({ twoFactorRedirect: true });
171
+ const pending = cookieHeader(challenged);
172
+ expect((await app.call("/api/me", { headers: { cookie: pending } })).status).toBe(401);
173
+
174
+ const verified = await app.call("/api/auth/two-factor/verify-totp", json({ code: await totpCode(secret) }, { cookie: pending }));
175
+ expect(verified.status).toBe(200);
176
+ expect((await app.call("/api/me", { headers: { cookie: cookieHeader(verified) } })).status).toBe(200);
177
+ });
178
+ });
179
+
180
+ describe("email and SMS delivery", () => {
181
+ const base = {
182
+ AUTH_BASE_URL: "http://localhost:8787",
183
+ APP_ID: "",
184
+ SMS_HEADER: "",
185
+ SMS_OTP_TEMPLATE_ID: "",
186
+ SMS_INVITATION_TEMPLATE_ID: "",
187
+ };
188
+ const config = appAuthConfig(env);
189
+
190
+ it("logs locally and keeps an outbox only on a local host", () => {
191
+ const local = appNotifications({ ...base, EMAIL_TRANSPORT: "log", SMS_TRANSPORT: "log" }, config);
192
+ expect(local.outbox).toBeDefined();
193
+ expect(local.smsOutbox).toBeDefined();
194
+ const deployed = appNotifications({ ...base, AUTH_BASE_URL: "https://app.example.test", EMAIL_TRANSPORT: "log", SMS_TRANSPORT: "log" }, config);
195
+ expect(deployed.outbox).toBeUndefined();
196
+ expect(deployed.smsOutbox).toBeUndefined();
197
+ });
198
+
199
+ it("fails at startup without the communication service binding or with an unknown transport", () => {
200
+ expect(() => appNotifications({ ...base, APP_ID: "app", EMAIL_TRANSPORT: "communication-service", SMS_TRANSPORT: "log" }, config)).toThrow(
201
+ /misconfigured/,
202
+ );
203
+ expect(() => appNotifications({ ...base, EMAIL_TRANSPORT: "smtp", SMS_TRANSPORT: "log" }, config)).toThrow(/EMAIL_TRANSPORT/);
204
+ });
205
+
206
+ it("the dev outbox route answers on a local host only", async () => {
207
+ const response = await exports.default.fetch(new Request(`${ORIGIN}/api/dev/outbox`));
208
+ expect(response.status).toBe(404);
209
+ });
210
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * An independent RFC 6238 TOTP generator (SHA-1, 6 digits, 30 s), playing the
3
+ * part of the user's authenticator app in tests. Runs on Web Crypto, so it
4
+ * works in workerd (Vitest) and in Node (Playwright).
5
+ */
6
+
7
+ const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
8
+
9
+ function base32Decode(input: string): Uint8Array<ArrayBuffer> {
10
+ const clean = input.replace(/=+$/, "").toUpperCase();
11
+ const bytes: number[] = [];
12
+ let bits = 0;
13
+ let value = 0;
14
+ for (const char of clean) {
15
+ const index = BASE32.indexOf(char);
16
+ if (index === -1) throw new Error(`Not base32: ${char}`);
17
+ value = (value << 5) | index;
18
+ bits += 5;
19
+ if (bits >= 8) {
20
+ bytes.push((value >>> (bits - 8)) & 0xff);
21
+ bits -= 8;
22
+ }
23
+ }
24
+ return new Uint8Array(bytes);
25
+ }
26
+
27
+ /** The base32 secret of an `otpauth://totp/...` URI (what the QR code encodes). */
28
+ export function secretOf(totpURI: string): string {
29
+ const secret = new URL(totpURI).searchParams.get("secret");
30
+ if (secret === null) throw new Error(`No secret in ${totpURI}`);
31
+ return secret;
32
+ }
33
+
34
+ /** The 6-digit code an authenticator app shows for `secret` at `time`. */
35
+ export async function totpCode(secret: string, time: number = Date.now()): Promise<string> {
36
+ const key = await crypto.subtle.importKey("raw", base32Decode(secret), { name: "HMAC", hash: "SHA-1" }, false, [
37
+ "sign",
38
+ ]);
39
+ const counter = new DataView(new ArrayBuffer(8));
40
+ counter.setBigUint64(0, BigInt(Math.floor(time / 30_000)), false);
41
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, counter.buffer));
42
+ const offset = (mac[mac.length - 1] ?? 0) & 0x0f;
43
+ const binary =
44
+ (((mac[offset] ?? 0) & 0x7f) << 24) |
45
+ ((mac[offset + 1] ?? 0) << 16) |
46
+ ((mac[offset + 2] ?? 0) << 8) |
47
+ (mac[offset + 3] ?? 0);
48
+ return String(binary % 1_000_000).padStart(6, "0");
49
+ }
@@ -0,0 +1,42 @@
1
+ import { vi } from "vitest";
2
+
3
+ // Cloudflare Turnstile's documented testing keys
4
+ // (https://developers.cloudflare.com/turnstile/troubleshooting/testing/).
5
+ // Better Auth's captcha plugin calls siteverify with the global `fetch`; the
6
+ // fixtures replace it with this stand-in, so tests never reach the network.
7
+ export const TURNSTILE_TEST = {
8
+ /** Site key whose widget always passes (`TURNSTILE_SITE_KEY` in wrangler.jsonc). */
9
+ siteKey: "1x00000000000000000000AA",
10
+ /** Secret key that accepts any token (`TURNSTILE_SECRET_KEY` in vitest.config.ts). */
11
+ passSecret: "1x0000000000000000000000000000000AA",
12
+ /** The token the testing site key hands out. */
13
+ token: "XXXX.DUMMY.TOKEN.XXXX",
14
+ } as const;
15
+
16
+ const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
17
+
18
+ interface SiteverifyCall {
19
+ secret: string;
20
+ response: string;
21
+ remoteip?: string;
22
+ }
23
+
24
+ let calls: SiteverifyCall[] = [];
25
+
26
+ /** The siteverify requests since {@link fakeSiteverify}. */
27
+ export function siteverifyCalls(): readonly SiteverifyCall[] {
28
+ return calls;
29
+ }
30
+
31
+ /** Replaces the global `fetch` with a siteverify stand-in that passes only the testing pass secret. */
32
+ export function fakeSiteverify(): void {
33
+ calls = [];
34
+ vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => {
35
+ const url = input instanceof Request ? input.url : String(input);
36
+ if (url !== SITEVERIFY_URL) return Promise.reject(new Error(`Unexpected network call in a test: ${url}`));
37
+ const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}") as SiteverifyCall;
38
+ calls.push(body);
39
+ const success = body.response !== "" && body.secret === TURNSTILE_TEST.passSecret;
40
+ return Promise.resolve(Response.json({ success, "error-codes": success ? [] : ["invalid-input-response"] }));
41
+ });
42
+ }