@cosmicdrift/kumiko-bundled-features 0.146.4 → 0.147.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 (56) hide show
  1. package/package.json +6 -6
  2. package/src/admin-shell/__tests__/overview-allowlist.test.ts +1 -0
  3. package/src/admin-shell/overview-allowlist.ts +1 -0
  4. package/src/admin-shell/web/platform-overview-screen.tsx +11 -13
  5. package/src/audit/__tests__/audit-screens.boot.test.ts +17 -8
  6. package/src/audit/handlers/details.query.ts +1 -1
  7. package/src/audit/web/audit-log-detail-screen.tsx +7 -9
  8. package/src/audit/web/audit-log-screen.tsx +16 -29
  9. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +4 -2
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -2
  11. package/src/auth-email-password/password-hashing.ts +4 -55
  12. package/src/auth-email-password/web/user-menu.tsx +1 -1
  13. package/src/auth-mfa/__tests__/totp.test.ts +122 -0
  14. package/src/auth-mfa/base32.ts +45 -0
  15. package/src/auth-mfa/otpauth-uri.ts +21 -0
  16. package/src/auth-mfa/totp.ts +47 -0
  17. package/src/billing-foundation/events.ts +4 -4
  18. package/src/billing-foundation/tenant-destroy-hook.ts +5 -6
  19. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +8 -5
  20. package/src/cap-counter/enforce-cap.ts +15 -18
  21. package/src/cap-counter/with-cap-enforcement.ts +2 -2
  22. package/src/config/__tests__/deserialize-value.test.ts +2 -2
  23. package/src/config/resolver.ts +7 -2
  24. package/src/custom-fields/__tests__/field-access.integration.test.ts +25 -0
  25. package/src/custom-fields/__tests__/retention.integration.test.ts +40 -0
  26. package/src/custom-fields/lib/field-access.ts +8 -1
  27. package/src/custom-fields/run-retention.ts +9 -1
  28. package/src/data-retention/__tests__/parse-override.test.ts +1 -0
  29. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +81 -2
  30. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +39 -1
  31. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +29 -8
  32. package/src/inbound-mail-foundation/retention-sweep.ts +25 -12
  33. package/src/inbound-provider-imap/feature.ts +3 -7
  34. package/src/jobs/web/job-run-detail-screen.tsx +7 -9
  35. package/src/jobs/web/job-runs-screen.tsx +3 -21
  36. package/src/page-render/__tests__/layout.test.ts +10 -0
  37. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
  38. package/src/renderer-simple/__tests__/resolve-variables.test.ts +37 -0
  39. package/src/renderer-simple/resolve-variables.ts +3 -1
  40. package/src/seo/__tests__/seo.integration.test.ts +12 -0
  41. package/src/seo/feature.ts +16 -4
  42. package/src/seo/sitemap.ts +3 -0
  43. package/src/{auth-email-password/__tests__ → shared}/identity-v3-hash.test.ts +2 -2
  44. package/src/shared/index.ts +2 -0
  45. package/src/shared/password-hashing.ts +55 -0
  46. package/src/tags/web/__tests__/tag-section.test.tsx +47 -1
  47. package/src/tags/web/tag-section.tsx +6 -3
  48. package/src/tenant-lifecycle/lifecycle-gate.ts +17 -10
  49. package/src/text-content/web/client-plugin.tsx +1 -1
  50. package/src/tier-engine/__tests__/trial-config-validation.test.ts +30 -0
  51. package/src/tier-engine/feature.ts +5 -0
  52. package/src/user-data-rights/__tests__/email-templates.test.ts +15 -0
  53. package/src/user-data-rights/email-templates.ts +6 -6
  54. package/src/user-data-rights/web/privacy-center-screen.tsx +2 -0
  55. /package/src/{auth-email-password → shared}/identity-v3-hash.ts +0 -0
  56. /package/src/{auth-email-password → shared}/password-hashing.test.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.146.4",
3
+ "version": "0.147.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -111,11 +111,11 @@
111
111
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
112
112
  },
113
113
  "dependencies": {
114
- "@cosmicdrift/kumiko-dispatcher-live": "0.146.4",
115
- "@cosmicdrift/kumiko-framework": "0.146.4",
116
- "@cosmicdrift/kumiko-headless": "0.146.4",
117
- "@cosmicdrift/kumiko-renderer": "0.146.4",
118
- "@cosmicdrift/kumiko-renderer-web": "0.146.4",
114
+ "@cosmicdrift/kumiko-dispatcher-live": "0.147.0",
115
+ "@cosmicdrift/kumiko-framework": "0.147.0",
116
+ "@cosmicdrift/kumiko-headless": "0.147.0",
117
+ "@cosmicdrift/kumiko-renderer": "0.147.0",
118
+ "@cosmicdrift/kumiko-renderer-web": "0.147.0",
119
119
  "@mollie/api-client": "^4.5.0",
120
120
  "imapflow": "^1.3.3",
121
121
  "mailparser": "^3.9.8",
@@ -38,5 +38,6 @@ describe("overview query allowlist", () => {
38
38
  test("platform queries are not tenant-allowlisted", () => {
39
39
  expect(isOverviewQueryAllowed("tenant", TenantQueries.list)).toBe(false);
40
40
  expect(isOverviewQueryAllowed("tenant", JobQueries.list)).toBe(false);
41
+ expect(isOverviewQueryAllowed("tenant", UserQueries.list)).toBe(false);
41
42
  });
42
43
  });
@@ -25,6 +25,7 @@ export const TENANT_OVERVIEW_FORBIDDEN_QUERIES = [
25
25
  "jobs:query:list",
26
26
  "feature-toggles:query:list",
27
27
  "feature-toggles:query:registered",
28
+ "user:query:user:list",
28
29
  ] as const;
29
30
 
30
31
  export function overviewAllowedQueries(kind: OverviewWorkspaceKind): readonly string[] {
@@ -16,11 +16,11 @@ export function PlatformOverviewScreen(): ReactNode {
16
16
  useEffect(() => {
17
17
  let cancelled = false;
18
18
  void (async (): Promise<void> => {
19
- const tenantsRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
19
+ const tenantsRes = await overviewQuery<{ readonly total: number }>(
20
20
  "platform",
21
21
  dispatcher,
22
22
  TenantQueries.list,
23
- {},
23
+ { totalCount: true },
24
24
  );
25
25
  if (cancelled) return;
26
26
  if (!tenantsRes.isSuccess) {
@@ -28,11 +28,11 @@ export function PlatformOverviewScreen(): ReactNode {
28
28
  return;
29
29
  }
30
30
 
31
- const usersRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
31
+ const usersRes = await overviewQuery<{ readonly total: number }>(
32
32
  "platform",
33
33
  dispatcher,
34
34
  UserQueries.list,
35
- {},
35
+ { totalCount: true },
36
36
  );
37
37
  if (cancelled) return;
38
38
  if (!usersRes.isSuccess) {
@@ -40,11 +40,11 @@ export function PlatformOverviewScreen(): ReactNode {
40
40
  return;
41
41
  }
42
42
 
43
- const failedJobsRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
43
+ const failedJobsRes = await overviewQuery<{ readonly total: number }>(
44
44
  "platform",
45
45
  dispatcher,
46
46
  JobQueries.list,
47
- { status: "failed", limit: 50 },
47
+ { status: "failed", totalCount: true },
48
48
  );
49
49
  if (cancelled) return;
50
50
  if (!failedJobsRes.isSuccess) {
@@ -57,20 +57,18 @@ export function PlatformOverviewScreen(): ReactNode {
57
57
  cards: [
58
58
  {
59
59
  label: t("admin-shell:overview.tenants"),
60
- value: String(tenantsRes.data.rows.length),
60
+ value: String(tenantsRes.data.total),
61
61
  },
62
62
  {
63
63
  label: t("admin-shell:overview.users"),
64
- value: String(usersRes.data.rows.length),
64
+ value: String(usersRes.data.total),
65
65
  },
66
66
  {
67
67
  label: t("admin-shell:overview.failedJobs"),
68
- value: String(failedJobsRes.data.rows.length),
68
+ value: String(failedJobsRes.data.total),
69
69
  hint:
70
- failedJobsRes.data.rows.length > 0
71
- ? t("admin-shell:overview.failedJobsHint")
72
- : undefined,
73
- attention: failedJobsRes.data.rows.length > 0,
70
+ failedJobsRes.data.total > 0 ? t("admin-shell:overview.failedJobsHint") : undefined,
71
+ attention: failedJobsRes.data.total > 0,
74
72
  },
75
73
  ],
76
74
  });
@@ -15,22 +15,31 @@ describe("audit log screen + handler access alignment", () => {
15
15
  test("audit-log screen is custom, access.admin-gated", () => {
16
16
  const audit = createAuditFeature();
17
17
  const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
18
- expect(screen?.type).toBe("custom");
19
- if (screen && "access" in screen && screen.access && "roles" in screen.access) {
20
- expect(screen.access.roles).toEqual(access.admin);
18
+ if (!screen || screen.type !== "custom") {
19
+ throw new Error(`expected a custom screen for ${AUDIT_LOG_SCREEN_ID}, got ${screen?.type}`);
21
20
  }
21
+ if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
22
+ throw new Error(`expected role-gated access on ${AUDIT_LOG_SCREEN_ID}`);
23
+ }
24
+ expect(screen.access.roles).toEqual(access.admin);
22
25
  });
23
26
 
24
27
  test("audit-log-detail screen is custom, admin-gated, breadcrumb-linked to list", () => {
25
28
  const audit = createAuditFeature();
26
29
  const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
27
- expect(screen?.type).toBe("custom");
28
- if (screen && "listScreenId" in screen) {
29
- expect(screen.listScreenId).toBe(AUDIT_LOG_SCREEN_ID);
30
+ if (!screen || screen.type !== "custom") {
31
+ throw new Error(
32
+ `expected a custom screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
33
+ );
34
+ }
35
+ if (!("listScreenId" in screen)) {
36
+ throw new Error(`expected listScreenId on ${AUDIT_LOG_DETAIL_SCREEN_ID}`);
30
37
  }
31
- if (screen && "access" in screen && screen.access && "roles" in screen.access) {
32
- expect(screen.access.roles).toEqual(access.admin);
38
+ expect(screen.listScreenId).toBe(AUDIT_LOG_SCREEN_ID);
39
+ if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
40
+ throw new Error(`expected role-gated access on ${AUDIT_LOG_DETAIL_SCREEN_ID}`);
33
41
  }
42
+ expect(screen.access.roles).toEqual(access.admin);
34
43
  });
35
44
 
36
45
  test("audit queries use access.admin (screen ⊆ handler)", () => {
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  export const detailsQuery = defineQueryHandler({
11
11
  name: "details",
12
12
  schema: z.object({
13
- id: z.string().regex(/^\d+$/, "id must be a positive integer"),
13
+ id: z.string().regex(/^[1-9]\d*$/, "id must be a positive integer"),
14
14
  }),
15
15
  access: { roles: access.admin },
16
16
  handler: async (query, ctx) => {
@@ -1,7 +1,13 @@
1
1
  // @runtime client
2
2
  // Single audit event detail. Route entityId = event-store id (bigint string).
3
3
 
4
- import { useDispatcher, useNav, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import {
5
+ formatWhen,
6
+ useDispatcher,
7
+ useNav,
8
+ usePrimitives,
9
+ useTranslation,
10
+ } from "@cosmicdrift/kumiko-renderer";
5
11
  import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
6
12
  import { type ReactNode, useCallback, useEffect, useState } from "react";
7
13
  import { AuditQueries } from "../constants";
@@ -123,11 +129,3 @@ export function AuditLogDetailScreen(): ReactNode {
123
129
  </FormScreenShell>
124
130
  );
125
131
  }
126
-
127
- function formatWhen(value: string): string {
128
- try {
129
- return new Date(value).toLocaleString();
130
- } catch {
131
- return value;
132
- }
133
- }
@@ -4,12 +4,14 @@
4
4
 
5
5
  import {
6
6
  type DataTableSort,
7
+ formatWhen,
8
+ sortByAccessor,
7
9
  useDispatcher,
8
10
  useNav,
9
11
  usePrimitives,
10
12
  useTranslation,
11
13
  } from "@cosmicdrift/kumiko-renderer";
12
- import { type ReactNode, useCallback, useEffect, useState } from "react";
14
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
13
15
  import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
14
16
 
15
17
  type AuditRow = {
@@ -52,18 +54,22 @@ export function AuditLogScreen(): ReactNode {
52
54
  const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
53
55
  const [sort, setSort] = useState<DataTableSort | null>(null);
54
56
 
57
+ const filtersRef = useRef(filters);
58
+ filtersRef.current = filters;
59
+
55
60
  const load = useCallback(
56
- async (cursor?: string): Promise<void> => {
61
+ async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
57
62
  setState({ kind: "loading" });
63
+ const f = overrideFilters ?? filtersRef.current;
58
64
  const res = await dispatcher.query<AuditResponse>(AuditQueries.list, {
59
65
  limit: 50,
60
66
  ...(cursor !== undefined && { before: cursor }),
61
- ...(filters.eventType.trim() !== "" && { eventType: filters.eventType.trim() }),
62
- ...(filters.aggregateType.trim() !== "" && {
63
- aggregateType: filters.aggregateType.trim(),
67
+ ...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
68
+ ...(f.aggregateType.trim() !== "" && {
69
+ aggregateType: f.aggregateType.trim(),
64
70
  }),
65
- ...(filters.from !== "" && { from: toIsoStart(filters.from) }),
66
- ...(filters.to !== "" && { to: toIsoEnd(filters.to) }),
71
+ ...(f.from !== "" && { from: toIsoStart(f.from) }),
72
+ ...(f.to !== "" && { to: toIsoEnd(f.to) }),
67
73
  });
68
74
  if (!res.isSuccess) {
69
75
  setState({ kind: "error", message: res.error.message });
@@ -71,7 +77,7 @@ export function AuditLogScreen(): ReactNode {
71
77
  }
72
78
  setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore });
73
79
  },
74
- [dispatcher, filters],
80
+ [dispatcher],
75
81
  );
76
82
 
77
83
  useEffect(() => {
@@ -156,6 +162,7 @@ export function AuditLogScreen(): ReactNode {
156
162
  onClick={() => {
157
163
  setFilters(EMPTY_FILTERS);
158
164
  setBefore(undefined);
165
+ void load(undefined, EMPTY_FILTERS);
159
166
  }}
160
167
  testId="audit-log-reset-filters"
161
168
  >
@@ -179,7 +186,7 @@ export function AuditLogScreen(): ReactNode {
179
186
  ]}
180
187
  sort={sort}
181
188
  onSortChange={setSort}
182
- rows={sortAudit(state.rows, sort).map((row) => ({
189
+ rows={sortByAccessor(state.rows, sort, SORT_ACCESSORS).map((row) => ({
183
190
  id: row.id,
184
191
  values: {
185
192
  when: formatWhen(row.createdAt),
@@ -234,26 +241,6 @@ const SORT_ACCESSORS: Record<string, (r: AuditRow) => string> = {
234
241
  type: (r) => r.type,
235
242
  };
236
243
 
237
- function sortAudit(rows: readonly AuditRow[], sort: DataTableSort | null): readonly AuditRow[] {
238
- if (sort === null) return rows;
239
- const accessor = SORT_ACCESSORS[sort.field];
240
- if (accessor === undefined) return rows;
241
- const factor = sort.dir === "asc" ? 1 : -1;
242
- return [...rows].sort((a, b) => {
243
- const av = accessor(a);
244
- const bv = accessor(b);
245
- return av < bv ? -factor : av > bv ? factor : 0;
246
- });
247
- }
248
-
249
- function formatWhen(value: string): string {
250
- try {
251
- return new Date(value).toLocaleString();
252
- } catch {
253
- return value;
254
- }
255
- }
256
-
257
244
  function toIsoStart(date: string): string {
258
245
  return new Date(`${date}T00:00:00.000Z`).toISOString();
259
246
  }
@@ -20,10 +20,10 @@
20
20
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
21
21
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
22
22
  import {
23
+ buildSessionRoles,
23
24
  createSystemUser,
24
25
  defineWriteHandler,
25
26
  type SessionUser,
26
- stripForbiddenMembershipRoles,
27
27
  type TenantId,
28
28
  } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
@@ -169,7 +169,9 @@ export function createInviteAcceptWithLoginHandler() {
169
169
  const session: SessionUser = {
170
170
  id: userId,
171
171
  tenantId: invitationTenantId,
172
- roles: stripForbiddenMembershipRoles([invitationRole]),
172
+ // buildSessionRoles calls stripForbiddenMembershipRoles internally —
173
+ // a reserved role on the invitation itself must never reach the session.
174
+ roles: buildSessionRoles([], [invitationRole]),
173
175
  };
174
176
 
175
177
  committed = true;
@@ -24,10 +24,10 @@ import {
24
24
  type DbConnection,
25
25
  } from "@cosmicdrift/kumiko-framework/db";
26
26
  import {
27
+ buildSessionRoles,
27
28
  createSystemUser,
28
29
  defineWriteHandler,
29
30
  type SessionUser,
30
- stripForbiddenMembershipRoles,
31
31
  type TenantId,
32
32
  } from "@cosmicdrift/kumiko-framework/engine";
33
33
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
@@ -165,7 +165,9 @@ export function createInviteSignupCompleteHandler() {
165
165
  const session: SessionUser = {
166
166
  id: userId,
167
167
  tenantId: invitationTenantId,
168
- roles: stripForbiddenMembershipRoles([invitationRole]),
168
+ // buildSessionRoles calls stripForbiddenMembershipRoles internally —
169
+ // a reserved role on the invitation itself must never reach the session.
170
+ roles: buildSessionRoles([], [invitationRole]),
169
171
  };
170
172
 
171
173
  committed = true;
@@ -1,55 +1,4 @@
1
- import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
2
- import { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
3
-
4
- // OWASP-recommended argon2id parameters (2024 guidance):
5
- // memoryCost: 19 MiB, timeCost: 2, parallelism: 1
6
- // These strike a balance between login latency (~20ms on typical hardware)
7
- // and brute-force resistance. If hashing becomes a bottleneck, tune memoryCost
8
- // before parallelism — memory hardness is what defeats GPU attacks.
9
- //
10
- // algorithm: 2 = Argon2id (best of argon2i + argon2d).
11
- // We inline the numeric value instead of importing Algorithm because the
12
- // @node-rs/argon2 enum is const and breaks verbatimModuleSyntax imports.
13
- const HASH_OPTIONS = {
14
- algorithm: 2,
15
- memoryCost: 19456,
16
- timeCost: 2,
17
- parallelism: 1,
18
- } as const;
19
-
20
- // @wrapper-known semantic-alias
21
- export async function hashPassword(password: string): Promise<string> {
22
- return argonHash(password, HASH_OPTIONS);
23
- }
24
-
25
- // Returns true if the password matches. Never throws on wrong passwords —
26
- // only on malformed hash strings (which would be a bug, not a login attempt).
27
- //
28
- // Two verifier paths:
29
- // - argon2id (default, what `hashPassword` produces)
30
- // - ASP.NET Core Identity V3 (verify-only, for legacy migrations from .NET
31
- // stacks). Sniffed via the format marker; on a successful match the
32
- // application can rehash to argon2 at the next password-change event.
33
- export async function verifyPassword(hashString: string, password: string): Promise<boolean> {
34
- if (isIdentityV3Hash(hashString)) {
35
- return verifyIdentityV3Hash(password, hashString);
36
- }
37
- try {
38
- return await argonVerify(hashString, password);
39
- } catch {
40
- // argon2 throws on unparseable hash — treat as mismatch rather than 500
41
- // to avoid revealing which accounts have corrupted stored hashes.
42
- return false;
43
- }
44
- }
45
-
46
- // Anti-enumeration timing equaliser (#774). The login handler runs this on
47
- // the no-user / no-hash path so a missing account costs the same argon2
48
- // latency as a real verify — otherwise response timing leaks whether an
49
- // email is registered. Derived from hashPassword once and cached, so it
50
- // always tracks HASH_OPTIONS; the password never matches, result discarded.
51
- let dummyHash: Promise<string> | undefined;
52
- export async function verifyDummyPassword(password: string): Promise<void> {
53
- dummyHash ??= hashPassword("anti-enumeration-dummy");
54
- await verifyPassword(await dummyHash, password);
55
- }
1
+ // Moved to shared/ reused across admin-shell, audit, sessions, tenant,
2
+ // user-data-rights, user-profile, and now auth-mfa. Re-exported here for the
3
+ // existing call sites within this feature that import via "./password-hashing".
4
+ export { hashPassword, verifyDummyPassword, verifyPassword } from "../shared/password-hashing";
@@ -34,7 +34,7 @@ export type UserMenuProps = {
34
34
  readonly children?: ReactNode;
35
35
  /** "pill" (Default) = kompakter Topbar-Trigger; "sidebar" = volle NavUser-
36
36
  * Row (Avatar + Name + Email) für den `sidebarFooter`-Slot der App-Shell.
37
- * Requires a `SidebarProvider` ancestor (547/3) — the default App-Shell
37
+ * Requires a `SidebarProvider` ancestor — the default App-Shell
38
38
  * `sidebarFooter` slot already provides one. */
39
39
  readonly variant?: UserMenuVariant;
40
40
  };
@@ -0,0 +1,122 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createHmac } from "node:crypto";
3
+ import { base32Decode, base32Encode } from "../base32";
4
+ import { buildOtpauthUri } from "../otpauth-uri";
5
+ import { generateTotpSecret, verifyTotp } from "../totp";
6
+
7
+ // RFC 6238 Appendix B test vectors use the ASCII secret "12345678901234567890"
8
+ // (20 bytes) with SHA1 — the same algorithm this implementation hardcodes
9
+ // (RFC 6238's SHA256/SHA512 variants are out of scope, matching every real
10
+ // authenticator app which only speaks SHA1).
11
+ const RFC_SECRET = Buffer.from("12345678901234567890", "ascii");
12
+
13
+ // RFC 6238 Appendix B, SHA1 column: T=59s → code "94287082" (8-digit RFC
14
+ // vector). This codebase computes 6-digit codes (Google Authenticator
15
+ // convention) — take the last 6 digits, which is truncation-equivalent
16
+ // since dynamic truncation just modulos a wider range.
17
+ function rfcVectorCode8Digit(epochSeconds: number): string {
18
+ // Precomputed from RFC 6238 Appendix B for T=59.
19
+ if (epochSeconds === 59) return "94287082";
20
+ throw new Error("no precomputed vector for this timestamp");
21
+ }
22
+
23
+ describe("TOTP — RFC 6238 vectors", () => {
24
+ test("6-digit code at T=59 matches the last 6 digits of the RFC 8-digit vector", () => {
25
+ const expected8 = rfcVectorCode8Digit(59);
26
+ const expected6 = expected8.slice(-6);
27
+ expect(verifyTotp(RFC_SECRET, expected6, 59_000)).toBe(true);
28
+ });
29
+
30
+ test("rejects a code from a different time window (>±1 step)", () => {
31
+ const expected8 = rfcVectorCode8Digit(59);
32
+ const expected6 = expected8.slice(-6);
33
+ // 59s + 3 steps (90s) is outside the ±1 (±30s) window.
34
+ expect(verifyTotp(RFC_SECRET, expected6, 59_000 + 3 * 30_000)).toBe(false);
35
+ });
36
+
37
+ test("accepts a code one step in the past or future (clock drift)", () => {
38
+ const secret = generateTotpSecret();
39
+ const now = 1_700_000_000_000;
40
+ // Derive the code for now, then verify it's still accepted 25s later —
41
+ // same 30s step, no window needed, sanity check for the step math.
42
+ const codeAtNow = deriveCodeForTest(secret, now);
43
+ expect(verifyTotp(secret, codeAtNow, now + 25_000)).toBe(true);
44
+ });
45
+
46
+ test("rejects wrong-length or non-numeric input without throwing", () => {
47
+ const secret = generateTotpSecret();
48
+ expect(verifyTotp(secret, "12345")).toBe(false);
49
+ expect(verifyTotp(secret, "1234567")).toBe(false);
50
+ expect(verifyTotp(secret, "abcdef")).toBe(false);
51
+ expect(verifyTotp(secret, "")).toBe(false);
52
+ });
53
+
54
+ test("generateTotpSecret returns 20 random, non-zero bytes", () => {
55
+ const a = generateTotpSecret();
56
+ const b = generateTotpSecret();
57
+ expect(a.length).toBe(20);
58
+ expect(a.equals(b)).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("base32", () => {
63
+ test("round-trips arbitrary byte buffers", () => {
64
+ for (const input of [
65
+ Buffer.from([]),
66
+ Buffer.from([0]),
67
+ Buffer.from([255]),
68
+ generateTotpSecret(),
69
+ Buffer.from("hello world", "ascii"),
70
+ ]) {
71
+ expect(base32Decode(base32Encode(input)).equals(input)).toBe(true);
72
+ }
73
+ });
74
+
75
+ test("decode is case-insensitive and tolerates stray whitespace", () => {
76
+ const encoded = base32Encode(Buffer.from("test-secret-bytes!!"));
77
+ expect(base32Decode(encoded.toLowerCase()).equals(base32Decode(encoded))).toBe(true);
78
+ expect(base32Decode(` ${encoded} `).equals(base32Decode(encoded))).toBe(true);
79
+ });
80
+
81
+ test("decode throws on a genuinely invalid character (e.g. punctuation)", () => {
82
+ expect(() => base32Decode("!!!not-valid-base32!!!")).toThrow();
83
+ });
84
+ });
85
+
86
+ describe("otpauth URI", () => {
87
+ test("builds a well-formed otpauth://totp URI with the expected params", () => {
88
+ const secret = generateTotpSecret();
89
+ const uri = buildOtpauthUri({
90
+ issuer: "Kumiko",
91
+ accountLabel: "acme:jane@example.com",
92
+ secret,
93
+ });
94
+ expect(uri).toStartWith("otpauth://totp/Kumiko%3Aacme%3Ajane%40example.com?");
95
+ const query = new URLSearchParams(uri.split("?")[1]);
96
+ expect(query.get("issuer")).toBe("Kumiko");
97
+ expect(query.get("algorithm")).toBe("SHA1");
98
+ expect(query.get("digits")).toBe("6");
99
+ expect(query.get("period")).toBe("30");
100
+ expect(base32Decode(query.get("secret") ?? "").equals(secret)).toBe(true);
101
+ });
102
+ });
103
+
104
+ // Internal helper mirroring the private `hotp`/`totpAt` math in totp.ts, kept
105
+ // test-local so we don't have to export internals just for round-trip tests.
106
+ function deriveCodeForTest(secret: Buffer, epochMs: number): string {
107
+ const step = Math.floor(epochMs / 1000 / 30);
108
+ // Re-implements the exact truncation from totp.ts's hotp() — duplicated
109
+ // deliberately: verifyTotp is the thing under test, so this must NOT call
110
+ // through it to derive its own expectation.
111
+ const counterBuf = Buffer.alloc(8);
112
+ counterBuf.writeUInt32BE(Math.floor(step / 2 ** 32), 0);
113
+ counterBuf.writeUInt32BE(step % 2 ** 32, 4);
114
+ const hmac = createHmac("sha1", secret).update(counterBuf).digest();
115
+ const offset = (hmac[hmac.length - 1] ?? 0) & 0x0f;
116
+ const truncated =
117
+ (((hmac[offset] ?? 0) & 0x7f) << 24) |
118
+ (((hmac[offset + 1] ?? 0) & 0xff) << 16) |
119
+ (((hmac[offset + 2] ?? 0) & 0xff) << 8) |
120
+ ((hmac[offset + 3] ?? 0) & 0xff);
121
+ return String(truncated % 10 ** 6).padStart(6, "0");
122
+ }
@@ -0,0 +1,45 @@
1
+ // RFC 4648 Base32 (no padding) — the encoding TOTP secrets and otpauth://
2
+ // URIs use. No external dependency: the alphabet lookup is ~20 lines and every
3
+ // authenticator app (Google/Microsoft/Authy) expects exactly this variant.
4
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
5
+
6
+ export function base32Encode(bytes: Buffer): string {
7
+ let bits = 0;
8
+ let value = 0;
9
+ let output = "";
10
+ for (const byte of bytes) {
11
+ value = (value << 8) | byte;
12
+ bits += 8;
13
+ while (bits >= 5) {
14
+ output += ALPHABET[(value >>> (bits - 5)) & 0x1f];
15
+ bits -= 5;
16
+ }
17
+ }
18
+ if (bits > 0) {
19
+ output += ALPHABET[(value << (5 - bits)) & 0x1f];
20
+ }
21
+ return output;
22
+ }
23
+
24
+ // Accepts lowercase and stray whitespace (authenticator apps and users
25
+ // copy-pasting secrets are inconsistent about casing) — normalizes case and
26
+ // strips whitespace before decoding, but throws on a genuinely invalid
27
+ // character so a corrupted stored secret fails loud at read time instead of
28
+ // silently producing wrong codes.
29
+ export function base32Decode(encoded: string): Buffer {
30
+ const clean = encoded.trim().toUpperCase().replace(/\s+/g, "");
31
+ let bits = 0;
32
+ let value = 0;
33
+ const bytes: number[] = [];
34
+ for (const char of clean) {
35
+ const index = ALPHABET.indexOf(char);
36
+ if (index === -1) throw new Error(`base32Decode: invalid character "${char}"`);
37
+ value = (value << 5) | index;
38
+ bits += 5;
39
+ if (bits >= 8) {
40
+ bytes.push((value >>> (bits - 8)) & 0xff);
41
+ bits -= 8;
42
+ }
43
+ }
44
+ return Buffer.from(bytes);
45
+ }
@@ -0,0 +1,21 @@
1
+ import { base32Encode } from "./base32";
2
+
3
+ // otpauth:// URI per Google Authenticator's key-uri-format (the de facto
4
+ // standard every authenticator app implements). The client renders this as
5
+ // a QR code (or lets the user type the secret manually) — the server never
6
+ // touches QR rendering.
7
+ export function buildOtpauthUri(opts: {
8
+ readonly issuer: string;
9
+ readonly accountLabel: string;
10
+ readonly secret: Buffer;
11
+ }): string {
12
+ const label = encodeURIComponent(`${opts.issuer}:${opts.accountLabel}`);
13
+ const params = new URLSearchParams({
14
+ secret: base32Encode(opts.secret),
15
+ issuer: opts.issuer,
16
+ algorithm: "SHA1",
17
+ digits: "6",
18
+ period: "30",
19
+ });
20
+ return `otpauth://totp/${label}?${params.toString()}`;
21
+ }
@@ -0,0 +1,47 @@
1
+ // RFC 6238 TOTP on top of node:crypto — no otplib/speakeasy dependency. The
2
+ // algorithm is ~20 lines of HMAC-SHA1 + dynamic truncation; pulling in a
3
+ // package for it would be the opposite of lazy.
4
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
5
+
6
+ const STEP_SECONDS = 30;
7
+ const DIGITS = 6;
8
+ const WINDOW = 1; // accept ±1 step (±30s) of clock drift, per the spec's guidance
9
+
10
+ export function generateTotpSecret(): Buffer {
11
+ return randomBytes(20); // 160 bits — RFC 4226's recommended HOTP secret length
12
+ }
13
+
14
+ function hotp(secret: Buffer, counter: number): string {
15
+ const counterBuf = Buffer.alloc(8);
16
+ counterBuf.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
17
+ counterBuf.writeUInt32BE(counter % 2 ** 32, 4);
18
+
19
+ const hmac = createHmac("sha1", secret).update(counterBuf).digest();
20
+ const offset = (hmac[hmac.length - 1] ?? 0) & 0x0f;
21
+ const truncated =
22
+ (((hmac[offset] ?? 0) & 0x7f) << 24) |
23
+ (((hmac[offset + 1] ?? 0) & 0xff) << 16) |
24
+ (((hmac[offset + 2] ?? 0) & 0xff) << 8) |
25
+ ((hmac[offset + 3] ?? 0) & 0xff);
26
+
27
+ return String(truncated % 10 ** DIGITS).padStart(DIGITS, "0");
28
+ }
29
+
30
+ function totpAt(secret: Buffer, epochSeconds: number): string {
31
+ return hotp(secret, Math.floor(epochSeconds / STEP_SECONDS));
32
+ }
33
+
34
+ // Timing-safe across the whole ±WINDOW check, not just the final compare —
35
+ // a TOTP code is the same sensitivity class as a password, brute-forceable
36
+ // over a public /auth/mfa/verify endpoint without it.
37
+ export function verifyTotp(secret: Buffer, code: string, nowMs: number = Date.now()): boolean {
38
+ if (!/^\d{6}$/.test(code)) return false;
39
+ const codeBuf = Buffer.from(code);
40
+ const nowSeconds = Math.floor(nowMs / 1000);
41
+ let matched = false;
42
+ for (let w = -WINDOW; w <= WINDOW; w++) {
43
+ const candidate = Buffer.from(totpAt(secret, nowSeconds + w * STEP_SECONDS));
44
+ if (timingSafeEqual(candidate, codeBuf)) matched = true;
45
+ }
46
+ return matched;
47
+ }
@@ -51,10 +51,10 @@ const statusEnum = z.enum([
51
51
  // payload — payload ist domain-clean, metadata ist provider-truth).
52
52
  export const subscriptionEventPayloadSchema = z.object({
53
53
  providerName: z.string().min(1).max(50),
54
- // 1000, not 200: these two are `encrypted: true` on the entity (see
55
- // entities.ts)process-event.write.ts stores the envelope-cipher
56
- // ciphertext here, not the raw provider id. A 200-char plaintext id
57
- // becomes ~460+ chars of JSON envelope; 1000 leaves headroom.
54
+ // 1000, not 200: these two are `tenantOwned: true` on the entity (see
55
+ // entities.ts — crypto-shreds on tenant-destroy, unlike `encrypted`).
56
+ // process-event.write.ts stores the PII-ciphertext here, not the raw
57
+ // provider id. A 200-char plaintext id becomes ~300+ chars of ciphertext.
58
58
  providerCustomerId: z.string().min(1).max(1000),
59
59
  providerSubscriptionId: z.string().min(1).max(1000),
60
60
  status: statusEnum,