@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,2 @@
1
+ // EXAMPLE CODE (example module, src/example-notes-admin.ts): the notes admin API. Copy or delete.
2
+ mountExampleNotesAdmin(app);
@@ -0,0 +1 @@
1
+ import { EXAMPLE_AUDIT_ACTIONS } from "./example-audit-actions.js";
@@ -0,0 +1,2 @@
1
+ // The notes example's actions (example module: example code, delete with it).
2
+ EXAMPLE_AUDIT_ACTIONS,
@@ -0,0 +1,4 @@
1
+ With the audit trail (the `audit` module), adding a note records `example.note.created` and
2
+ deleting one (admin area) `example.note.deleted`, with the note as the subject
3
+ (`example_note:<id>`) and the signed-in user, if any, as the actor. The actions are declared
4
+ in `src/example-audit-actions.ts` and registered in `src/audit-catalogues.ts`.
@@ -0,0 +1,16 @@
1
+ ## Example notes (example code)
2
+
3
+ A small business feature to copy for your own, or delete. Everything it adds is marked
4
+ `EXAMPLE CODE`:
5
+
6
+ - **Migration:** `migrations/0002_example_notes.sql` (the `example_note` table, in the app's
7
+ own migrations, applied after the platform's).
8
+ - **API:** `src/example-notes.ts` mounts `GET /v1/example/notes` (newest first) and
9
+ `POST /v1/example/notes` (`{ title, body? }`, validated: `422 VALIDATION_FAILED`), both in
10
+ `/openapi.json`. Its `policy` decides who may call them.
11
+ - **Screen:** `web/src/example-notes.tsx` at `/notes`, its strings under `notes.*` in
12
+ `web/src/messages.ts`; tests in `test/example-notes.test.ts` and `e2e/example-notes.spec.ts`.
13
+
14
+ To delete it: remove those files, the `mountExampleNotes` call in `src/app.ts`, the `/notes`
15
+ route in `web/src/routes.tsx` and the `notes.*` messages, then add a migration that drops
16
+ `example_note` if one was applied anywhere (never delete an applied migration).
@@ -0,0 +1,19 @@
1
+ // Notes (example module: example code, copy or delete).
2
+ "notes.title": "Notes",
3
+ "notes.description": "Short notes kept by this app. An example feature: copy it for your own, or delete it.",
4
+ "notes.add": "Add a note",
5
+ "notes.noteTitle": "Title",
6
+ "notes.noteBody": "Note",
7
+ "notes.save": "Add note",
8
+ "notes.saving": "Adding…",
9
+ "notes.saved": "Added “{title}”.",
10
+ "notes.saveFailed": "The note could not be added. Check the title and try again.",
11
+ "notes.saveForbidden": "You can't add notes. Ask an administrator for access.",
12
+ "notes.listHeading": "Latest notes",
13
+ "notes.loading": "Loading notes…",
14
+ "notes.empty": "No notes yet.",
15
+ "notes.failed": "Notes could not be loaded.",
16
+ "notes.failedHint": "Check your connection and try again.",
17
+ "notes.tryAgain": "Try again",
18
+ "notes.forbidden": "You can't see notes.",
19
+ "notes.forbiddenHint": "Sign in with an account that has access, or ask an administrator.",
@@ -0,0 +1 @@
1
+ import { ExampleNotes } from "./example-notes.js";
@@ -0,0 +1,2 @@
1
+ // EXAMPLE CODE (example module, web/src/example-notes.tsx): the notes screen. Copy or delete.
2
+ { path: "/notes", title: "notes.title", nav: true, render: () => <ExampleNotes /> },
@@ -0,0 +1 @@
1
+ import { mountExampleNotes } from "./example-notes.js";
@@ -0,0 +1,2 @@
1
+ // EXAMPLE CODE (example module, src/example-notes.ts): GET|POST /v1/example/notes. Copy or delete.
2
+ mountExampleNotes(app, deps);
@@ -0,0 +1,51 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { choicesWith, json, migrate, newUser, seedAdmin, seedUser, signIn, testApp, type TestApp } from "./auth-fixtures.js";
3
+
4
+ // EXAMPLE CODE (example module, with access-control): the notes routes need sign-in and the
5
+ // notes permissions. Delete it with the example.
6
+
7
+ const NOTES = "/v1/example/notes";
8
+
9
+ beforeEach(async () => {
10
+ await migrate();
11
+ });
12
+
13
+ async function signedInUser(app: TestApp): Promise<{ cookie: string; id: string }> {
14
+ const user = newUser();
15
+ await seedUser(user);
16
+ const cookie = await signIn(app, user);
17
+ const me = await app.call("/api/me", { headers: { cookie } });
18
+ return { cookie, id: (await me.json<{ user: { id: string } }>()).user.id };
19
+ }
20
+
21
+ describe("the example notes routes with access control", () => {
22
+ it("refuse a visitor who is not signed in", async () => {
23
+ const response = await testApp().call(NOTES);
24
+ expect(response.status).toBe(401);
25
+ expect(await response.json()).toMatchObject({ error: { code: "UNAUTHENTICATED" } });
26
+ });
27
+
28
+ it("refuse a signed-in user without the notes permissions", async () => {
29
+ const app = testApp(choicesWith("invite-only"));
30
+ const { cookie } = await signedInUser(app);
31
+ const read = await app.call(NOTES, { headers: { cookie } });
32
+ expect(read.status).toBe(403);
33
+ expect(await read.json()).toMatchObject({ error: { code: "FORBIDDEN", details: { permission: "example.notes.read" } } });
34
+ const write = await app.call(NOTES, json({ title: "Refused" }, { cookie }));
35
+ expect(write.status).toBe(403);
36
+ });
37
+
38
+ it("admit a user granted the notes-editor role, who is recorded as the author", async () => {
39
+ const app = testApp(choicesWith("invite-only"));
40
+ const user = await signedInUser(app);
41
+ const admin = await seedAdmin();
42
+ const granted = await app.call(`/api/admin/users/${user.id}/roles/notes-editor`, { ...json({}, { cookie: admin }), method: "PUT" });
43
+ expect(granted.status).toBe(200);
44
+
45
+ const added = await app.call(NOTES, json({ title: "Mine" }, { cookie: user.cookie }));
46
+ expect(added.status).toBe(201);
47
+ expect(await added.json()).toMatchObject({ title: "Mine", authorId: user.id });
48
+ const read = await app.call(NOTES, { headers: { cookie: user.cookie } });
49
+ expect(read.status).toBe(200);
50
+ });
51
+ });
@@ -0,0 +1,55 @@
1
+ import { expect, test, type BrowserContext } from "@playwright/test";
2
+ import { adminApi } from "./auth-helpers.js";
3
+ import { adminsCanSignIn } from "./auth-seeded.js";
4
+ import { expectNoPageScroll, expectNoViolations, THEMES, VIEWPORTS } from "./axe.js";
5
+
6
+ // EXAMPLE CODE (example module, with admin): the admin area's Notes page, as the seeded
7
+ // administrator (signed in once, through the API). Delete it with the example.
8
+
9
+ test.skip(!adminsCanSignIn, "administrators need email + password and 2FA (src/auth-choices.ts)");
10
+ test.describe.configure({ mode: "serial" });
11
+
12
+ let session: Awaited<ReturnType<BrowserContext["storageState"]>>;
13
+ const title = `Moderated ${String(Date.now())}`;
14
+
15
+ test.beforeAll(async ({ playwright, baseURL }) => {
16
+ const admin = await adminApi(playwright, baseURL);
17
+ // The note to delete. With access control, adding one needs the notes-editor role.
18
+ const { user } = (await (await admin.get("/api/me")).json()) as { user: { id: string } };
19
+ await admin.put(`/api/admin/users/${user.id}/roles/notes-editor`);
20
+ expect((await admin.post("/v1/example/notes", { data: { title } })).status()).toBe(201);
21
+ session = await admin.storageState();
22
+ await admin.dispose();
23
+ });
24
+
25
+ test.beforeEach(async ({ context }) => {
26
+ await context.addCookies(session.cookies);
27
+ });
28
+
29
+ test("an administrator deletes a note from the Notes page", async ({ page }) => {
30
+ await page.goto("/admin");
31
+ await page.getByRole("link", { name: "Notes" }).click();
32
+ await expect(page.getByRole("heading", { name: "Notes", level: 1 })).toBeVisible();
33
+ const notes = page.getByRole("list", { name: "Notes" });
34
+ await expect(notes).toContainText(title);
35
+ await page.getByRole("button", { name: `Delete “${title}”` }).click();
36
+ const dialog = page.getByRole("alertdialog");
37
+ await expect(dialog).toContainText(title);
38
+ await dialog.getByRole("button", { name: "Delete" }).click();
39
+ await expect(dialog).toBeHidden();
40
+ await expect(page.getByText(title)).toHaveCount(0);
41
+ });
42
+
43
+ for (const theme of THEMES) {
44
+ for (const [viewport, size] of Object.entries(VIEWPORTS)) {
45
+ test(`Admin notes (${theme}, ${viewport}) has no accessibility violations`, async ({ page }) => {
46
+ await page.emulateMedia({ colorScheme: theme });
47
+ await page.setViewportSize(size);
48
+ await page.goto("/admin/notes");
49
+ await expect(page.getByRole("heading", { name: "Notes", level: 1 })).toBeVisible();
50
+ await expect(page.getByText("Loading notes…")).toBeHidden();
51
+ await expectNoPageScroll(page);
52
+ await expectNoViolations(page);
53
+ });
54
+ }
55
+ }
@@ -0,0 +1,62 @@
1
+ import { createRoute, z, type OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { ApiEnv } from "@awsmbiz/inbis-api";
3
+ import { requireAdmin, requireRecentAdminSignIn, type AuthEnv } from "@awsmbiz/inbis-auth/hono";
4
+ import { AppError } from "@awsmbiz/inbis-core";
5
+ import { desc, eq } from "drizzle-orm";
6
+ import type { Context } from "hono";
7
+ import { EXAMPLE_AUDIT_ACTIONS } from "./example-audit-actions.js";
8
+ import { exampleNote, NoteSchema, notesDatabase, recordNoteEvent, toNote } from "./example-notes.js";
9
+
10
+ // EXAMPLE CODE (example module, with admin): the notes admin API behind the admin area's
11
+ // Notes page (web/src/admin-notes.tsx). Copy it for your own admin routes, or delete it.
12
+
13
+ const NoteListSchema = z.object({ items: z.array(NoteSchema) }).openapi("ExampleNoteAdminList");
14
+
15
+ /** The newest notes the admin page shows. A real feature would paginate. */
16
+ const LIST_LIMIT = 100;
17
+
18
+ const list = createRoute({
19
+ method: "get",
20
+ path: "/api/admin/example/notes",
21
+ tags: ["example"],
22
+ summary: "The newest example notes, for administrators (example code)",
23
+ middleware: [requireAdmin()],
24
+ responses: {
25
+ 200: { description: "Up to 100 notes, newest first", content: { "application/json": { schema: NoteListSchema } } },
26
+ },
27
+ });
28
+
29
+ const remove = createRoute({
30
+ method: "delete",
31
+ path: "/api/admin/example/notes/{noteId}",
32
+ tags: ["example"],
33
+ summary: "Delete an example note, for administrators (example code)",
34
+ middleware: [requireAdmin(), requireRecentAdminSignIn()],
35
+ request: { params: z.object({ noteId: z.string().min(1) }) },
36
+ responses: {
37
+ 204: { description: "Deleted" },
38
+ 404: { description: "No such note" },
39
+ },
40
+ });
41
+
42
+ /**
43
+ * Mounts the notes admin API, after the admin module's `mountAppAdmin` (auth's session
44
+ * middleware covers `/api/*`): `GET /api/admin/example/notes` (administrators) and
45
+ * `DELETE /api/admin/example/notes/:noteId` (also a recent sign-in; recorded in the audit
46
+ * trail when the app has one, as `example.note.deleted`).
47
+ */
48
+ export function mountExampleNotesAdmin(app: OpenAPIHono<ApiEnv<Env>>): void {
49
+ app.openapi(list, async (c) => {
50
+ const rows = await notesDatabase(c).select().from(exampleNote).orderBy(desc(exampleNote.createdAt), desc(exampleNote.id)).limit(LIST_LIMIT);
51
+ return c.json({ items: rows.map(toNote) }, 200);
52
+ });
53
+
54
+ app.openapi(remove, async (c) => {
55
+ const { noteId } = c.req.valid("param");
56
+ const [row] = await notesDatabase(c).delete(exampleNote).where(eq(exampleNote.id, noteId)).returning({ id: exampleNote.id });
57
+ if (row === undefined) throw new AppError({ code: "NOT_FOUND", status: 404, message: "There is no such note." });
58
+ const actorId = (c as unknown as Context<AuthEnv>).var.user?.id ?? null;
59
+ await recordNoteEvent(c, EXAMPLE_AUDIT_ACTIONS.noteDeleted, actorId, row.id);
60
+ return c.body(null, 204);
61
+ });
62
+ }
@@ -0,0 +1,42 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { choicesWith, json, migrate, newUser, seedAdmin, seedUser, signIn, testApp } from "./auth-fixtures.js";
3
+ import { notesCaller, postJson } from "./example-notes-fixtures.js";
4
+
5
+ // EXAMPLE CODE (example module, with admin): the notes admin API. Delete it with the example.
6
+
7
+ const ADMIN_NOTES = "/api/admin/example/notes";
8
+
9
+ beforeEach(async () => {
10
+ await migrate();
11
+ });
12
+
13
+ describe("the example notes admin API", () => {
14
+ it("lets an administrator list notes and delete one", async () => {
15
+ const call = await notesCaller();
16
+ const { id } = await (await call("/v1/example/notes", postJson({ title: "To moderate" }))).json<{ id: string }>();
17
+ const app = testApp(choicesWith("invite-only"));
18
+ const admin = await seedAdmin();
19
+
20
+ const listed = await app.call(ADMIN_NOTES, { headers: { cookie: admin } });
21
+ expect(listed.status).toBe(200);
22
+ const { items } = await listed.json<{ items: { id: string; title: string }[] }>();
23
+ expect(items.find((note) => note.id === id)).toMatchObject({ title: "To moderate" });
24
+
25
+ const deleted = await app.call(`${ADMIN_NOTES}/${id}`, { ...json({}, { cookie: admin }), method: "DELETE" });
26
+ expect(deleted.status).toBe(204);
27
+ const after = await (await app.call(ADMIN_NOTES, { headers: { cookie: admin } })).json<{ items: { id: string }[] }>();
28
+ expect(after.items.map((note) => note.id)).not.toContain(id);
29
+
30
+ const again = await app.call(`${ADMIN_NOTES}/${id}`, { ...json({}, { cookie: admin }), method: "DELETE" });
31
+ expect(again.status).toBe(404);
32
+ });
33
+
34
+ it("is for administrators only", async () => {
35
+ const app = testApp(choicesWith("invite-only"));
36
+ expect((await app.call(ADMIN_NOTES)).status).toBe(401);
37
+ const user = newUser();
38
+ await seedUser(user);
39
+ const cookie = await signIn(app, user);
40
+ expect((await app.call(ADMIN_NOTES, { headers: { cookie } })).status).toBe(403);
41
+ });
42
+ });
@@ -0,0 +1,99 @@
1
+ import { ConfirmDialog, EmptyState, ErrorState, LoadingState, PageHeader, useAdmin } from "@awsmbiz/inbis-admin-ui";
2
+ import { ApiError } from "@awsmbiz/inbis-api-client";
3
+ import { Button, Card, CardContent } from "@awsmbiz/inbis-ui";
4
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { NotebookPen } from "lucide-react";
6
+ import { useRef, useState } from "react";
7
+ import { getJson } from "./admin-client.js";
8
+ import { t } from "./messages.js";
9
+
10
+ // EXAMPLE CODE (example module, with admin): the admin area's Notes page, on the notes admin
11
+ // API (src/example-notes-admin.ts). Copy it for an admin page of your own, or delete it.
12
+
13
+ export { NotebookPen as NotesIcon };
14
+
15
+ interface Note {
16
+ id: string;
17
+ title: string;
18
+ body: string;
19
+ createdAt: string;
20
+ }
21
+
22
+ const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
23
+
24
+ async function deleteNote(id: string): Promise<void> {
25
+ let response: Response;
26
+ try {
27
+ response = await fetch(`/api/admin/example/notes/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "include" });
28
+ } catch (cause) {
29
+ throw ApiError.network(cause);
30
+ }
31
+ if (!response.ok) throw await ApiError.fromResponse(response);
32
+ }
33
+
34
+ /** Notes: the newest notes, each with a Delete action (confirmed first). */
35
+ export function NotesAdminPage() {
36
+ const { withStepUp } = useAdmin();
37
+ const queryClient = useQueryClient();
38
+ const [deleting, setDeleting] = useState<Note | null>(null);
39
+ const list = useRef<HTMLUListElement>(null);
40
+ const notes = useQuery({
41
+ queryKey: ["example", "notes"],
42
+ queryFn: ({ signal }) => withStepUp(() => getJson<{ items: Note[] }>("/api/admin/example/notes", { signal })),
43
+ });
44
+
45
+ return (
46
+ <>
47
+ <PageHeader title={t("admin.notes.title")} description={t("admin.notes.description")} />
48
+ {notes.isPending ? <LoadingState label={t("admin.notes.loading")} /> : null}
49
+ {notes.isError ? <ErrorState title={t("admin.notes.failed")} error={notes.error} onRetry={() => void notes.refetch()} /> : null}
50
+ {notes.isSuccess && notes.data.items.length === 0 ? <EmptyState title={t("admin.notes.empty")} /> : null}
51
+ {notes.isSuccess && notes.data.items.length > 0 ? (
52
+ <Card>
53
+ <CardContent>
54
+ <ul ref={list} tabIndex={-1} aria-label={t("admin.notes.listLabel")} className="flex flex-col divide-y outline-none">
55
+ {notes.data.items.map((note) => (
56
+ <li key={note.id} className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between">
57
+ <div className="flex min-w-0 flex-col gap-1">
58
+ <span className="font-medium break-words">{note.title}</span>
59
+ {note.body === "" ? null : <span className="text-sm break-words whitespace-pre-line">{note.body}</span>}
60
+ <time dateTime={note.createdAt} className="text-sm text-muted-foreground">
61
+ {dateFormat.format(new Date(note.createdAt))}
62
+ </time>
63
+ </div>
64
+ <Button
65
+ variant="outline"
66
+ size="sm"
67
+ className="self-start"
68
+ aria-label={t("admin.notes.deleteNote", { title: note.title })}
69
+ onClick={() => {
70
+ setDeleting(note);
71
+ }}
72
+ >
73
+ {t("admin.notes.delete")}
74
+ </Button>
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ </CardContent>
79
+ </Card>
80
+ ) : null}
81
+ <ConfirmDialog
82
+ open={deleting !== null}
83
+ onOpenChange={(open) => {
84
+ if (!open) setDeleting(null);
85
+ }}
86
+ title={t("admin.notes.confirmTitle")}
87
+ description={t("admin.notes.confirmDescription", { title: deleting?.title ?? "" })}
88
+ confirmLabel={t("admin.notes.delete")}
89
+ destructive
90
+ fallbackFocus={() => list.current}
91
+ onConfirm={async () => {
92
+ if (deleting === null) return;
93
+ await withStepUp(() => deleteNote(deleting.id));
94
+ await queryClient.invalidateQueries({ queryKey: ["example", "notes"] });
95
+ }}
96
+ />
97
+ </>
98
+ );
99
+ }
@@ -0,0 +1,22 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { beforeEach, expect, it } from "vitest";
3
+ import { auditLog } from "../src/audit-log.js";
4
+ import { notesCaller, postJson } from "./example-notes-fixtures.js";
5
+ import { migrate } from "./migrate.js";
6
+
7
+ // EXAMPLE CODE (example module, with audit): adding a note is recorded in the audit trail.
8
+
9
+ beforeEach(async () => {
10
+ await migrate();
11
+ });
12
+
13
+ it("records example.note.created with the note as the subject", async () => {
14
+ const call = await notesCaller();
15
+ const response = await call("/v1/example/notes", postJson({ title: "Audited" }));
16
+ expect(response.status).toBe(201);
17
+ const { id, authorId } = await response.json<{ id: string; authorId: string | null }>();
18
+
19
+ const { items } = await auditLog(env).list({ action: "example.note.created", subjectId: `example_note:${id}` });
20
+ expect(items).toHaveLength(1);
21
+ expect(items[0]).toMatchObject({ action: "example.note.created", actorId: authorId });
22
+ });
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "react-hono-app",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "An Inbis app: Hono API and React SPA on one Cloudflare Worker, with D1.",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24.0.0"
9
+ },
10
+ "scripts": {
11
+ "dev": "pnpm build:web && wrangler dev",
12
+ "dev:web": "vite --config web/vite.config.ts",
13
+ "build": "pnpm build:web && wrangler deploy --dry-run --outdir dist",
14
+ "build:web": "vite build --config web/vite.config.ts",
15
+ "typegen": "wrangler types --include-runtime=false --strict-vars=false .wrangler/types/env.d.ts",
16
+ "typecheck": "tsc && tsc -p web && tsc -p e2e",
17
+ "lint": "eslint . --max-warnings 0",
18
+ "test": "vitest run",
19
+ "test:e2e": "playwright test",
20
+ "db:migrate": "inbis-db migrate --local",
21
+ "db:status": "inbis-db status --local"
22
+ },
23
+ "dependencies": {
24
+ "@awsmbiz/inbis-api": "workspace:*",
25
+ "@awsmbiz/inbis-cloudflare": "workspace:*",
26
+ "@awsmbiz/inbis-core": "workspace:*",
27
+ "@awsmbiz/inbis-db": "workspace:*",
28
+ "@awsmbiz/inbis-ui": "workspace:*",
29
+ "@hono/zod-openapi": "1.6.3",
30
+ "drizzle-orm": "0.45.4",
31
+ "hono": "4.13.14",
32
+ "react": "19.3.1",
33
+ "react-dom": "19.3.1",
34
+ "react-hook-form": "7.91.0",
35
+ "zod": "4.6.7"
36
+ },
37
+ "devDependencies": {
38
+ "@awsmbiz/inbis-eslint-config": "workspace:*",
39
+ "@awsmbiz/inbis-tsconfig": "workspace:*",
40
+ "@axe-core/playwright": "4.14.0",
41
+ "@cloudflare/vitest-plugin": "1.4.11",
42
+ "@cloudflare/workers-types": "5.20261030.1",
43
+ "@playwright/test": "1.64.1",
44
+ "@tailwindcss/vite": "4.3.3",
45
+ "@types/node": "24.19.2",
46
+ "@types/react": "19.3.0",
47
+ "@types/react-dom": "19.3.0",
48
+ "@vitejs/plugin-react": "6.1.2",
49
+ "eslint": "10.14.0",
50
+ "tailwindcss": "4.3.3",
51
+ "typescript": "6.0.3",
52
+ "vite": "8.3.5",
53
+ "vitest": "5.0.5",
54
+ "wrangler": "4.156.0"
55
+ },
56
+ "packageManager": "pnpm@12.16.1"
57
+ }
@@ -0,0 +1,38 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+ // inbis:wire playwright.imports
3
+
4
+ // Browser seam: the Worker + SPA under `wrangler dev`, on a fresh local D1 (its own
5
+ // persist dir) with every migration applied. Port 8790 keeps clear of `pnpm dev` (8787);
6
+ // `E2E_PORT` moves it, and modules' extra servers take the ports after it.
7
+ const PORT = Number(process.env["E2E_PORT"] ?? 8790);
8
+ const BASE_URL = `http://localhost:${PORT}`;
9
+ const STATE = ".wrangler/e2e";
10
+
11
+ export default defineConfig({
12
+ testDir: "./e2e",
13
+ fullyParallel: false,
14
+ workers: 1,
15
+ forbidOnly: !!process.env["CI"],
16
+ retries: process.env["CI"] ? 1 : 0,
17
+ reporter: process.env["CI"] ? "github" : "list",
18
+ use: { baseURL: BASE_URL, trace: "retain-on-failure" },
19
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
20
+ webServer: [
21
+ {
22
+ command: [
23
+ "pnpm build:web",
24
+ `rm -rf ${STATE}`,
25
+ `inbis-db migrate --local --persist-to ${STATE}`,
26
+ // inbis:wire playwright.setup
27
+ [
28
+ `wrangler dev --port ${PORT} --persist-to ${STATE}`,
29
+ // inbis:wire playwright.vars
30
+ ].join(" "),
31
+ ].join(" && "),
32
+ url: `${BASE_URL}/health`,
33
+ reuseExistingServer: false,
34
+ timeout: 120_000,
35
+ },
36
+ // inbis:wire playwright.servers
37
+ ],
38
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": ["config:recommended"],
4
+ "packageRules": [
5
+ {
6
+ "description": "The Inbis platform is released in lockstep: upgrade every package together.",
7
+ "matchPackageNames": ["@awsmbiz/inbis-*"],
8
+ "groupName": "Inbis platform",
9
+ "groupSlug": "inbis-platform"
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,46 @@
1
+ import { createApi } from "@awsmbiz/inbis-api";
2
+ import { requireBinding } from "@awsmbiz/inbis-cloudflare";
3
+ import type { Logger } from "@awsmbiz/inbis-core";
4
+ import { createDatabase } from "@awsmbiz/inbis-db";
5
+ import { sql } from "drizzle-orm";
6
+ import { createV1 } from "./v1.js";
7
+ // inbis:wire worker.imports
8
+
9
+ /** Everything the app injects into the platform packages. Tests can swap these for fakes. */
10
+ export interface AppDeps {
11
+ /** Base logger; defaults to the console JSON logger (Workers Logs). */
12
+ logger?: Logger;
13
+ // inbis:wire worker.deps
14
+ }
15
+
16
+ /** `TRUSTED_ORIGINS` (comma-separated origins) as a list; throws on an entry that is not an origin. */
17
+ export function trustedOrigins(env: Pick<Env, "TRUSTED_ORIGINS">): string[] {
18
+ const origins = env.TRUSTED_ORIGINS.split(",")
19
+ .map((origin) => origin.trim())
20
+ .filter((origin) => origin !== "");
21
+ for (const origin of origins) {
22
+ if (!URL.canParse(origin) || new URL(origin).origin !== origin) {
23
+ throw new Error(`TRUSTED_ORIGINS: "${origin}" is not an origin (scheme://host[:port], no path).`);
24
+ }
25
+ }
26
+ return origins;
27
+ }
28
+
29
+ /** Builds the Worker app. `src/index.ts` calls it with the real adapters. */
30
+ export function createApp(deps: AppDeps = {}) {
31
+ const app = createApi<Env>({
32
+ info: { title: "React Hono App", version: "0.0.0" },
33
+ trustedOrigins,
34
+ readiness: {
35
+ database: async (c) => {
36
+ await createDatabase(requireBinding(c.env, "DB")).run(sql`select 1`);
37
+ },
38
+ },
39
+ v1: createV1(),
40
+ ...(deps.logger === undefined ? {} : { logger: deps.logger }),
41
+ });
42
+
43
+ // inbis:wire worker.routes
44
+
45
+ return app;
46
+ }
@@ -0,0 +1,9 @@
1
+ // inbis:wire audit.catalogues.imports
2
+
3
+ /**
4
+ * Audit action catalogues of the platform packages the app uses (e.g. auth's
5
+ * `AUTH_AUDIT_ACTIONS`): an audit trail, when the app has one, accepts these action names.
6
+ */
7
+ export const platformAuditActions: readonly (Readonly<Record<string, string>> | readonly string[])[] = [
8
+ // inbis:wire audit.catalogues
9
+ ];
@@ -0,0 +1,21 @@
1
+ import type { AuditSink, Logger } from "@awsmbiz/inbis-core";
2
+ // inbis:wire audit.imports
3
+
4
+ /**
5
+ * Where platform packages (and the app) record audit events. The app has no audit trail
6
+ * until it adds one (`@awsmbiz/inbis-audit`): until then `auditSinkFor` returns `undefined`
7
+ * and platform packages fall back to their own behaviour (auth keeps its interim history).
8
+ */
9
+ export interface AuditSinkOptions {
10
+ /** The request's logger, for warnings such as an unregistered action. */
11
+ logger?: Logger | undefined;
12
+ }
13
+
14
+ const sinks: ((env: Env, options: AuditSinkOptions) => AuditSink)[] = [
15
+ // inbis:wire audit.sink
16
+ ];
17
+
18
+ /** The app's audit sink for a request, or `undefined` when the app has no audit trail. */
19
+ export function auditSinkFor(env: Env, options: AuditSinkOptions = {}): AuditSink | undefined {
20
+ return sinks[0]?.(env, options);
21
+ }
@@ -0,0 +1,21 @@
1
+ // The Worker's bindings, vars and secrets, as declared in wrangler.jsonc. Keep the two in
2
+ // step: when you add a binding or var there, add it here. `pnpm typegen` writes wrangler's
3
+ // own view to .wrangler/types/env.d.ts if you want to compare.
4
+ declare namespace Cloudflare {
5
+ interface GlobalProps {
6
+ /** Types `exports` from `cloudflare:workers` (the Worker's own entrypoints). */
7
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- a global declaration file cannot import
8
+ mainModule: typeof import("./index");
9
+ }
10
+ interface Env {
11
+ // inbis:wire env.bindings
12
+ /** The app's own D1 binding (migrations/). */
13
+ DB: D1Database;
14
+ /** Comma-separated browser origins allowed to call the API with credentials. */
15
+ TRUSTED_ORIGINS: string;
16
+ }
17
+ }
18
+
19
+ // The global `Env` used in handler types, as `wrangler types` declares it.
20
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
21
+ interface Env extends Cloudflare.Env {}
@@ -0,0 +1,18 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { createApp, trustedOrigins, type AppDeps } from "./app.js";
3
+ // inbis:wire index.imports
4
+
5
+ // Fail at startup, not on the first request, when the configuration is invalid.
6
+ trustedOrigins(env);
7
+ // inbis:wire index.setup
8
+
9
+ // The real dependencies, built from the environment once per isolate.
10
+ const deps: AppDeps = {
11
+ // inbis:wire index.deps
12
+ };
13
+ const app = createApp(deps);
14
+
15
+ export default {
16
+ fetch: app.fetch,
17
+ // inbis:wire index.handlers
18
+ } satisfies ExportedHandler<Env>;