@cosmicdrift/kumiko-samples 0.404.0 → 0.405.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 (156) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +25 -25
  3. package/packages/bundled-features/src/account-security/README.md +7 -5
  4. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  5. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  6. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
  7. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  8. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  11. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  12. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  13. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  14. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  15. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  16. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  17. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  18. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  19. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  20. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  23. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  24. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  26. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  27. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  28. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  29. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  30. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  31. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  32. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  33. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  34. package/packages/bundled-features/src/documents/changes.json +8 -0
  35. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  36. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  37. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  38. package/packages/bundled-features/src/folders/feature.ts +2 -8
  39. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  40. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  41. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  42. package/packages/bundled-features/src/host-routing/README.md +10 -10
  43. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  44. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  46. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  47. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  48. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  49. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  50. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  51. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  52. package/packages/bundled-features/src/legal-pages/README.md +21 -13
  53. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  54. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  55. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  56. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  57. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  58. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  59. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  60. package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
  61. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  62. package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
  63. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  64. package/packages/bundled-features/src/shared/changes.json +6 -0
  65. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  66. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  67. package/packages/bundled-features/src/shared/index.ts +6 -0
  68. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  69. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  70. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  71. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  72. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  73. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  74. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  75. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  76. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  77. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  78. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  79. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  80. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  81. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  82. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  83. package/packages/bundled-features/src/tags/feature.ts +2 -8
  84. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  85. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  86. package/packages/bundled-features/src/template-resolver/README.md +29 -29
  87. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  88. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  89. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
  90. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  91. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  92. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  93. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  94. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
  97. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  98. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
  99. package/packages/bundled-features/src/user-data-rights/README.md +33 -33
  100. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  101. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  102. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  103. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  104. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  105. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  106. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  107. package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
  108. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
  109. package/samples/apps/admin-console/README.md +5 -5
  110. package/samples/apps/admin-console/package.json +3 -3
  111. package/samples/apps/cap-billing-demo/README.md +15 -15
  112. package/samples/apps/config-demo/README.md +7 -7
  113. package/samples/apps/config-demo/package.json +2 -2
  114. package/samples/apps/marketing-demo/README.md +2 -2
  115. package/samples/apps/marketing-demo/package.json +2 -2
  116. package/samples/apps/showcase/package.json +3 -3
  117. package/samples/apps/styleguide/package.json +2 -2
  118. package/samples/apps/ui-walkthrough/README.md +8 -9
  119. package/samples/apps/ui-walkthrough/package.json +2 -2
  120. package/samples/apps/use-all-bundled/README.md +2 -2
  121. package/samples/apps/use-all-bundled/package.json +2 -2
  122. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
  123. package/samples/apps/user-data-rights-demo/README.md +12 -12
  124. package/samples/apps/workspaces/package.json +2 -2
  125. package/samples/recipes/apex-landing/README.md +3 -3
  126. package/samples/recipes/apex-landing/package.json +1 -1
  127. package/samples/recipes/apex-surface-auth/README.md +13 -9
  128. package/samples/recipes/auth-mfa/README.md +2 -4
  129. package/samples/recipes/auth-mfa/package.json +2 -2
  130. package/samples/recipes/boot-check/README.md +2 -2
  131. package/samples/recipes/compliance-profiles-demo/README.md +5 -5
  132. package/samples/recipes/compose-stacks/README.md +8 -8
  133. package/samples/recipes/cross-feature-events/README.md +1 -0
  134. package/samples/recipes/crypto-shredding-hr/README.md +3 -3
  135. package/samples/recipes/custom-fields-basic/README.md +1 -1
  136. package/samples/recipes/documents/README.md +25 -0
  137. package/samples/recipes/embedded-entity-form/package.json +2 -2
  138. package/samples/recipes/encrypted-tenant-config/README.md +8 -8
  139. package/samples/recipes/env-composition/README.md +10 -10
  140. package/samples/recipes/error-contract/README.md +9 -9
  141. package/samples/recipes/event-sourcing/README.md +9 -9
  142. package/samples/recipes/ledger-invoicing/README.md +6 -6
  143. package/samples/recipes/legal-pages/README.md +16 -16
  144. package/samples/recipes/managed-config/README.md +15 -11
  145. package/samples/recipes/managed-pages/README.md +7 -7
  146. package/samples/recipes/pdf-audit-export/package.json +1 -1
  147. package/samples/recipes/pipeline-basics/README.md +13 -13
  148. package/samples/recipes/public-share-token/README.md +4 -4
  149. package/samples/recipes/record-detail-layout/package.json +3 -3
  150. package/samples/recipes/security-baseline/README.md +4 -1
  151. package/samples/recipes/session-revocation/README.md +1 -1
  152. package/samples/recipes/tenant-settings/README.md +4 -4
  153. package/samples/recipes/tier-admin/README.md +2 -2
  154. package/samples/recipes/user-data-rights/README.md +21 -19
  155. package/samples/recipes/wizard-form/package.json +3 -3
  156. package/samples/recipes/writeform-section/package.json +3 -3
@@ -1,23 +1,21 @@
1
1
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
- import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec.ts";
3
+ import {
4
+ createHmacTokenCodec,
5
+ type HmacTokenVerifyResult,
6
+ isUserTenantPayload,
7
+ } from "./hmac-token-codec.ts";
5
8
 
6
9
  export type MfaChallengePayload = {
7
10
  readonly userId: string;
8
11
  readonly tenantId: TenantId;
9
12
  };
10
13
 
11
- function isPayload(value: unknown): value is MfaChallengePayload {
12
- if (!isPlainObject(value)) return false;
13
- return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
14
- }
15
-
16
14
  // Domain-separated from mfa-preauth-setup (see hmac-token-codec). Single-use is
17
15
  // enforced by the consumer via burnToken (purpose "mfa-challenge"); this codec
18
16
  // only signs and verifies. Brute-forcing the code behind a valid token is
19
17
  // limited separately in mfa-verify-attempts.ts.
20
- const codec = createHmacTokenCodec("mfa-challenge", isPayload);
18
+ const codec = createHmacTokenCodec("mfa-challenge", isUserTenantPayload);
21
19
 
22
20
  export function signMfaChallengeToken(
23
21
  payload: MfaChallengePayload,
@@ -1,22 +1,20 @@
1
1
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
- import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec.ts";
3
+ import {
4
+ createHmacTokenCodec,
5
+ type HmacTokenVerifyResult,
6
+ isUserTenantPayload,
7
+ } from "./hmac-token-codec.ts";
5
8
 
6
9
  export type MfaPreauthSetupPayload = {
7
10
  readonly userId: string;
8
11
  readonly tenantId: TenantId;
9
12
  };
10
13
 
11
- function isPayload(value: unknown): value is MfaPreauthSetupPayload {
12
- if (!isPlainObject(value)) return false;
13
- return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
14
- }
15
-
16
14
  // Domain "mfa-preauth-setup" must never equal the challenge domain: a setup
17
15
  // token has to fail verification as a challenge token and vice versa. The
18
16
  // challenge token secret is reused deliberately; the domain carries the separation.
19
- const codec = createHmacTokenCodec("mfa-preauth-setup", isPayload);
17
+ const codec = createHmacTokenCodec("mfa-preauth-setup", isUserTenantPayload);
20
18
 
21
19
  export function signMfaPreauthSetupToken(
22
20
  payload: MfaPreauthSetupPayload,
@@ -6,12 +6,10 @@
6
6
  // the same response — the token exists to avoid a second round-trip to
7
7
  // regenerate it, not to hide it.
8
8
  //
9
- // Format: <base64url(JSON body)>.<hmac-base64url>. Distinct from
10
- // signed-token.ts (userId-only payload) because setup needs to carry
11
- // generated secret material, not just re-derive it from a lookup.
12
- import { createHmac, timingSafeEqual } from "node:crypto";
9
+ // Wire format and signing live in hmac-token-codec.ts.
13
10
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
14
11
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
12
+ import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec.ts";
15
13
 
16
14
  export type MfaSetupPayload = {
17
15
  readonly userId: string;
@@ -24,73 +22,34 @@ export type MfaSetupPayload = {
24
22
  readonly tenantId?: string;
25
23
  };
26
24
 
27
- type EncodedBody = MfaSetupPayload & { readonly expiresAtMs: number };
28
-
29
- function sign(input: string, secret: string): string {
30
- return createHmac("sha256", secret).update(input).digest("base64url");
25
+ function isMfaSetupPayload(value: unknown): value is MfaSetupPayload {
26
+ if (!isPlainObject(value)) return false;
27
+ return (
28
+ typeof value["userId"] === "string" &&
29
+ typeof value["totpSecretBase32"] === "string" &&
30
+ Array.isArray(value["recoveryCodeHashes"]) &&
31
+ value["recoveryCodeHashes"].every((h) => typeof h === "string") &&
32
+ (value["tenantId"] === undefined || typeof value["tenantId"] === "string")
33
+ );
31
34
  }
32
35
 
36
+ const codec = createHmacTokenCodec("mfa-setup", isMfaSetupPayload);
37
+
33
38
  export function signMfaSetupToken(
34
39
  payload: MfaSetupPayload,
35
40
  ttlMinutes: number,
36
41
  secret: string,
37
42
  now: Temporal.Instant = Temporal.Now.instant(),
38
43
  ): { token: string; expiresAt: Temporal.Instant } {
39
- const expiresAt = now.add({ minutes: ttlMinutes });
40
- const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
41
- const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
42
- const signature = sign(`mfa-setup:${bodyB64}`, secret);
43
- return { token: `${bodyB64}.${signature}`, expiresAt };
44
+ return codec.sign(payload, ttlMinutes, secret, now);
44
45
  }
45
46
 
46
- export type VerifyMfaSetupResult =
47
- | { readonly ok: true; readonly payload: MfaSetupPayload; readonly expiresAtMs: number }
48
- | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
49
-
50
- function isEncodedBody(value: unknown): value is EncodedBody {
51
- if (!isPlainObject(value)) return false;
52
- return (
53
- typeof value["userId"] === "string" &&
54
- typeof value["totpSecretBase32"] === "string" &&
55
- Array.isArray(value["recoveryCodeHashes"]) &&
56
- value["recoveryCodeHashes"].every((h) => typeof h === "string") &&
57
- typeof value["expiresAtMs"] === "number" &&
58
- (value["tenantId"] === undefined || typeof value["tenantId"] === "string")
59
- );
60
- }
47
+ export type VerifyMfaSetupResult = HmacTokenVerifyResult<MfaSetupPayload>;
61
48
 
62
49
  export function verifyMfaSetupToken(
63
50
  token: string,
64
51
  secret: string,
65
52
  now: Temporal.Instant = Temporal.Now.instant(),
66
53
  ): VerifyMfaSetupResult {
67
- const parts = token.split(".");
68
- if (parts.length !== 2) return { ok: false, reason: "malformed" };
69
- const [bodyB64, providedSig] = parts;
70
- if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
71
-
72
- const expected = sign(`mfa-setup:${bodyB64}`, secret);
73
- const expectedBuf = Buffer.from(expected, "base64url");
74
- const providedBuf = Buffer.from(providedSig, "base64url");
75
- // Length check before timingSafeEqual — a length mismatch throws inside
76
- // timingSafeEqual, and that throw is itself a timing signal.
77
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
78
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
79
-
80
- let parsed: unknown;
81
- try {
82
- parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
83
- } catch {
84
- return { ok: false, reason: "malformed" };
85
- }
86
- if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
87
-
88
- if (
89
- Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
90
- ) {
91
- return { ok: false, reason: "expired" };
92
- }
93
-
94
- const { expiresAtMs, ...payload } = parsed;
95
- return { ok: true, payload, expiresAtMs };
54
+ return codec.verify(token, secret, now);
96
55
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.405.0",
4
+ "type": "improvement",
5
+ "title": "Provider helpers shared through billing-foundation",
6
+ "detail": "billing-foundation exports validateOneOffPriceIds, parseStoredSecret, createTtlPriceCache and hasSeenProviderEvent. subscription-stripe and subscription-paddle use them instead of their own copies, with the same error texts as before. The subscription projection reuses one apply body for created/updated and one for canceled/invoice-paid; the exported apply names stay."
7
+ },
2
8
  {
3
9
  "version": "0.399.0",
4
10
  "type": "improvement",
@@ -3,7 +3,7 @@
3
3
  // The anonymous handler only routes to the declaration's tenant; the system
4
4
  // handler checks the token and does the work.
5
5
 
6
- import { createHash, timingSafeEqual } from "node:crypto";
6
+ import { hashOpaqueTokenSync, opaqueTokenHashMatches } from "@cosmicdrift/kumiko-framework/crypto";
7
7
  import {
8
8
  access,
9
9
  createSystemUser,
@@ -45,17 +45,7 @@ export function createRevocationToken(): { readonly token: string; readonly toke
45
45
  const token = Buffer.from(crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))).toString(
46
46
  "base64url",
47
47
  );
48
- return { token, tokenHash: hashRevocationToken(token) };
49
- }
50
-
51
- function hashRevocationToken(token: string): string {
52
- return createHash("sha256").update(token).digest("hex");
53
- }
54
-
55
- function tokenMatchesHash(token: string, expectedHash: string): boolean {
56
- const actual = Buffer.from(hashRevocationToken(token));
57
- const expected = Buffer.from(expectedHash);
58
- return actual.length === expected.length && timingSafeEqual(actual, expected);
48
+ return { token, tokenHash: hashOpaqueTokenSync(token) };
59
49
  }
60
50
 
61
51
  export function revocationUrl(args: {
@@ -154,7 +144,7 @@ export function createRecordContractTerminationRevocationHandler(
154
144
  .find((candidate) => candidate?.requestId === payload.requestId);
155
145
  if (
156
146
  declared?.revocationTokenHash === undefined ||
157
- !tokenMatchesHash(payload.token, declared.revocationTokenHash)
147
+ !opaqueTokenHashMatches(payload.token, declared.revocationTokenHash)
158
148
  ) {
159
149
  return outcome("invalid");
160
150
  }
@@ -200,11 +200,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
200
200
  });
201
201
 
202
202
  // Plugin extension point. Provider plugins register here.
203
- r.extendsRegistrar(SUBSCRIPTION_PROVIDER_EXTENSION, {
204
- onRegister: () => {
205
- // No side-effects at register-time.
206
- },
207
- });
203
+ r.extendsRegistrar(SUBSCRIPTION_PROVIDER_EXTENSION, {});
208
204
 
209
205
  // Custom write handlers:
210
206
  // - process-event: programmatic entry point called by the webhook
@@ -33,6 +33,7 @@ import {
33
33
  type SubscriptionEventPayload,
34
34
  } from "../events.ts";
35
35
  import { getSubscriptionForTenant, type SubscriptionView } from "../get-subscription-for-tenant.ts";
36
+ import { hasSeenProviderEvent } from "../provider-event-seen.ts";
36
37
  import {
37
38
  BILLING_SUBSCRIPTION_LOCK_NAMESPACE,
38
39
  isSubscriptionBlockingCheckout,
@@ -176,14 +177,7 @@ export async function appendSubscriptionEvent(
176
177
  // idempotency anchor (like cap-counter).
177
178
  // ---------------------------------------------------------------
178
179
  const existingEvents = await ctx.events.load(aggId);
179
- const alreadySeen = existingEvents.some((e) => {
180
- const headers = e.metadata.headers ?? {};
181
- return (
182
- headers["providerEventId"] === payload.providerEventId &&
183
- headers["providerName"] === payload.providerName
184
- );
185
- });
186
- if (alreadySeen) {
180
+ if (hasSeenProviderEvent(existingEvents, payload)) {
187
181
  return { duplicate: true, subscriptionAggregateId: aggId };
188
182
  }
189
183
 
@@ -24,6 +24,7 @@ import {
24
24
  type PaymentEventHeaders,
25
25
  type PaymentEventPayload,
26
26
  } from "../events.ts";
27
+ import { hasSeenProviderEvent } from "../provider-event-seen.ts";
27
28
 
28
29
  export const processPaymentEventSchema = z.object({
29
30
  providerEventId: z.string().min(1).max(200),
@@ -57,14 +58,7 @@ export const processPaymentEventHandler: WriteHandlerDef = {
57
58
  // optimize via a per-tenant providerEventId-index if that bites).
58
59
  // ---------------------------------------------------------------
59
60
  const existingEvents = await ctx.events.load(aggId);
60
- const alreadySeen = existingEvents.some((e) => {
61
- const headers = e.metadata.headers ?? {};
62
- return (
63
- headers["providerEventId"] === payload.providerEventId &&
64
- headers["providerName"] === payload.providerName
65
- );
66
- });
67
- if (alreadySeen) {
61
+ if (hasSeenProviderEvent(existingEvents, payload)) {
68
62
  return {
69
63
  isSuccess: true as const,
70
64
  data: { duplicate: true as const, paymentAggregateId: aggId },
@@ -46,6 +46,14 @@ export {
46
46
  export { createBillingFoundationFeature } from "./feature.ts";
47
47
  export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant.ts";
48
48
  export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection.ts";
49
+ export { hasSeenProviderEvent } from "./provider-event-seen.ts";
50
+ export {
51
+ createTtlPriceCache,
52
+ parseStoredSecret,
53
+ type TtlPriceCache,
54
+ type TtlPriceCacheOptions,
55
+ validateOneOffPriceIds,
56
+ } from "./provider-shared.ts";
49
57
  export { billingPlansPanel, createBillingPlansScreen } from "./screens.ts";
50
58
  export {
51
59
  createSubscriptionTierSync,
@@ -130,43 +130,12 @@ async function upsert(
130
130
  // materialize the row correctly.
131
131
  // =============================================================================
132
132
 
133
- /** subscription-created → UPSERT with all fields. PK = aggregateId =
134
- * subscriptionAggregateId(tenantId), with one row per tenant. UPSERT allows
135
- * the Disney+-change pattern (a second provider sends create for the same
136
- * tenant) to overwrite the existing row instead of causing a PK conflict. */
137
- export const applySubscriptionCreated = defineApply<SubscriptionEventPayload>(async (event, tx) => {
138
- const full = fullSetFromPayload(event.payload);
139
- await upsert(tx, event, { ...full, ...cancelAtSetFromPayload(event.payload) }, event.payload);
140
- });
141
-
142
- /** subscription-updated → UPSERT with all fields. */
143
- export const applySubscriptionUpdated = defineApply<SubscriptionEventPayload>(async (event, tx) => {
133
+ const applyFullSubscriptionState = defineApply<SubscriptionEventPayload>(async (event, tx) => {
144
134
  const full = fullSetFromPayload(event.payload);
145
135
  await upsert(tx, event, { ...full, ...cancelAtSetFromPayload(event.payload) }, event.payload);
146
136
  });
147
137
 
148
- /** subscription-canceled → status/tier/currentPeriodEnd patchen. */
149
- export const applySubscriptionCanceled = defineApply<SubscriptionEventPayload>(
150
- async (event, tx) => {
151
- const p = event.payload;
152
- await upsert(
153
- tx,
154
- event,
155
- {
156
- status: p.status,
157
- tier: p.tier,
158
- currentPeriodEnd: p.currentPeriodEndIso,
159
- ...cancelAtSetFromPayload(p),
160
- },
161
- p,
162
- );
163
- },
164
- );
165
-
166
- /** invoice-paid → state update (status, tier, currentPeriodEnd).
167
- * The invoice history itself lives in the event store, so it remains
168
- * replayable. */
169
- export const applyInvoicePaid = defineApply<SubscriptionEventPayload>(async (event, tx) => {
138
+ const applyStatusTierPeriodPatch = defineApply<SubscriptionEventPayload>(async (event, tx) => {
170
139
  const p = event.payload;
171
140
  await upsert(
172
141
  tx,
@@ -181,6 +150,23 @@ export const applyInvoicePaid = defineApply<SubscriptionEventPayload>(async (eve
181
150
  );
182
151
  });
183
152
 
153
+ /** subscription-created → UPSERT with all fields. PK = aggregateId =
154
+ * subscriptionAggregateId(tenantId), with one row per tenant. UPSERT allows
155
+ * the Disney+-change pattern (a second provider sends create for the same
156
+ * tenant) to overwrite the existing row instead of causing a PK conflict. */
157
+ export const applySubscriptionCreated = applyFullSubscriptionState;
158
+
159
+ /** subscription-updated → UPSERT with all fields. */
160
+ export const applySubscriptionUpdated = applyFullSubscriptionState;
161
+
162
+ /** subscription-canceled → status/tier/currentPeriodEnd patchen. */
163
+ export const applySubscriptionCanceled = applyStatusTierPeriodPatch;
164
+
165
+ /** invoice-paid → state update (status, tier, currentPeriodEnd).
166
+ * The invoice history itself lives in the event store, so it remains
167
+ * replayable. */
168
+ export const applyInvoicePaid = applyStatusTierPeriodPatch;
169
+
184
170
  /** invoice-payment-failed → status (typically past_due) + tier. The tier
185
171
  * engine reads the row and decides whether to downgrade. currentPeriodEnd is
186
172
  * intentionally unchanged: the period has not "ended"; payment simply
@@ -0,0 +1,14 @@
1
+ import type { StoredEvent } from "@cosmicdrift/kumiko-framework/event-store";
2
+
3
+ export function hasSeenProviderEvent(
4
+ existingEvents: readonly Pick<StoredEvent, "metadata">[],
5
+ providerEvent: { readonly providerEventId: string; readonly providerName: string },
6
+ ): boolean {
7
+ return existingEvents.some((e) => {
8
+ const headers = e.metadata.headers ?? {};
9
+ return (
10
+ headers["providerEventId"] === providerEvent.providerEventId &&
11
+ headers["providerName"] === providerEvent.providerName
12
+ );
13
+ });
14
+ }
@@ -0,0 +1,61 @@
1
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
2
+
3
+ export function validateOneOffPriceIds(providerLabel: string, priceIds: readonly string[]): void {
4
+ const seen = new Set<string>();
5
+ for (const priceId of priceIds) {
6
+ if (priceId.length === 0) {
7
+ throw new Error(`${providerLabel}: oneOffPriceIds must not contain an empty string`);
8
+ }
9
+ if (seen.has(priceId)) {
10
+ throw new Error(`${providerLabel}: duplicate oneOffPriceIds entry "${priceId}"`);
11
+ }
12
+ seen.add(priceId);
13
+ }
14
+ }
15
+
16
+ // backing:"secrets" values are stored JSON-serialized, so the store read has to
17
+ // be parsed back; the env fallback is a plain string and stays raw. The error
18
+ // must not carry the input: the JSON parser's own message would repeat the
19
+ // credential into logs and, for webhooks, into the 401 body.
20
+ export function parseStoredSecret(
21
+ providerLabel: string,
22
+ raw: string | undefined,
23
+ ): string | undefined {
24
+ if (raw === undefined || raw.length === 0) return undefined;
25
+ const parsed = parseJsonSafe<unknown>(raw, undefined);
26
+ if (parsed === undefined) {
27
+ throw new Error(`${providerLabel} credential (backing:secrets) is not valid JSON`);
28
+ }
29
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
30
+ }
31
+
32
+ export type TtlPriceCache<TPrice> = {
33
+ readonly get: (priceId: string) => TPrice | undefined;
34
+ readonly set: (priceId: string, price: TPrice) => void;
35
+ };
36
+
37
+ export type TtlPriceCacheOptions = {
38
+ readonly ttlMs?: number;
39
+ readonly now: () => number;
40
+ };
41
+
42
+ export function createTtlPriceCache<TPrice>({
43
+ ttlMs = 600_000,
44
+ now,
45
+ }: TtlPriceCacheOptions): TtlPriceCache<TPrice> {
46
+ const store = new Map<string, { readonly price: TPrice; readonly expiresAt: number }>();
47
+ return {
48
+ get: (priceId) => {
49
+ const entry = store.get(priceId);
50
+ if (!entry) return undefined;
51
+ if (entry.expiresAt <= now()) {
52
+ store.delete(priceId);
53
+ return undefined;
54
+ }
55
+ return entry.price;
56
+ },
57
+ set: (priceId, price) => {
58
+ store.set(priceId, { price, expiresAt: now() + ttlMs });
59
+ },
60
+ };
61
+ }
@@ -1,6 +1,10 @@
1
1
  import { declareEscapeHatch, type TenantDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
3
3
  import { resolveProfileForTenant } from "../compliance-profiles/index.ts";
4
+ import {
5
+ TENANT_DESTROY_ARCHIVE_REASON,
6
+ TENANT_DESTROY_ARCHIVED_BY,
7
+ } from "../tenant-lifecycle/index.ts";
4
8
  import { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.ts";
5
9
  import { PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE } from "./events.ts";
6
10
  import { paymentsProjectionMeta, subscriptionsProjectionMeta } from "./projection.ts";
@@ -42,8 +46,8 @@ export async function subscriptionTenantDestroyHook(ctx: TenantDataHookCtx): Pro
42
46
  tenantId: ctx.tenantId,
43
47
  aggregateId,
44
48
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
45
- archivedBy: "tenant-lifecycle:destroy",
46
- reason: "tenant_destroy",
49
+ archivedBy: TENANT_DESTROY_ARCHIVED_BY,
50
+ reason: TENANT_DESTROY_ARCHIVE_REASON,
47
51
  });
48
52
  }
49
53
 
@@ -74,7 +78,7 @@ export async function paymentTenantDestroyHook(ctx: TenantDataHookCtx): Promise<
74
78
  tenantId: ctx.tenantId,
75
79
  aggregateId,
76
80
  aggregateType: PAYMENT_AGGREGATE_TYPE,
77
- archivedBy: "tenant-lifecycle:destroy",
78
- reason: "tenant_destroy",
81
+ archivedBy: TENANT_DESTROY_ARCHIVED_BY,
82
+ reason: TENANT_DESTROY_ARCHIVE_REASON,
79
83
  });
80
84
  }
@@ -25,7 +25,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions) {
25
25
  r.envSchema(smtpEnvSchema);
26
26
 
27
27
  r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
28
- r.extendsRegistrar(EMAIL_APP_TRANSPORT_EXTENSION, { onRegister: () => {} });
28
+ r.extendsRegistrar(EMAIL_APP_TRANSPORT_EXTENSION, {});
29
29
  r.useExtension(EMAIL_APP_TRANSPORT_EXTENSION, EMAIL_APP_TRANSPORT_USAGE, {
30
30
  transport: appTransport,
31
31
  });
@@ -28,7 +28,6 @@ import {
28
28
  createSystemDbView,
29
29
  createTenantDb,
30
30
  type DbConnection,
31
- type TenantDb,
32
31
  type UncheckedSystemDb,
33
32
  } from "@cosmicdrift/kumiko-framework/db";
34
33
  import {
@@ -43,6 +42,7 @@ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
43
42
  import {
44
43
  type ChunkedMigrationStopReason,
45
44
  classifyStoredEnvelope,
45
+ createTenantDbCache,
46
46
  runChunkedMigration,
47
47
  } from "../../shared/index.ts";
48
48
  import { configValueEntity, configValuesTable } from "../table.ts";
@@ -121,20 +121,14 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
121
121
  });
122
122
  }
123
123
 
124
- const tdbCache = new Map<TenantId, TenantDb>();
125
- function tdbFor(tenantId: TenantId): TenantDb {
126
- let existing = tdbCache.get(tenantId);
127
- if (!existing) {
128
- existing = createTenantDb({
129
- db,
130
- tenantId,
131
- mode: "system",
132
- reason: "KEK rotation job re-encrypts config values of every tenant",
133
- });
134
- tdbCache.set(tenantId, existing);
135
- }
136
- return existing;
137
- }
124
+ const tdbFor = createTenantDbCache((tenantId) =>
125
+ createTenantDb({
126
+ db,
127
+ tenantId,
128
+ mode: "system",
129
+ reason: "KEK rotation job re-encrypts config values of every tenant",
130
+ }),
131
+ );
138
132
 
139
133
  const sdbCache = new Map<TenantId, UncheckedSystemDb>();
140
134
  function sdbFor(tenantId: TenantId): UncheckedSystemDb {
@@ -33,7 +33,7 @@ import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framewo
33
33
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
34
34
  import type { Redis } from "ioredis";
35
35
  import { fileContextFromResolver } from "../file-foundation/index.ts";
36
- import { redactErrorText } from "../shared/index.ts";
36
+ import { collectExtensionOptions, redactErrorText } from "../shared/index.ts";
37
37
  import { hashUnsubscribeAddress } from "./address-opt-out.ts";
38
38
  import { appendAttemptEvent, logAttempt } from "./attempt-log.ts";
39
39
  import { buildChannelContext } from "./channel-context.ts";
@@ -120,15 +120,7 @@ export type DeliveryServiceOptions = {
120
120
 
121
121
  // Build channel list from registry extension usages
122
122
  export function collectChannels(registry: Registry): DeliveryChannel[] {
123
- const usages = registry.getExtensionUsages(DELIVERY_CHANNEL_EXTENSION);
124
- return usages.map((usage) => {
125
- if (!isDeliveryChannelPlugin(usage.options)) {
126
- throw new Error(
127
- `${DELIVERY_CHANNEL_EXTENSION} registration for "${usage.entityName}" has invalid options`,
128
- );
129
- }
130
- return { name: usage.entityName, ...usage.options };
131
- });
123
+ return collectExtensionOptions(registry, DELIVERY_CHANNEL_EXTENSION, isDeliveryChannelPlugin);
132
124
  }
133
125
 
134
126
  export function createDeliveryService(options: DeliveryServiceOptions): DeliveryService {
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.404.1",
4
+ "type": "fix",
5
+ "title": "Documents recipe has a README and gen-sample-index rejects a primarySample without one",
6
+ "detail": "feature-reference linked to /en/samples/recipes-documents/, a page the docs site never generated because the recipe had no README.md. The README is added, and gen-sample-index aborts with the feature, the sample and the fix when a primarySample from sample-index.overrides.json has no README.md."
7
+ }
8
+ ]
@@ -120,27 +120,15 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
120
120
  // time variant() runs.
121
121
  r.requires({ features: ["files"] });
122
122
 
123
- r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
124
- onRegister: () => {
125
- // No side-effects at register-time — resolution (exact MIME match,
126
- // then `<type>/*` wildcard) happens at request-time in
127
- // resolveRenderer, mirrors file-foundation's fileProvider point.
128
- },
129
- });
130
- r.extendsRegistrar(EXT_DERIVATIVE_PUBLIC_PREDICATE, {
131
- onRegister: () => {
132
- // No side-effects at register-time — resolution happens at
133
- // request-time in publicVariantQuery, keyed by the FileRef's
134
- // entityType.
135
- },
136
- });
137
- r.extendsRegistrar(EXT_DERIVATIVE_OVERLAY_RESOLVER, {
138
- onRegister: () => {
139
- // No side-effects at register-time — resolution happens in
140
- // variant(), keyed by the FileRef's entityType, before the spec
141
- // hash is computed.
142
- },
143
- });
123
+ // Resolution (exact MIME match, then `<type>/*` wildcard) happens at request-time in
124
+ // resolveRenderer, mirrors file-foundation's fileProvider point.
125
+ r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {});
126
+ // Resolution happens at request-time in publicVariantQuery, keyed by the FileRef's
127
+ // entityType.
128
+ r.extendsRegistrar(EXT_DERIVATIVE_PUBLIC_PREDICATE, {});
129
+ // Resolution happens in variant(), keyed by the FileRef's entityType, before the spec
130
+ // hash is computed.
131
+ r.extendsRegistrar(EXT_DERIVATIVE_OVERLAY_RESOLVER, {});
144
132
 
145
133
  // Registered ONLY when resolveApexTenant is supplied — r.queryHandler
146
134
  // registers publicVariantQuery into the feature's dispatch table as a
@@ -54,12 +54,8 @@ export function createFileFoundationFeature() {
54
54
  });
55
55
  r.requires({ features: ["config"] });
56
56
 
57
- r.extendsRegistrar(EXT_FILE_PROVIDER, {
58
- onRegister: () => {
59
- // No side-effects at register-time — registry stores the usage,
60
- // factory looks it up at request-time.
61
- },
62
- });
57
+ // Registry stores the usage, factory looks it up at request-time.
58
+ r.extendsRegistrar(EXT_FILE_PROVIDER, {});
63
59
 
64
60
  const providerConfigKey = r.config(
65
61
  "provider",
@@ -62,6 +62,7 @@ import {
62
62
  storageKeyStemPrefix,
63
63
  tenantStoragePrefixes,
64
64
  } from "@cosmicdrift/kumiko-framework/files";
65
+ import { chunk } from "@cosmicdrift/kumiko-framework/utils";
65
66
  import { type ChunkedMigrationResult, runChunkedMigration } from "../shared/index.ts";
66
67
 
67
68
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
@@ -73,12 +74,6 @@ const SURVIVOR_CHECK_BATCH_SIZE = 500;
73
74
 
74
75
  const DESTROY_CHUNK_SIZE = 500;
75
76
 
76
- function chunk<T>(items: readonly T[], size: number): T[][] {
77
- const chunks: T[][] = [];
78
- for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
79
- return chunks;
80
- }
81
-
82
77
  function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): boolean {
83
78
  return prefixes.some((prefix) => storageKey.startsWith(prefix));
84
79
  }