@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,24 @@
1
+ import { createRoute, z } from "@hono/zod-openapi";
2
+ import { createRouter } from "@awsmbiz/inbis-api";
3
+ // inbis:wire v1.imports
4
+
5
+ const HelloQuery = z.object({ name: z.string().min(1).max(100) });
6
+ const HelloSchema = z.object({ message: z.string() }).openapi("Hello");
7
+
8
+ const hello = createRoute({
9
+ method: "get",
10
+ path: "/hello",
11
+ tags: ["app"],
12
+ summary: "Greet someone (an example route: replace it with your own)",
13
+ request: { query: HelloQuery },
14
+ responses: {
15
+ 200: { description: "The greeting", content: { "application/json": { schema: HelloSchema } } },
16
+ },
17
+ });
18
+
19
+ /** Version 1 API routes, mounted under `/v1`. Each route is documented in `/openapi.json`. */
20
+ export function createV1() {
21
+ const v1 = createRouter<Env>();
22
+ // inbis:wire v1.routes
23
+ return v1.openapi(hello, (c) => c.json({ message: `Hello, ${c.req.valid("query").name}` }, 200));
24
+ }
@@ -0,0 +1,21 @@
1
+ # The platform's app CI: install, typecheck, lint, unit + Workers
2
+ # integration tests, build, Playwright + axe. `ci-v1` moves with compatible changes only.
3
+ name: CI
4
+
5
+ on:
6
+ pull_request:
7
+ push:
8
+ branches: [main]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ concurrency:
14
+ group: ci-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ ci:
19
+ permissions:
20
+ contents: read
21
+ uses: awsmbiz/inbis-platform/.github/workflows/app-ci.yml@ci-v1
@@ -0,0 +1,32 @@
1
+ # The platform's deploy pipeline. A push to main deploys staging. A v* tag whose commit is on
2
+ # main, pushed by someone in production-actors, deploys staging and then production (after
3
+ # a D1 Time Travel bookmark). Each run: secrets check, migrations (platform sources, then
4
+ # the app), deploy, smoke test. `ci-v1` moves with compatible changes only.
5
+ name: Deploy
6
+
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ tags: ["v*"]
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ deploy:
17
+ permissions:
18
+ contents: read
19
+ uses: awsmbiz/inbis-platform/.github/workflows/app-deploy.yml@ci-v1
20
+ with:
21
+ # GitHub logins allowed to release to production by pushing a v* tag. Keep it short.
22
+ production-actors: ""
23
+ staging-url: https://staging.react-hono-app.example.com
24
+ production-url: https://react-hono-app.example.com
25
+ secrets:
26
+ CLOUDFLARE_API_TOKEN_STAGING: ${{ secrets.CLOUDFLARE_API_TOKEN_STAGING }}
27
+ CLOUDFLARE_API_TOKEN_PRODUCTION: ${{ secrets.CLOUDFLARE_API_TOKEN_PRODUCTION }}
28
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
29
+ SMOKE_EMAIL_STAGING: ${{ secrets.SMOKE_EMAIL_STAGING }}
30
+ SMOKE_PASSWORD_STAGING: ${{ secrets.SMOKE_PASSWORD_STAGING }}
31
+ SMOKE_EMAIL_PRODUCTION: ${{ secrets.SMOKE_EMAIL_PRODUCTION }}
32
+ SMOKE_PASSWORD_PRODUCTION: ${{ secrets.SMOKE_PASSWORD_PRODUCTION }}
@@ -0,0 +1 @@
1
+ 24
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ web/dist/
4
+ .wrangler/
5
+ .dev.vars
6
+ *.log
7
+ coverage/
8
+ test-results/
9
+ playwright-report/
@@ -0,0 +1,11 @@
1
+ # pnpm settings for this app (a single-package repository).
2
+
3
+ # Dependencies allowed to run install scripts (pnpm blocks all others).
4
+ allowBuilds:
5
+ esbuild: true
6
+ workerd: true
7
+
8
+ # pnpm holds back releases younger than a day by default. Platform releases
9
+ # (exact pins from npm) are installed as soon as they are published.
10
+ minimumReleaseAgeExclude:
11
+ - "@awsmbiz/*"
@@ -0,0 +1,56 @@
1
+ {
2
+ "$comment": "The template's base manifest (docs/template-modules.md). Not copied into apps.",
3
+ "exclude": [
4
+ "template.json",
5
+ "modules",
6
+ "standalone",
7
+ "node_modules",
8
+ "dist",
9
+ "web/dist",
10
+ ".wrangler",
11
+ ".turbo",
12
+ ".dev.vars",
13
+ "test-results",
14
+ "playwright-report"
15
+ ],
16
+ "standalone": "standalone",
17
+ "bindings": {
18
+ "DB": "the app's own D1 database (migrations/)"
19
+ },
20
+ "wiring": {
21
+ "worker.imports": { "file": "src/app.ts", "description": "Imports for the Worker composition." },
22
+ "worker.deps": { "file": "src/app.ts", "description": "Fields of AppDeps: what src/index.ts injects (senders, configs)." },
23
+ "worker.routes": { "file": "src/app.ts", "description": "Inside createApp, after createApi: middleware and mount* calls on `app`." },
24
+ "index.imports": { "file": "src/index.ts", "description": "Imports for the Worker entry." },
25
+ "index.setup": { "file": "src/index.ts", "description": "Module-load setup from `env` (config validation; fails startup)." },
26
+ "index.deps": { "file": "src/index.ts", "description": "Properties of the AppDeps object built from `env`." },
27
+ "index.handlers": { "file": "src/index.ts", "description": "Extra Worker handlers (e.g. `scheduled`)." },
28
+ "v1.imports": { "file": "src/v1.ts", "description": "Imports for the /v1 router." },
29
+ "v1.routes": { "file": "src/v1.ts", "description": "Statements on the `v1` router before the base routes (middleware, `v1.route(...)`)." },
30
+ "audit.imports": { "file": "src/audit-sink.ts", "description": "Imports for the audit sink hook (the audit log)." },
31
+ "audit.catalogues.imports": { "file": "src/audit-catalogues.ts", "description": "Imports of platform packages' audit action catalogues." },
32
+ "audit.catalogues": { "file": "src/audit-catalogues.ts", "description": "Platform audit action catalogues the app's audit trail registers (e.g. AUTH_AUDIT_ACTIONS), comma-terminated." },
33
+ "audit.sink": { "file": "src/audit-sink.ts", "description": "The audit sink factory `(env, options) => AuditSink` platform packages record to (the audit module's). At most one." },
34
+ "env.bindings": { "file": "src/env.d.ts", "description": "Env members: bindings, vars and secrets the module adds to wrangler.jsonc." },
35
+ "wrangler.vars": { "file": "wrangler.jsonc", "description": "Vars, per env (dev, staging, production). Entries end with a comma." },
36
+ "wrangler.secrets": { "file": "wrangler.jsonc", "description": "Required secret names, per env. Entries end with a comma." },
37
+ "wrangler.bindings": { "file": "wrangler.jsonc", "description": "Top-level keys of an env (services, triggers, kv_namespaces...), per env. Entries end with a comma." },
38
+ "wrangler.d1": { "file": "wrangler.jsonc", "description": "D1 bindings to the app's database, per env, before DB (platform sources apply first, in module order). Args: env, database_name, database_id." },
39
+ "devvars": { "file": ".dev.vars.example", "description": "Local secrets for .dev.vars, with a comment each." },
40
+ "test.env": { "file": "test/env.d.ts", "description": "Test-only Env members (migration lists read by vitest.config.ts)." },
41
+ "test.migrate": { "file": "test/migrate.ts", "description": "applyD1Migrations calls for platform sources, before the app's own." },
42
+ "vitest.bindings": { "file": "vitest.config.ts", "description": "Miniflare bindings for tests (migration lists, test-only secrets)." },
43
+ "playwright.imports": { "file": "playwright.config.ts", "description": "Imports for the Playwright config (values the setup commands, vars and servers use)." },
44
+ "playwright.setup": { "file": "playwright.config.ts", "description": "Shell commands (strings, comma-terminated) run after migrations, before wrangler dev (e.g. seeding)." },
45
+ "playwright.vars": { "file": "playwright.config.ts", "description": "`--var NAME:value` strings (comma-terminated) for the e2e wrangler dev." },
46
+ "playwright.servers": { "file": "playwright.config.ts", "description": "Extra webServer entries (comma-terminated objects), started after the main one (the SPA is built). PORT, BASE_URL and STATE are in scope; take ports after PORT." },
47
+ "web.routes.imports": { "file": "web/src/routes.tsx", "description": "Imports for page components." },
48
+ "web.routes": { "file": "web/src/routes.tsx", "description": "AppRoute entries (path, title message key, nav, bare, render). Order = nav order." },
49
+ "web.layout.imports": { "file": "web/src/layout.tsx", "description": "Imports for the header slot." },
50
+ "web.header": { "file": "web/src/layout.tsx", "description": "JSX at the end of the header (e.g. an account menu); receives `path` and `navigate`." },
51
+ "web.messages": { "file": "web/src/messages.ts", "description": "Message keys and English strings for the module's own screens." },
52
+ "web.styles": { "file": "web/src/styles.css", "description": "@import lines for UI packages' stylesheets." },
53
+ "readme.modules": { "file": "README.md", "description": "One section per module: what it adds and how to use it." },
54
+ "readme.provisioning": { "file": "README.md", "description": "Extra provisioning steps (secrets to set, services to bind), inside step 3 of the provisioning list: indent the snippet 3 spaces." }
55
+ }
56
+ }
@@ -0,0 +1,11 @@
1
+ import type { D1Migration } from "cloudflare:test";
2
+
3
+ declare global {
4
+ namespace Cloudflare {
5
+ interface Env {
6
+ /** Test-only: migration files read in Node by vitest.config.ts. */
7
+ // inbis:wire test.env
8
+ APP_MIGRATIONS: D1Migration[];
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ import { applyD1Migrations } from "cloudflare:test";
2
+ import { env } from "cloudflare:workers";
3
+
4
+ /**
5
+ * Applies every migration source in the same order as `pnpm db:migrate`
6
+ * (wrangler.jsonc declaration order): platform sources first, the app's own last.
7
+ */
8
+ export async function migrate(): Promise<void> {
9
+ // inbis:wire test.migrate
10
+ await applyD1Migrations(env.DB, env.APP_MIGRATIONS, "d1_migrations");
11
+ }
@@ -0,0 +1,22 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { describe, expect, it } from "vitest";
3
+ import { migrate } from "./migrate.js";
4
+
5
+ // The app's own migrations (migrations/): 0001 first, then any the app or a module adds.
6
+ const appMigrations = () => env.APP_MIGRATIONS.map((migration) => migration.name);
7
+
8
+ describe("D1 migrations", () => {
9
+ it("apply in order and record the app's history in d1_migrations", async () => {
10
+ await migrate();
11
+ const { results } = await env.DB.prepare("SELECT name FROM d1_migrations ORDER BY id").all<{ name: string }>();
12
+ expect(results[0]?.name).toBe("0001_app_initial.sql");
13
+ expect(results.map((row) => row.name)).toEqual(appMigrations());
14
+ });
15
+
16
+ it("are idempotent", async () => {
17
+ await migrate();
18
+ await migrate();
19
+ const count = await env.DB.prepare("SELECT count(*) AS n FROM d1_migrations").first<number>("n");
20
+ expect(count).toBe(appMigrations().length);
21
+ });
22
+ });
@@ -0,0 +1,65 @@
1
+ import { exports } from "cloudflare:workers";
2
+ import { describe, expect, it } from "vitest";
3
+ import { migrate } from "./migrate.js";
4
+
5
+ const ORIGIN = "https://app.example.test";
6
+ const TRUSTED = "http://localhost:5173"; // TRUSTED_ORIGINS in wrangler.jsonc
7
+
8
+ function call(path: string, init?: RequestInit) {
9
+ return exports.default.fetch(new Request(`${ORIGIN}${path}`, init));
10
+ }
11
+
12
+ interface ErrorBody {
13
+ error: { code: string; message: string; requestId: string; details?: unknown };
14
+ }
15
+
16
+ describe("the app Worker over HTTP", () => {
17
+ it("GET /health reports liveness", async () => {
18
+ const response = await call("/health");
19
+ expect(response.status).toBe(200);
20
+ expect(await response.json()).toEqual({ status: "ok" });
21
+ expect(response.headers.get("x-request-id")).toBeTruthy();
22
+ });
23
+
24
+ it("GET /health/ready checks the D1 database", async () => {
25
+ await migrate();
26
+ const response = await call("/health/ready");
27
+ expect(response.status).toBe(200);
28
+ expect(await response.json()).toEqual({ status: "ok", checks: { database: "ok" } });
29
+ });
30
+
31
+ it("GET /openapi.json documents health and the /v1 routes", async () => {
32
+ const response = await call("/openapi.json");
33
+ const doc = await response.json<{ openapi: string; info: { title: string }; paths: object }>();
34
+ expect(response.status).toBe(200);
35
+ expect(doc.openapi).toBe("3.1.0");
36
+ expect(doc.info.title).toBe("React Hono App");
37
+ expect(Object.keys(doc.paths)).toEqual(expect.arrayContaining(["/health", "/health/ready", "/v1/hello"]));
38
+ });
39
+
40
+ it("GET /v1/hello greets by name", async () => {
41
+ const response = await call("/v1/hello?name=Ada");
42
+ expect(response.status).toBe(200);
43
+ expect(await response.json()).toEqual({ message: "Hello, Ada" });
44
+ });
45
+
46
+ it("rejects invalid input with 422 VALIDATION_FAILED", async () => {
47
+ const response = await call("/v1/hello?name=");
48
+ const body = await response.json<ErrorBody>();
49
+ expect(response.status).toBe(422);
50
+ expect(body.error.code).toBe("VALIDATION_FAILED");
51
+ });
52
+
53
+ it("renders unknown API routes as 404 NOT_FOUND", async () => {
54
+ const response = await call("/v1/does-not-exist");
55
+ expect(response.status).toBe(404);
56
+ expect((await response.json<ErrorBody>()).error.code).toBe("NOT_FOUND");
57
+ });
58
+
59
+ it("allows credentialed CORS for trusted origins only", async () => {
60
+ const trusted = await call("/health", { headers: { origin: TRUSTED } });
61
+ expect(trusted.headers.get("access-control-allow-origin")).toBe(TRUSTED);
62
+ const untrusted = await call("/health", { headers: { origin: "https://evil.example" } });
63
+ expect(untrusted.headers.get("access-control-allow-origin")).toBeNull();
64
+ });
65
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "@awsmbiz/inbis-tsconfig/workers.json",
3
+ "compilerOptions": {
4
+ "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"]
5
+ },
6
+ "include": ["src", "test", "*.ts"]
7
+ }
@@ -0,0 +1,24 @@
1
+ import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ // Runs the Worker exactly as `wrangler dev` would (same wrangler.jsonc, bindings and
5
+ // compatibility flags); tests call it over HTTP. Migration files are read here, in Node,
6
+ // and handed to tests, which apply them (test/migrate.ts).
7
+ export default defineConfig(async () => ({
8
+ plugins: [
9
+ cloudflareTest({
10
+ wrangler: { configPath: "./wrangler.jsonc" },
11
+ miniflare: {
12
+ bindings: {
13
+ // inbis:wire vitest.bindings
14
+ APP_MIGRATIONS: await readD1Migrations("./migrations"),
15
+ },
16
+ },
17
+ }),
18
+ ],
19
+ test: {
20
+ // e2e/ holds the Playwright suite (`pnpm test:e2e`).
21
+ include: ["test/**/*.test.ts"],
22
+ testTimeout: 30_000,
23
+ },
24
+ }));
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>React Hono App</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,44 @@
1
+ import { useEffect } from "react";
2
+ import { Layout } from "./layout.js";
3
+ import { t } from "./messages.js";
4
+ import { Link, useLocation, type Navigate } from "./navigation.js";
5
+ import { useRouteFocus } from "./route-focus.js";
6
+ import { matchRoute } from "./routes.js";
7
+
8
+ function NotFound({ navigate }: { navigate: Navigate }) {
9
+ return (
10
+ <div className="flex flex-col gap-2">
11
+ <h1 className="text-2xl font-semibold tracking-tight">{t("notFound.title")}</h1>
12
+ <p className="text-muted-foreground">{t("notFound.description")}</p>
13
+ <p>
14
+ <Link href="/" navigate={navigate} className="font-medium text-primary underline underline-offset-4">
15
+ {t("notFound.home")}
16
+ </Link>
17
+ </p>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ export function App() {
23
+ const { path, search, navigate } = useLocation();
24
+ const route = matchRoute(path);
25
+ useRouteFocus(path);
26
+ useEffect(() => {
27
+ document.title = `${t(route?.title ?? "notFound.title")} · ${t("app.name")}`;
28
+ }, [route]);
29
+
30
+ if (route === undefined) {
31
+ return (
32
+ <Layout path={path} navigate={navigate}>
33
+ <NotFound navigate={navigate} />
34
+ </Layout>
35
+ );
36
+ }
37
+ const page = route.render({ path, search, navigate });
38
+ if (route.bare === true) return page;
39
+ return (
40
+ <Layout path={path} navigate={navigate}>
41
+ {page}
42
+ </Layout>
43
+ );
44
+ }
@@ -0,0 +1,51 @@
1
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@awsmbiz/inbis-ui";
2
+ import { useEffect, useState } from "react";
3
+ import { t } from "./messages.js";
4
+
5
+ type ApiState = "checking" | "ready" | "unavailable";
6
+
7
+ /** Asks `GET /health/ready` whether the Worker and its dependencies are ready. */
8
+ function useApiState(): ApiState {
9
+ const [state, setState] = useState<ApiState>("checking");
10
+ useEffect(() => {
11
+ let active = true;
12
+ fetch("/health/ready")
13
+ .then((response) => {
14
+ if (active) setState(response.ok ? "ready" : "unavailable");
15
+ })
16
+ .catch(() => {
17
+ if (active) setState("unavailable");
18
+ });
19
+ return () => {
20
+ active = false;
21
+ };
22
+ }, []);
23
+ return state;
24
+ }
25
+
26
+ const STATUS = { checking: "home.apiChecking", ready: "home.apiReady", unavailable: "home.apiUnavailable" } as const;
27
+
28
+ export function Home() {
29
+ const api = useApiState();
30
+ return (
31
+ <div className="flex flex-col gap-6">
32
+ <div className="flex flex-col gap-2">
33
+ <h1 className="text-2xl font-semibold tracking-tight">{t("home.heading")}</h1>
34
+ <p className="text-muted-foreground">{t("home.intro")}</p>
35
+ </div>
36
+ <Card>
37
+ <CardHeader>
38
+ <CardTitle>
39
+ <h2>{t("home.apiStatus")}</h2>
40
+ </CardTitle>
41
+ <CardDescription role="status">{t(STATUS[api])}</CardDescription>
42
+ </CardHeader>
43
+ <CardContent>
44
+ <a className="text-sm font-medium text-primary underline underline-offset-4" href="/openapi.json">
45
+ {t("home.apiDocs")}
46
+ </a>
47
+ </CardContent>
48
+ </Card>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,48 @@
1
+ import type { ReactNode } from "react";
2
+ import { t } from "./messages.js";
3
+ import { Link, type Navigate } from "./navigation.js";
4
+ import { routes } from "./routes.js";
5
+ // inbis:wire web.layout.imports
6
+
7
+ /** The app shell: skip link, header with the main navigation, and the page in <main>. */
8
+ export function Layout({ path, navigate, children }: { path: string; navigate: Navigate; children: ReactNode }) {
9
+ return (
10
+ <div className="flex min-h-svh flex-col bg-background text-foreground">
11
+ <a
12
+ href="#main"
13
+ className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:ring-2 focus:ring-ring"
14
+ >
15
+ {t("app.skipToContent")}
16
+ </a>
17
+ <header className="border-b">
18
+ <div className="mx-auto flex max-w-4xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">
19
+ <Link href="/" navigate={navigate} className="font-semibold">
20
+ {t("app.name")}
21
+ </Link>
22
+ <nav aria-label={t("app.navLabel")} className="flex-1">
23
+ <ul className="flex flex-wrap gap-4 text-sm">
24
+ {routes
25
+ .filter((route) => route.nav === true)
26
+ .map((route) => (
27
+ <li key={route.path}>
28
+ <Link
29
+ href={route.path}
30
+ navigate={navigate}
31
+ aria-current={route.path === path ? "page" : undefined}
32
+ className="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline aria-[current=page]:font-medium aria-[current=page]:text-foreground"
33
+ >
34
+ {t(route.title)}
35
+ </Link>
36
+ </li>
37
+ ))}
38
+ </ul>
39
+ </nav>
40
+ {/* inbis:wire web.header */}
41
+ </div>
42
+ </header>
43
+ <main id="main" className="mx-auto w-full max-w-4xl flex-1 px-4 py-8">
44
+ {children}
45
+ </main>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,16 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./app.js";
4
+ import "./styles.css";
5
+ import { followSystemTheme } from "./system-theme.js";
6
+
7
+ followSystemTheme();
8
+
9
+ const root = document.getElementById("root");
10
+ if (root === null) throw new Error("Missing #root element.");
11
+
12
+ createRoot(root).render(
13
+ <StrictMode>
14
+ <App />
15
+ </StrictMode>,
16
+ );
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Every user-facing string of the app's own screens, by message key. Platform UI
3
+ * packages bring their own. English only for now; keys keep a later translation a
4
+ * data change.
5
+ */
6
+ export const messages = {
7
+ "app.name": "React Hono App",
8
+ "app.skipToContent": "Skip to content",
9
+ "app.navLabel": "Main",
10
+ "common.loading": "Loading…",
11
+
12
+ "home.title": "Home",
13
+ "home.heading": "Welcome to React Hono App",
14
+ "home.intro": "Your Hono API and React app run on one Cloudflare Worker. Edit web/src/home.tsx to change this page.",
15
+ "home.apiStatus": "API status",
16
+ "home.apiChecking": "Checking the API…",
17
+ "home.apiReady": "The API and database are ready.",
18
+ "home.apiUnavailable": "The API is not ready. Check the Worker logs.",
19
+ "home.apiDocs": "API description (OpenAPI)",
20
+
21
+ "notFound.title": "Page not found",
22
+ "notFound.description": "There is no page at this address.",
23
+ "notFound.home": "Go to the home page",
24
+
25
+ // inbis:wire web.messages
26
+ } as const;
27
+
28
+ export type MessageKey = keyof typeof messages;
29
+
30
+ /** Whether `key` names a message: for keys built from data. */
31
+ export function isMessageKey(key: string): key is MessageKey {
32
+ return Object.hasOwn(messages, key);
33
+ }
34
+
35
+ /** Looks up a message and fills `{name}` placeholders. */
36
+ export function t(key: MessageKey, values: Record<string, string> = {}): string {
37
+ return messages[key].replace(/\{(\w+)\}/g, (match, name: string) => values[name] ?? match);
38
+ }
@@ -0,0 +1,34 @@
1
+ import { useCallback, useEffect, useState, type AnchorHTMLAttributes, type MouseEvent } from "react";
2
+
3
+ export type Navigate = (path: string, options?: { replace?: boolean }) => void;
4
+
5
+ /** The current path and query string, and a function to move to another path (History API). */
6
+ export function useLocation(): { path: string; search: string; navigate: Navigate } {
7
+ const [location, setLocation] = useState({ path: window.location.pathname, search: window.location.search });
8
+ useEffect(() => {
9
+ const onPop = () => {
10
+ setLocation({ path: window.location.pathname, search: window.location.search });
11
+ };
12
+ window.addEventListener("popstate", onPop);
13
+ return () => {
14
+ window.removeEventListener("popstate", onPop);
15
+ };
16
+ }, []);
17
+ const navigate = useCallback<Navigate>((next, options = {}) => {
18
+ if (options.replace === true) window.history.replaceState(null, "", next);
19
+ else window.history.pushState(null, "", next);
20
+ setLocation({ path: window.location.pathname, search: window.location.search });
21
+ }, []);
22
+ return { ...location, navigate };
23
+ }
24
+
25
+ /** A link that navigates inside the app (modified clicks still open a new tab). */
26
+ export function Link({ href, navigate, onClick, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; navigate: Navigate }) {
27
+ const follow = (event: MouseEvent<HTMLAnchorElement>) => {
28
+ onClick?.(event);
29
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
30
+ event.preventDefault();
31
+ navigate(href);
32
+ };
33
+ return <a href={href} onClick={follow} {...props} />;
34
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ // After a client-side route change the link that was followed is usually gone and focus
4
+ // falls back to <body>: keyboard users would start over from the top and screen readers
5
+ // would not announce the new page. So focus moves to the new page's <h1> (WCAG 2.4.3).
6
+ // Pages may render asynchronously, so this watches the document for a short while and
7
+ // focuses the heading once it is there. Focus a page gave itself (an autofocused field)
8
+ // is left alone, and so is everything once the user acts.
9
+
10
+ const WATCH_MS = 10_000;
11
+
12
+ function focusIsLost(): boolean {
13
+ const active = document.activeElement;
14
+ return active === null || active === document.body || !active.isConnected;
15
+ }
16
+
17
+ function focusHeading(): void {
18
+ const heading = document.querySelector("main h1") ?? document.querySelector("h1");
19
+ if (!(heading instanceof HTMLElement) || heading.closest("[hidden]") !== null) return;
20
+ if (!heading.hasAttribute("tabindex")) heading.setAttribute("tabindex", "-1");
21
+ heading.focus();
22
+ }
23
+
24
+ export function useRouteFocus(path: string): void {
25
+ const previous = useRef(path);
26
+
27
+ useEffect(() => {
28
+ const from = previous.current;
29
+ previous.current = path;
30
+ if (from === path) return;
31
+
32
+ const check = () => {
33
+ if (focusIsLost()) focusHeading();
34
+ };
35
+ const observer = new MutationObserver(check);
36
+ const stop = () => {
37
+ observer.disconnect();
38
+ clearTimeout(timer);
39
+ window.removeEventListener("keydown", stop, true);
40
+ window.removeEventListener("pointerdown", stop, true);
41
+ };
42
+ const timer = setTimeout(stop, WATCH_MS);
43
+ observer.observe(document.body, { childList: true, subtree: true });
44
+ window.addEventListener("keydown", stop, true);
45
+ window.addEventListener("pointerdown", stop, true);
46
+ check();
47
+ return stop;
48
+ }, [path]);
49
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ import { Home } from "./home.js";
3
+ import type { MessageKey } from "./messages.js";
4
+ import type { Navigate } from "./navigation.js";
5
+ // inbis:wire web.routes.imports
6
+
7
+ export interface RouteContext {
8
+ path: string;
9
+ search: string;
10
+ navigate: Navigate;
11
+ }
12
+
13
+ export interface AppRoute {
14
+ /** An exact path, or a prefix when it ends in `/*` (`/admin/*` matches `/admin` and below). */
15
+ path: string;
16
+ /** The page title (document title), also the label of its navigation link. */
17
+ title: MessageKey;
18
+ /** Listed in the main navigation. */
19
+ nav?: boolean;
20
+ /** Rendered without the app layout (a screen with its own shell, e.g. full-page sign-in or an admin area). */
21
+ bare?: boolean;
22
+ render: (context: RouteContext) => ReactNode;
23
+ }
24
+
25
+ /** The app's pages, in navigation order. The first route that matches the path wins. */
26
+ export const routes: AppRoute[] = [
27
+ { path: "/", title: "home.title", nav: true, render: () => <Home /> },
28
+ // inbis:wire web.routes
29
+ ];
30
+
31
+ /** The route for `path`, if any. */
32
+ export function matchRoute(path: string): AppRoute | undefined {
33
+ return routes.find((route) =>
34
+ route.path.endsWith("/*")
35
+ ? path === route.path.slice(0, -2) || path.startsWith(route.path.slice(0, -1))
36
+ : path === route.path,
37
+ );
38
+ }