@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,37 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { choicesWith, json, migrate, newUser, seedAdmin, seedUser, testApp, type TestApp } from "./auth-fixtures.js";
3
+
4
+ // With the audit trail, the admin dashboard counts and the auth history read it (src/admin.ts).
5
+
6
+ beforeEach(async () => {
7
+ await migrate();
8
+ });
9
+
10
+ async function stats(app: TestApp, cookie: string) {
11
+ const response = await app.call("/api/admin/auth-stats", { headers: { cookie } });
12
+ expect(response.status).toBe(200);
13
+ return response.json<{ failedSignIns24h: number; activeUsers7d: number }>();
14
+ }
15
+
16
+ describe("the admin API over the audit trail", () => {
17
+ it("counts failed sign-ins and active users from the trail, and lists auth's events", async () => {
18
+ const app = testApp(choicesWith("invite-only"));
19
+ const cookie = await seedAdmin(newUser("Grace Admin"));
20
+ const before = await stats(app, cookie);
21
+
22
+ const user = newUser();
23
+ await seedUser(user);
24
+ const failed = await app.call("/api/auth/sign-in/email", json({ email: user.email, password: "not the password" }));
25
+ expect(failed.status).toBe(401);
26
+
27
+ const after = await stats(app, cookie);
28
+ expect(after.failedSignIns24h).toBe(before.failedSignIns24h + 1);
29
+ // The admin's own sign-in, recorded in the trail.
30
+ expect(after.activeUsers7d).toBeGreaterThanOrEqual(1);
31
+
32
+ const history = await app.call("/api/admin/auth-audit-events?limit=200", { headers: { cookie } });
33
+ expect(history.status).toBe(200);
34
+ const { items } = await history.json<{ items: { action: string }[] }>();
35
+ expect(items.map((item) => item.action)).toContain("auth.sign_in.failed");
36
+ });
37
+ });
@@ -0,0 +1,21 @@
1
+ import { AuthActivityPage, UserActivitySection, type AdminNavItem, type AdminPage } from "@awsmbiz/inbis-admin-ui";
2
+ import { ScrollText } from "lucide-react";
3
+ import { auth } from "./admin-client.js";
4
+ import { t } from "./messages.js";
5
+
6
+ // The admin area's activity, over the audit trail (audit module): GET /api/admin/audit-events.
7
+ // admin-area.tsx lists the page, its navigation item and the user section.
8
+
9
+ /** Every event, filterable by action, actor, subject and dates. */
10
+ export const activityPage: AdminPage = {
11
+ path: "activity",
12
+ title: t("admin.nav.activity"),
13
+ component: () => <AuthActivityPage client={auth} />,
14
+ };
15
+
16
+ export const activityNavItem: AdminNavItem = { to: "activity", label: t("admin.nav.activity"), icon: ScrollText };
17
+
18
+ /** A user's recent activity, on their page in the admin area. */
19
+ export function UserActivity({ user }: { user: { id: string } }) {
20
+ return <UserActivitySection client={auth} userId={user.id} />;
21
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The app's own audit actions: one entry per kind of event the app records, as
3
+ * `namespace.thing[.more]` in lowercase. Use the app's own namespaces: platform ones
4
+ * (`auth`, `access`, `audit`, `notifications`, ...) are refused at startup.
5
+ *
6
+ * ```ts
7
+ * await auditLog(c.env, { logger: c.var.logger }).record({
8
+ * action: APP_AUDIT_ACTIONS.settingsChanged,
9
+ * actorId: null, // the user ID when someone is signed in; null for the system
10
+ * subjectId: "settings:general",
11
+ * occurredAt: new Date(),
12
+ * requestId: c.var.requestId,
13
+ * metadata: { changed: ["timezone"] },
14
+ * });
15
+ * ```
16
+ */
17
+ export const APP_AUDIT_ACTIONS = {
18
+ /** An example action: replace it with your own. */
19
+ settingsChanged: "app.settings.changed",
20
+ } as const;
@@ -0,0 +1,33 @@
1
+ import { createAuditActions, createD1AuditLog, type AuditActions, type AuditLog } from "@awsmbiz/inbis-audit";
2
+ import { requireBinding } from "@awsmbiz/inbis-cloudflare";
3
+ import { APP_AUDIT_ACTIONS } from "./audit-actions.js";
4
+ import type { Logger } from "@awsmbiz/inbis-core";
5
+ import { platformAuditActions } from "./audit-catalogues.js";
6
+
7
+ let registry: AuditActions | undefined;
8
+
9
+ /**
10
+ * Every action the audit trail accepts: the platform packages' catalogues
11
+ * (`src/audit-sink.ts`) and the app's own (`src/audit-actions.ts`). Built once per
12
+ * isolate; throws on a malformed name or an app action in a platform namespace
13
+ * (`src/index.ts` calls it at startup, so that fails the deploy, not a request).
14
+ */
15
+ export function auditActions(): AuditActions {
16
+ registry ??= createAuditActions({ platform: platformAuditActions, app: APP_AUDIT_ACTIONS });
17
+ return registry;
18
+ }
19
+
20
+ /**
21
+ * The app's audit trail (`audit_event`, binding `AUDIT_DB`): record events with
22
+ * `auditLog(c.env, { logger: c.var.logger }).record({ action, actorId, subjectId, occurredAt, ... })`,
23
+ * read them with `list` and `count`. With `AUDIT_STRICT_ACTIONS` = `"true"` (local
24
+ * development and tests) an unregistered action throws; deployed, it is stored and
25
+ * logged as `audit.action.unregistered`.
26
+ */
27
+ export function auditLog(env: Env, { logger }: { logger?: Logger | undefined } = {}): AuditLog {
28
+ return createD1AuditLog(requireBinding(env, "AUDIT_DB"), {
29
+ actions: auditActions(),
30
+ strict: env.AUDIT_STRICT_ACTIONS === "true",
31
+ ...(logger === undefined ? {} : { logger }),
32
+ });
33
+ }
@@ -0,0 +1,47 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { beforeAll, describe, expect, it } from "vitest";
3
+ import { APP_AUDIT_ACTIONS } from "../src/audit-actions.js";
4
+ import { auditLog } from "../src/audit-log.js";
5
+ import { auditSinkFor } from "../src/audit-sink.js";
6
+ import { migrate } from "./migrate.js";
7
+
8
+ describe("the audit trail", () => {
9
+ beforeAll(migrate);
10
+
11
+ it("platform packages record into it through the app's audit sink", async () => {
12
+ const sink = auditSinkFor(env);
13
+ expect(sink).toBeDefined();
14
+ await sink?.record({ action: APP_AUDIT_ACTIONS.settingsChanged, actorId: null, subjectId: "settings:sink", occurredAt: new Date("2040-01-01T00:00:00Z") });
15
+ const page = await auditLog(env).list({ subjectId: "settings:sink" });
16
+ expect(page.items.map((event) => event.action)).toEqual(["app.settings.changed"]);
17
+ });
18
+
19
+ it("stores the app's own events, redacted, and lists them newest first", async () => {
20
+ const log = auditLog(env);
21
+ const record = (minute: number, metadata: Record<string, unknown>) =>
22
+ log.record({
23
+ action: APP_AUDIT_ACTIONS.settingsChanged,
24
+ actorId: null,
25
+ subjectId: "settings:general",
26
+ occurredAt: new Date(`2040-01-02T00:0${String(minute)}:00Z`),
27
+ requestId: `request-${String(minute)}`,
28
+ metadata,
29
+ });
30
+ await record(1, { changed: ["timezone"] });
31
+ await record(2, { changed: ["locale"], apiToken: "do-not-store" });
32
+
33
+ const page = await log.list({ subjectId: "settings:general" });
34
+ expect(page.items.map(({ requestId, metadata }) => ({ requestId, metadata }))).toEqual([
35
+ { requestId: "request-2", metadata: { changed: ["locale"], apiToken: "[REDACTED]" } },
36
+ { requestId: "request-1", metadata: { changed: ["timezone"] } },
37
+ ]);
38
+ expect(await log.count({ subjectId: "settings:general" })).toBe(2);
39
+ });
40
+
41
+ it("refuses an unregistered action locally, so a typo fails the test that records it", async () => {
42
+ await expect(
43
+ auditLog(env).record({ action: "app.settings.chnaged", actorId: null, subjectId: "settings:typo", occurredAt: new Date() }),
44
+ ).rejects.toThrow(/not registered/);
45
+ expect(await auditLog(env).count({ subjectId: "settings:typo" })).toBe(0);
46
+ });
47
+ });
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "audit",
3
+ "description": "Audit trail (@awsmbiz/inbis-audit): one audit_event table for platform and app events with the app's action registry; with auth, the activity timelines and a My activity page.",
4
+ "order": 30,
5
+ "requires": [],
6
+ "dependencies": { "@awsmbiz/inbis-audit": "workspace:*" },
7
+ "wiring": {
8
+ "audit.imports": "wiring/audit.imports.ts",
9
+ "audit.sink": "wiring/audit.sink.ts",
10
+ "index.imports": "wiring/index.imports.ts",
11
+ "index.setup": "wiring/index.setup.ts",
12
+ "env.bindings": "wiring/env.bindings.ts",
13
+ "wrangler.vars": { "dev": "wiring/vars.dev.jsonc", "staging": "wiring/vars.deployed.jsonc", "production": "wiring/vars.deployed.jsonc" },
14
+ "wrangler.d1": "wiring/d1.jsonc",
15
+ "test.env": "wiring/test.env.ts",
16
+ "test.migrate": "wiring/test.migrate.ts",
17
+ "vitest.bindings": "wiring/vitest.bindings.ts",
18
+ "readme.modules": "wiring/readme.modules.md"
19
+ },
20
+ "when": [
21
+ {
22
+ "modules": ["auth"],
23
+ "files": "with-auth",
24
+ "wiring": {
25
+ "audit.catalogues.imports": "wiring/auth/audit.catalogues.imports.ts",
26
+ "audit.catalogues": "wiring/auth/audit.catalogues.ts",
27
+ "worker.imports": "wiring/auth/worker.imports.ts",
28
+ "worker.routes": "wiring/auth/worker.routes.ts",
29
+ "web.routes.imports": "wiring/auth/web.routes.imports.tsx",
30
+ "web.routes": "wiring/auth/web.routes.tsx",
31
+ "web.messages": "wiring/auth/web.messages.ts",
32
+ "readme.modules": "wiring/auth/readme.modules.md"
33
+ }
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1 @@
1
+ import { auditLog } from "./audit-log.js";
@@ -0,0 +1,2 @@
1
+ // The audit trail (src/audit-log.ts, binding AUDIT_DB).
2
+ (env, options) => auditLog(env, options),
@@ -0,0 +1 @@
1
+ import { AUTH_AUDIT_ACTIONS } from "@awsmbiz/inbis-auth";
@@ -0,0 +1 @@
1
+ AUTH_AUDIT_ACTIONS,
@@ -0,0 +1,7 @@
1
+ With sign-in (the `auth` module), the trail also holds auth's events (`AUTH_AUDIT_ACTIONS`,
2
+ registered in `src/audit-sink.ts`), and `src/audit-routes.ts` serves the activity timelines:
3
+
4
+ | Route | Who | |
5
+ |---|---|---|
6
+ | `GET /api/me/activity` | Anyone signed in | The caller's own events, newest first: the **My activity** page (`/activity`, `web/src/my-activity.tsx`) |
7
+ | `GET /api/admin/audit-events` | Platform administrators | Every event; query `action`, `actor`, `subject`, `from`, `to`, `limit`, `cursor` |
@@ -0,0 +1,26 @@
1
+ "activity.title": "My activity",
2
+ "activity.description": "Sign-ins and other changes to your account, newest first. If you don't recognise one, change your password.",
3
+ "activity.loading": "Loading activity…",
4
+ "activity.failed": "Your activity could not be loaded.",
5
+ "activity.failedHint": "Check your connection and try again.",
6
+ "activity.tryAgain": "Try again",
7
+ "activity.empty": "No activity yet.",
8
+ "activity.listLabel": "Activity",
9
+ "activity.moreFailed": "Older activity could not be loaded. Try again.",
10
+ "activity.loadMore": "Load older activity",
11
+ "activity.method.email": "Email and password",
12
+ "activity.method.phone-otp": "Phone code",
13
+ "activity.action.auth.sign_in.succeeded": "Signed in",
14
+ "activity.action.auth.sign_out": "Signed out",
15
+ "activity.action.auth.password.changed": "Changed password",
16
+ "activity.action.auth.password.reset": "Reset password",
17
+ "activity.action.auth.email.verified": "Verified email address",
18
+ "activity.action.auth.two_factor.enabled": "Turned on two-factor authentication",
19
+ "activity.action.auth.two_factor.disabled": "Turned off two-factor authentication",
20
+ "activity.action.auth.two_factor.backup_codes_regenerated": "Generated new backup codes",
21
+ "activity.action.auth.two_factor.backup_code_used": "Used a backup code",
22
+ "activity.action.auth.session.revoked": "Signed out a session",
23
+ "activity.action.auth.session.others_revoked": "Signed out other sessions",
24
+ "activity.action.auth.account.deletion_scheduled": "Scheduled account deletion",
25
+ "activity.action.auth.account.deletion_cancelled": "Cancelled account deletion",
26
+ "activity.action.auth.invitation.accepted": "Accepted an invitation",
@@ -0,0 +1 @@
1
+ import { MyActivity } from "./my-activity.js";
@@ -0,0 +1 @@
1
+ { path: "/activity", title: "activity.title", nav: true, render: ({ navigate }) => <MyActivity navigate={navigate} /> },
@@ -0,0 +1 @@
1
+ import { mountAuditRoutes } from "./audit-routes.js";
@@ -0,0 +1,2 @@
1
+ // Activity timelines (src/audit-routes.ts): GET /api/me/activity (signed in), GET /api/admin/audit-events (admins).
2
+ mountAuditRoutes(app);
@@ -0,0 +1,8 @@
1
+ {
2
+ // The audit trail (@awsmbiz/inbis-audit): after auth's and access control's, before DB.
3
+ "binding": "AUDIT_DB",
4
+ "database_name": "{{database_name}}",
5
+ "database_id": "{{database_id}}",
6
+ "migrations_dir": "node_modules/@awsmbiz/inbis-audit/migrations",
7
+ "migrations_table": "_inbis_audit_migrations"
8
+ },
@@ -0,0 +1,4 @@
1
+ /** The audit trail's D1 binding (`@awsmbiz/inbis-audit` migrations), to the app's database. */
2
+ AUDIT_DB: D1Database;
3
+ /** `"true"`: an unregistered audit action throws (local development and tests). */
4
+ AUDIT_STRICT_ACTIONS: string;
@@ -0,0 +1 @@
1
+ import { auditActions } from "./audit-log.js";
@@ -0,0 +1,2 @@
1
+ // The audit action registry: a malformed or platform-namespaced app action fails startup.
2
+ auditActions();
@@ -0,0 +1,16 @@
1
+ ## Audit trail
2
+
3
+ `@awsmbiz/inbis-audit` keeps one `audit_event` table for platform and app events, in the
4
+ app's database through its own binding (`AUDIT_DB`, migrations shipped in the package and
5
+ applied before the app's own). Metadata is redacted before it is stored.
6
+
7
+ - **Recording an event:** `auditLog(c.env, { logger: c.var.logger }).record({ action,
8
+ actorId, subjectId, occurredAt, requestId, metadata })` (`src/audit-log.ts`).
9
+ - **Action registry:** every action must be registered. Add the app's own to
10
+ `src/audit-actions.ts` (`APP_AUDIT_ACTIONS`, in the app's own namespaces); platform
11
+ packages' catalogues are listed in `src/audit-sink.ts`. A malformed name fails startup.
12
+ With `AUDIT_STRICT_ACTIONS` = `"true"` (local and tests) an unregistered action throws;
13
+ deployed (`"false"`) it is stored and logged as `audit.action.unregistered`.
14
+ - **Platform packages** record through `auditSinkFor(env)` in `src/audit-sink.ts`.
15
+ - **Reading:** `auditLog(env).list({ action, actorId, subjectId, from, to, limit, cursor })`
16
+ (newest first, keyset pages) and `count(...)`.
@@ -0,0 +1 @@
1
+ AUDIT_MIGRATIONS: D1Migration[];
@@ -0,0 +1 @@
1
+ await applyD1Migrations(env.AUDIT_DB, env.AUDIT_MIGRATIONS, "_inbis_audit_migrations");
@@ -0,0 +1,2 @@
1
+ // Deployed, an unregistered audit action is stored anyway and logged (audit.action.unregistered).
2
+ "AUDIT_STRICT_ACTIONS": "false",
@@ -0,0 +1,2 @@
1
+ // Unregistered audit actions throw locally (a typo fails the test that records it).
2
+ "AUDIT_STRICT_ACTIONS": "true",
@@ -0,0 +1 @@
1
+ AUDIT_MIGRATIONS: await readD1Migrations("./node_modules/@awsmbiz/inbis-audit/migrations"),
@@ -0,0 +1,89 @@
1
+ import { expect, test, type Page } from "@playwright/test";
2
+ import { expectNoPageScroll, expectNoViolations, THEMES, VIEWPORTS } from "./axe.js";
3
+
4
+ // My activity, against canned `GET /api/me/activity` responses: the page's own behaviour
5
+ // (listing, paging, empty, failure, signed out) without depending on a seeded account.
6
+ // The route itself is covered by test/audit-routes.test.ts.
7
+
8
+ const event = (id: string, action: string, occurredAt: string, metadata: Record<string, unknown> | null = null) => ({
9
+ id,
10
+ action,
11
+ actorId: "user-1",
12
+ subjectId: "user:user-1",
13
+ occurredAt,
14
+ requestId: null,
15
+ metadata,
16
+ });
17
+
18
+ const FIRST = {
19
+ items: [
20
+ event("e3", "auth.sign_in.succeeded", "2040-01-03T09:00:00Z", { method: "email", ipAddress: "203.0.113.7", userAgent: "Firefox on Linux" }),
21
+ event("e2", "auth.password.changed", "2040-01-02T09:00:00Z"),
22
+ ],
23
+ nextCursor: "page-2",
24
+ };
25
+ const SECOND = { items: [event("e1", "app.settings.changed", "2040-01-01T09:00:00Z")], nextCursor: null };
26
+
27
+ async function serveActivity(page: Page, respond: (cursor: string | null) => { status: number; body: unknown }) {
28
+ await page.route("**/api/me/activity?*", async (route) => {
29
+ const { status, body } = respond(new URL(route.request().url()).searchParams.get("cursor"));
30
+ await route.fulfill({ status, json: body });
31
+ });
32
+ }
33
+
34
+ test("lists the user's own activity, newest first, and pages to older events", async ({ page }) => {
35
+ await serveActivity(page, (cursor) => ({ status: 200, body: cursor === "page-2" ? SECOND : FIRST }));
36
+ await page.goto("/");
37
+ await page.getByRole("navigation", { name: "Main" }).getByRole("link", { name: "My activity" }).click();
38
+ await expect(page).toHaveTitle("My activity · React Hono App");
39
+ const list = page.getByRole("list", { name: "Activity" });
40
+ await expect(list.getByRole("listitem")).toHaveCount(2);
41
+ await expect(list.getByRole("listitem").first()).toContainText("Signed in");
42
+ await expect(list.getByRole("listitem").first()).toContainText("Email and password · 203.0.113.7 · Firefox on Linux");
43
+ await expect(list.getByRole("listitem").nth(1)).toContainText("Changed password");
44
+
45
+ await page.getByRole("button", { name: "Load older activity" }).click();
46
+ await expect(list.getByRole("listitem")).toHaveCount(3);
47
+ // An action without a message shows its name; focus moves to the first older event.
48
+ await expect(list.getByRole("listitem").nth(2)).toContainText("app.settings.changed");
49
+ await expect(list.getByRole("listitem").nth(2)).toBeFocused();
50
+ await expect(page.getByRole("button", { name: "Load older activity" })).toHaveCount(0);
51
+ });
52
+
53
+ test("says so when there is no activity yet", async ({ page }) => {
54
+ await serveActivity(page, () => ({ status: 200, body: { items: [], nextCursor: null } }));
55
+ await page.goto("/activity");
56
+ await expect(page.getByText("No activity yet.")).toBeVisible();
57
+ });
58
+
59
+ test("offers a retry when the activity cannot be loaded", async ({ page }) => {
60
+ let failing = true;
61
+ await serveActivity(page, () =>
62
+ failing ? { status: 500, body: { error: { code: "INTERNAL", message: "x", requestId: "r" } } } : { status: 200, body: FIRST },
63
+ );
64
+ await page.goto("/activity");
65
+ await expect(page.getByRole("alert")).toContainText("Your activity could not be loaded.");
66
+ failing = false;
67
+ await page.getByRole("button", { name: "Try again" }).click();
68
+ await expect(page.getByRole("list", { name: "Activity" }).getByRole("listitem")).toHaveCount(2);
69
+ });
70
+
71
+ test("sends a signed-out visitor to sign in, coming back here afterwards", async ({ page }) => {
72
+ await serveActivity(page, () => ({ status: 401, body: { error: { code: "UNAUTHENTICATED", message: "Sign in to continue.", requestId: "r" } } }));
73
+ await page.goto("/activity");
74
+ await expect(page).toHaveURL(/\/sign-in\?next=%2Factivity$/);
75
+ });
76
+
77
+ for (const theme of THEMES) {
78
+ for (const [viewport, size] of Object.entries(VIEWPORTS)) {
79
+ test(`My activity (${theme}, ${viewport}) has no accessibility violations`, async ({ page }) => {
80
+ await serveActivity(page, () => ({ status: 200, body: FIRST }));
81
+ await page.emulateMedia({ colorScheme: theme });
82
+ await page.setViewportSize(size);
83
+ await page.goto("/activity");
84
+ await expect(page.getByRole("list", { name: "Activity" }).getByRole("listitem")).toHaveCount(2);
85
+ await expectNoPageScroll(page);
86
+ await expectNoViolations(page);
87
+ });
88
+ }
89
+ }
@@ -0,0 +1,21 @@
1
+ import type { OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { ApiEnv } from "@awsmbiz/inbis-api";
3
+ import { mountAuditTimeline, mountOwnActivity } from "@awsmbiz/inbis-audit/hono";
4
+ import { requireAdmin, requireAuth, type AuthEnv } from "@awsmbiz/inbis-auth/hono";
5
+ import type { Hono } from "hono";
6
+ import { auditLog } from "./audit-log.js";
7
+
8
+ /**
9
+ * The activity timelines over the audit trail, behind auth's session middleware
10
+ * (mounted after it, which sets `c.var.user`):
11
+ *
12
+ * - `GET /api/me/activity`: the signed-in user's own events, newest first (My activity).
13
+ * - `GET /api/admin/audit-events`: every event, filterable by action, actor, subject and
14
+ * date range (platform administrators).
15
+ */
16
+ export function mountAuditRoutes(app: OpenAPIHono<ApiEnv<Env>>): void {
17
+ const timeline = app as unknown as Hono<ApiEnv<Env> & AuthEnv>;
18
+ const log = (c: { env: Env; var: { logger: ApiEnv["Variables"]["logger"] } }) => auditLog(c.env, { logger: c.var.logger });
19
+ mountOwnActivity(timeline, { log, guard: requireAuth(), actorIdOf: (c) => c.var.user?.id });
20
+ mountAuditTimeline(timeline, { log, guard: requireAdmin() });
21
+ }
@@ -0,0 +1,17 @@
1
+ import { exports } from "cloudflare:workers";
2
+ import { beforeAll, describe, expect, it } from "vitest";
3
+ import { migrate } from "./migrate.js";
4
+
5
+ const ORIGIN = "https://app.example.test";
6
+
7
+ describe("the activity timelines", () => {
8
+ beforeAll(migrate);
9
+
10
+ for (const path of ["/api/me/activity", "/api/admin/audit-events"]) {
11
+ it(`GET ${path} needs a signed-in user`, async () => {
12
+ const response = await exports.default.fetch(new Request(`${ORIGIN}${path}`));
13
+ expect(response.status).toBe(401);
14
+ expect(await response.json()).toMatchObject({ error: { code: "UNAUTHENTICATED" } });
15
+ });
16
+ }
17
+ });
@@ -0,0 +1,168 @@
1
+ import { Alert, AlertDescription, AlertTitle, Button } from "@awsmbiz/inbis-ui";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { isMessageKey, t } from "./messages.js";
4
+ import type { Navigate } from "./navigation.js";
5
+
6
+ /** One event from `GET /api/me/activity`. */
7
+ interface ActivityEvent {
8
+ id: string;
9
+ action: string;
10
+ occurredAt: string;
11
+ metadata: Record<string, unknown> | null;
12
+ }
13
+
14
+ interface ActivityPage {
15
+ items: ActivityEvent[];
16
+ nextCursor: string | null;
17
+ }
18
+
19
+ type State =
20
+ | { kind: "loading" }
21
+ | { kind: "error" }
22
+ | { kind: "ready"; events: ActivityEvent[]; nextCursor: string | null; loadingMore: boolean; moreFailed: boolean };
23
+
24
+ /** Where a signed-out visitor is sent; they come back here after signing in. */
25
+ const SIGN_IN = "/sign-in?next=%2Factivity";
26
+
27
+ /**
28
+ * The message for `prefix` + `value` when there is one, else the raw value: plain-language
29
+ * names for the actions a user performs (`activity.action.*`; others show their dotted
30
+ * name) and for sign-in methods (`activity.method.*`).
31
+ */
32
+ function labelOf(prefix: string, value: string): string {
33
+ const key = `${prefix}${value}`;
34
+ return isMessageKey(key) ? t(key) : value;
35
+ }
36
+
37
+ const text = (value: unknown): string | null => (typeof value === "string" && value !== "" ? value : null);
38
+
39
+ /** Where and how: sign-in method, IP address and browser, when recorded. */
40
+ function detailsOf(event: ActivityEvent): string[] {
41
+ const metadata = event.metadata ?? {};
42
+ const method = text(metadata["method"]);
43
+ return [method === null ? null : labelOf("activity.method.", method), text(metadata["ipAddress"]), text(metadata["userAgent"])].filter(
44
+ (detail): detail is string => detail !== null,
45
+ );
46
+ }
47
+
48
+ const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
49
+
50
+ function fetchPage(cursor: string | null): Promise<Response> {
51
+ const query = new URLSearchParams({ limit: "20" });
52
+ if (cursor !== null) query.set("cursor", cursor);
53
+ return fetch(`/api/me/activity?${query.toString()}`, { credentials: "include" });
54
+ }
55
+
56
+ /** My activity: the signed-in user's own audit events, newest first, to spot sign-ins they did not make. */
57
+ export function MyActivity({ navigate }: { navigate: Navigate }) {
58
+ const [state, setState] = useState<State>({ kind: "loading" });
59
+ const [attempt, setAttempt] = useState(0);
60
+ /** Index of the first event of the last page loaded by "Load older activity": it gets focus. */
61
+ const focusIndex = useRef<number | null>(null);
62
+ const items = useRef<(HTMLLIElement | null)[]>([]);
63
+
64
+ useEffect(() => {
65
+ let active = true;
66
+ fetchPage(null)
67
+ .then((response) => {
68
+ if (!active) return null;
69
+ if (response.status === 401) navigate(SIGN_IN, { replace: true });
70
+ else if (!response.ok) setState({ kind: "error" });
71
+ else return response.json() as Promise<ActivityPage>;
72
+ return null;
73
+ })
74
+ .then((page) => {
75
+ if (active && page !== null) {
76
+ setState({ kind: "ready", events: page.items, nextCursor: page.nextCursor, loadingMore: false, moreFailed: false });
77
+ }
78
+ })
79
+ .catch(() => {
80
+ if (active) setState({ kind: "error" });
81
+ });
82
+ return () => {
83
+ active = false;
84
+ };
85
+ }, [navigate, attempt]);
86
+
87
+ useEffect(() => {
88
+ if (state.kind !== "ready" || focusIndex.current === null) return;
89
+ items.current[focusIndex.current]?.focus();
90
+ focusIndex.current = null;
91
+ }, [state]);
92
+
93
+ const loadMore = useCallback(async () => {
94
+ if (state.kind !== "ready" || state.nextCursor === null) return;
95
+ const { events, nextCursor } = state;
96
+ setState({ ...state, loadingMore: true, moreFailed: false });
97
+ const response = await fetchPage(nextCursor).catch(() => null);
98
+ if (response?.ok !== true) {
99
+ setState({ ...state, loadingMore: false, moreFailed: true });
100
+ return;
101
+ }
102
+ const page = (await response.json()) as ActivityPage;
103
+ focusIndex.current = page.items.length > 0 ? events.length : null;
104
+ setState({ kind: "ready", events: [...events, ...page.items], nextCursor: page.nextCursor, loadingMore: false, moreFailed: false });
105
+ }, [state]);
106
+
107
+ return (
108
+ <div className="flex flex-col gap-6">
109
+ <div className="flex flex-col gap-2">
110
+ <h1 className="text-2xl font-semibold tracking-tight">{t("activity.title")}</h1>
111
+ <p className="text-muted-foreground">{t("activity.description")}</p>
112
+ </div>
113
+ {state.kind === "loading" ? (
114
+ <p role="status" className="text-sm text-muted-foreground">
115
+ {t("activity.loading")}
116
+ </p>
117
+ ) : null}
118
+ {state.kind === "error" ? (
119
+ <>
120
+ <Alert variant="destructive" role="alert">
121
+ <AlertTitle>{t("activity.failed")}</AlertTitle>
122
+ <AlertDescription>{t("activity.failedHint")}</AlertDescription>
123
+ </Alert>
124
+ <Button
125
+ className="self-start"
126
+ onClick={() => {
127
+ setState({ kind: "loading" });
128
+ setAttempt((n) => n + 1);
129
+ }}
130
+ >
131
+ {t("activity.tryAgain")}
132
+ </Button>
133
+ </>
134
+ ) : null}
135
+ {state.kind === "ready" && state.events.length === 0 ? <p>{t("activity.empty")}</p> : null}
136
+ {state.kind === "ready" && state.events.length > 0 ? (
137
+ <ol aria-label={t("activity.listLabel")} className="flex flex-col divide-y">
138
+ {state.events.map((event, index) => (
139
+ <li
140
+ key={event.id}
141
+ ref={(element) => {
142
+ items.current[index] = element;
143
+ }}
144
+ tabIndex={-1}
145
+ className="flex flex-col gap-1 py-3 outline-offset-2 first:pt-0"
146
+ >
147
+ <span className="font-medium">{labelOf("activity.action.", event.action)}</span>
148
+ <time dateTime={event.occurredAt} className="text-sm text-muted-foreground">
149
+ {dateFormat.format(new Date(event.occurredAt))}
150
+ </time>
151
+ {detailsOf(event).length > 0 ? <span className="text-sm break-words text-muted-foreground">{detailsOf(event).join(" · ")}</span> : null}
152
+ </li>
153
+ ))}
154
+ </ol>
155
+ ) : null}
156
+ {state.kind === "ready" && state.moreFailed ? (
157
+ <Alert variant="destructive" role="alert">
158
+ <AlertDescription>{t("activity.moreFailed")}</AlertDescription>
159
+ </Alert>
160
+ ) : null}
161
+ {state.kind === "ready" && state.nextCursor !== null ? (
162
+ <Button variant="outline" className="self-start" disabled={state.loadingMore} onClick={() => void loadMore()}>
163
+ {state.loadingMore ? t("common.loading") : t("activity.loadMore")}
164
+ </Button>
165
+ ) : null}
166
+ </div>
167
+ );
168
+ }