@cosmicdrift/kumiko-samples 0.404.1 → 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 (93) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +10 -10
  3. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  4. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  5. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  6. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  7. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  8. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  9. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  11. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  12. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  13. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  14. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  15. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  16. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  17. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  18. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  19. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  21. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  22. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  23. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  24. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  25. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  26. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  27. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  28. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  29. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  30. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  31. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  32. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  33. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  34. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  35. package/packages/bundled-features/src/folders/feature.ts +2 -8
  36. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  37. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  38. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  39. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  40. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  41. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  42. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  43. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  44. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  45. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  46. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  47. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  48. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  49. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  50. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  51. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  52. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  53. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  54. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  55. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  56. package/packages/bundled-features/src/shared/changes.json +6 -0
  57. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  58. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  59. package/packages/bundled-features/src/shared/index.ts +6 -0
  60. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  61. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  62. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  63. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  64. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  65. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  66. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  67. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  68. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  69. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  70. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  71. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  72. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  73. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  74. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  75. package/packages/bundled-features/src/tags/feature.ts +2 -8
  76. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  77. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  78. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  79. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  80. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  81. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  82. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  83. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  84. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  85. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  86. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  87. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  88. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  89. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  90. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  91. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  92. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  93. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
@@ -4,6 +4,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { isExpiredAt } from "./expiry.ts";
6
6
  import { hashPatToken } from "./hash.ts";
7
+ import { parseScopeNames } from "./parse-scope-names.ts";
7
8
  import { resolvePatRoles } from "./roles.ts";
8
9
  import { apiTokenTable } from "./schema/api-token.ts";
9
10
  import { expandScopes, type PatScopeConfig } from "./web/scopes.ts";
@@ -42,12 +43,3 @@ export function createPatResolver(opts: {
42
43
  };
43
44
  };
44
45
  }
45
-
46
- function parseScopeNames(raw: string): string[] {
47
- try {
48
- const parsed: unknown = JSON.parse(raw);
49
- return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
50
- } catch {
51
- return [];
52
- }
53
- }
@@ -1,4 +1,5 @@
1
1
  import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { collectExtensionOptions } from "../shared/index.ts";
2
3
  import { RENDERER_EXTENSION } from "./constants.ts";
3
4
  import { isRendererRegistrationPlugin, type RendererPlugin } from "./types.ts";
4
5
 
@@ -22,13 +23,7 @@ export function createRendererFoundationFeature() {
22
23
  });
23
24
  r.requires({ features: ["template-resolver"] });
24
25
 
25
- r.extendsRegistrar(RENDERER_EXTENSION, {
26
- onRegister: () => {
27
- // Plugin conformance check could go here: validate the shape of
28
- // options (kinds, render function present). No shape check currently —
29
- // the caller's TypeScript type safety applies.
30
- },
31
- });
26
+ r.extendsRegistrar(RENDERER_EXTENSION, {});
32
27
 
33
28
  return {};
34
29
  });
@@ -40,13 +35,5 @@ export function createRendererFoundationFeature() {
40
35
  //
41
36
  // Symmetric to collectChannels / collectRenderers in delivery-service.
42
37
  export function collectRendererPlugins(registry: Registry): RendererPlugin[] {
43
- const usages = registry.getExtensionUsages(RENDERER_EXTENSION);
44
- return usages.map((usage) => {
45
- if (!isRendererRegistrationPlugin(usage.options)) {
46
- throw new Error(
47
- `${RENDERER_EXTENSION} registration for "${usage.entityName}" has invalid options`,
48
- );
49
- }
50
- return { name: usage.entityName, ...usage.options };
51
- });
38
+ return collectExtensionOptions(registry, RENDERER_EXTENSION, isRendererRegistrationPlugin);
52
39
  }
@@ -22,7 +22,6 @@ import {
22
22
  createTenantDb,
23
23
  type DbConnection,
24
24
  selectMany,
25
- type TenantDb,
26
25
  } from "@cosmicdrift/kumiko-framework/db";
27
26
  import type { JobContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
28
27
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
@@ -31,7 +30,11 @@ import {
31
30
  encodeStoredEnvelope,
32
31
  rewrapDek,
33
32
  } from "@cosmicdrift/kumiko-framework/secrets";
34
- import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared/index.ts";
33
+ import {
34
+ type ChunkedMigrationStopReason,
35
+ createTenantDbCache,
36
+ runChunkedMigration,
37
+ } from "../../shared/index.ts";
35
38
  import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table.ts";
36
39
 
37
40
  const DEFAULT_BATCH_SIZE = 100;
@@ -74,23 +77,14 @@ export async function rotateJob(
74
77
  ? Date.now() + payload.maxDurationMs
75
78
  : Number.POSITIVE_INFINITY;
76
79
 
77
- // Reuse a TenantDb-per-tenant map so we don't rebuild the wrapper for
78
- // each row in the same tenant. Rotation typically hits one tenant in a
79
- // batch; the map trims an allocation without adding complexity.
80
- const tdbCache = new Map<TenantId, TenantDb>();
81
- function tdbFor(tenantId: TenantId): TenantDb {
82
- let existing = tdbCache.get(tenantId);
83
- if (!existing) {
84
- existing = createTenantDb({
85
- db,
86
- tenantId,
87
- mode: "system",
88
- reason: "secret key rotation job re-encrypts secrets of every tenant",
89
- });
90
- tdbCache.set(tenantId, existing);
91
- }
92
- return existing;
93
- }
80
+ const tdbFor = createTenantDbCache((tenantId) =>
81
+ createTenantDb({
82
+ db,
83
+ tenantId,
84
+ mode: "system",
85
+ reason: "secret key rotation job re-encrypts secrets of every tenant",
86
+ }),
87
+ );
94
88
 
95
89
  type SecretRow = {
96
90
  id: string;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.405.0",
4
+ "type": "improvement",
5
+ "title": "Bundled features share internal helpers",
6
+ "detail": "Access resolution for folders, ledger, notes-history and tags, the folders/tags write hook, the system-mode TenantDb cache of the re-encryption and rotation jobs, the mail locale templates of auth-email-password and user-data-rights, extension option collection for delivery and renderers, PAT scope parsing, user-data-rights hook DBs, unique-constraint failure checks, tenant member PII decryption, the status banner, the invite-accept request, the tenant label and the jobs subject parser each live in one internal module now. Empty onRegister callbacks are dropped. No behavior change."
7
+ },
2
8
  {
3
9
  "version": "0.364.0",
4
10
  "type": "improvement",
@@ -0,0 +1,14 @@
1
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export function collectExtensionOptions<TOptions extends object>(
4
+ registry: Registry,
5
+ extension: string,
6
+ isOptions: (candidate: unknown) => candidate is TOptions,
7
+ ): Array<{ name: string } & TOptions> {
8
+ return registry.getExtensionUsages(extension).map((usage) => {
9
+ if (!isOptions(usage.options)) {
10
+ throw new Error(`${extension} registration for "${usage.entityName}" has invalid options`);
11
+ }
12
+ return { name: usage.entityName, ...usage.options };
13
+ });
14
+ }
@@ -0,0 +1,15 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ export function signHmacSha256Base64Url(input: string, secret: string): string {
4
+ return createHmac("sha256", secret).update(input).digest("base64url");
5
+ }
6
+
7
+ export function hmacSignatureMatches(
8
+ expectedBase64Url: string,
9
+ providedBase64Url: string,
10
+ ): boolean {
11
+ const expectedBuf = Buffer.from(expectedBase64Url, "base64url");
12
+ const providedBuf = Buffer.from(providedBase64Url, "base64url");
13
+ // timingSafeEqual throws on a length mismatch, and the throw itself is a timing signal.
14
+ return expectedBuf.length === providedBuf.length && timingSafeEqual(expectedBuf, providedBuf);
15
+ }
@@ -15,13 +15,17 @@ export {
15
15
  classifyStoredEnvelope,
16
16
  type StoredEnvelopeClassification,
17
17
  } from "./classify-stored-envelope.ts";
18
+ export { collectExtensionOptions } from "./collect-extension-options.ts";
18
19
  export { decryptStoredPii, decryptStoredUserEmail } from "./decrypt-stored-pii.ts";
19
20
  export { encryptForDirectWrite } from "./encrypt-for-direct-write.ts";
20
21
  export { entitiesOf } from "./entities-of.ts";
21
22
  export { isWithinGracePeriod } from "./grace-period.ts";
23
+ export { hmacSignatureMatches, signHmacSha256Base64Url } from "./hmac-signature.ts";
22
24
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.ts";
23
25
  export { isTenantDb } from "./is-tenant-db.ts";
26
+ export { isUniqueConstraintFailure } from "./is-unique-constraint-failure.ts";
24
27
  export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.ts";
28
+ export { createMailLocaleTemplates } from "./mail-locale-templates.ts";
25
29
  export {
26
30
  mailHeading,
27
31
  mailParagraph,
@@ -59,6 +63,7 @@ export {
59
63
  } from "./redact.ts";
60
64
  export { REDACTED_PII_PLACEHOLDER, redactPiiCiphertext } from "./redact-pii-ciphertext.ts";
61
65
  export { requireForTenant } from "./require-for-tenant.ts";
66
+ export { resolveFeatureAccess } from "./resolve-feature-access.ts";
62
67
  export {
63
68
  extensionForResourceContentType,
64
69
  isResourceImageContentType,
@@ -108,4 +113,5 @@ export {
108
113
  stepFailures,
109
114
  } from "./stream-tenant-derived-state.ts";
110
115
  export type { SystemQueryFn } from "./system-query.ts";
116
+ export { createTenantDbCache } from "./tenant-db-cache.ts";
111
117
  export { type BurnResult, burnToken, unburnToken } from "./token-burn-store.ts";
@@ -0,0 +1,11 @@
1
+ import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
3
+
4
+ // The executor reports a unique-index race as a WriteFailure(unique_violation)
5
+ // whose details name the violated constraint.
6
+ export function isUniqueConstraintFailure(failure: WriteFailure, constraint: string): boolean {
7
+ return (
8
+ propertyOf(failure.error, "code") === "unique_violation" &&
9
+ propertyOf(propertyOf(failure.error, "details"), "constraintName") === constraint
10
+ );
11
+ }
@@ -0,0 +1,64 @@
1
+ import type { NotificationLocalizedTemplate } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ hasMailTranslationKey,
4
+ listMailLocales,
5
+ mailT,
6
+ resolveMailLocale,
7
+ } from "@cosmicdrift/kumiko-framework/i18n";
8
+
9
+ type MailLocaleTemplateConfig<Kind extends string> = {
10
+ readonly kinds: readonly Kind[];
11
+ readonly notificationTypes: Readonly<Record<Kind, string>>;
12
+ readonly subjectKey: (kind: Kind) => string;
13
+ readonly bodyFor: (kind: Kind, locale: string) => string;
14
+ };
15
+
16
+ export function createMailLocaleTemplates<Kind extends string>(
17
+ config: MailLocaleTemplateConfig<Kind>,
18
+ ): {
19
+ templateLocale: (notificationType: string, requestedLocale: string) => string;
20
+ localizedTemplate: (kind: Kind) => () => NotificationLocalizedTemplate;
21
+ } {
22
+ const subjectKeyByType: ReadonlyMap<string, string> = new Map(
23
+ config.kinds.map((kind) => [config.notificationTypes[kind], config.subjectKey(kind)]),
24
+ );
25
+
26
+ // A registered locale whose bundle lacks the subject key has no template (see
27
+ // localizedTemplate); the renderer has no locale fallback, so the sender must
28
+ // pick a locale that has one or the mail would not go out at all.
29
+ const templateLocale = (notificationType: string, requestedLocale: string): string => {
30
+ const locale = resolveMailLocale(requestedLocale);
31
+ const key = subjectKeyByType.get(notificationType);
32
+ return key !== undefined && hasMailTranslationKey(locale, key) ? locale : "en";
33
+ };
34
+
35
+ // Evaluated when the registry is built, after every locale feature has
36
+ // registered its mail translations. English always ships; another locale only
37
+ // gets a template when it has its own subject, so a missing translation never
38
+ // silently becomes an English mail in that locale's slot.
39
+ const localizedTemplate = (kind: Kind): (() => NotificationLocalizedTemplate) => {
40
+ const kindSubjectKey = config.subjectKey(kind);
41
+ return () => {
42
+ const locales = [
43
+ "en",
44
+ ...listMailLocales().filter(
45
+ (locale) => locale !== "en" && hasMailTranslationKey(locale, kindSubjectKey),
46
+ ),
47
+ ];
48
+ return Object.fromEntries(
49
+ locales.map((locale) => [
50
+ locale,
51
+ {
52
+ body: config.bodyFor(kind, locale),
53
+ meta: {
54
+ subject: mailT(locale, kindSubjectKey, { app: "{{appName}}" }),
55
+ category: "transactional" as const,
56
+ },
57
+ },
58
+ ]),
59
+ );
60
+ };
61
+ };
62
+
63
+ return { templateLocale, localizedTemplate };
64
+ }
@@ -0,0 +1,16 @@
1
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ type FeatureAccessOptions = {
4
+ readonly access?: AccessRule;
5
+ readonly roles?: readonly string[];
6
+ };
7
+
8
+ // `access` wins over the `roles` shorthand; neither set falls back to the feature default.
9
+ export function resolveFeatureAccess(
10
+ opts: FeatureAccessOptions,
11
+ defaultAccess: AccessRule,
12
+ ): AccessRule {
13
+ if (opts.access !== undefined) return opts.access;
14
+ if (opts.roles !== undefined) return { roles: opts.roles };
15
+ return defaultAccess;
16
+ }
@@ -12,17 +12,13 @@
12
12
  // Timing-safe comparison on verify so a valid-length forgery can't leak
13
13
  // signal through a short-circuit.
14
14
 
15
- import { createHmac, timingSafeEqual } from "node:crypto";
16
15
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
16
+ import { hmacSignatureMatches, signHmacSha256Base64Url } from "./hmac-signature.ts";
17
17
 
18
18
  export type VerifyResult =
19
19
  | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
20
20
  | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
21
21
 
22
- function sign(input: string, secret: string): string {
23
- return createHmac("sha256", secret).update(input).digest("base64url");
24
- }
25
-
26
22
  function payload(purpose: string, userId: string, expiresAtMs: number): string {
27
23
  return `${purpose}:${userId}.${expiresAtMs}`;
28
24
  }
@@ -36,7 +32,7 @@ export function signToken(
36
32
  ): { token: string; expiresAt: Temporal.Instant } {
37
33
  const expiresAt = now.add({ minutes: ttlMinutes });
38
34
  const expiresAtMs = expiresAt.epochMilliseconds;
39
- const signature = sign(payload(purpose, userId, expiresAtMs), secret);
35
+ const signature = signHmacSha256Base64Url(payload(purpose, userId, expiresAtMs), secret);
40
36
  return {
41
37
  token: `${userId}.${expiresAtMs}.${signature}`,
42
38
  expiresAt,
@@ -74,14 +70,8 @@ export function verifyToken(
74
70
  return { ok: false, reason: "malformed" };
75
71
  }
76
72
 
77
- const expected = sign(payload(purpose, userId, expiresAtMs), secret);
78
- const expectedBuf = Buffer.from(expected, "base64url");
79
- const providedBuf = Buffer.from(providedSig, "base64url");
80
- // Length mismatch fails BEFORE timingSafeEqual, which throws on different
81
- // lengths — but that throw itself leaks via timing. Explicit length check
82
- // keeps the path uniform.
83
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
84
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
73
+ const expected = signHmacSha256Base64Url(payload(purpose, userId, expiresAtMs), secret);
74
+ if (!hmacSignatureMatches(expected, providedSig)) return { ok: false, reason: "bad_signature" };
85
75
 
86
76
  if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
87
77
  return { ok: false, reason: "expired" };
@@ -38,14 +38,14 @@
38
38
  // permanently tax Redis, long enough to catch replays inside any realistic
39
39
  // race window).
40
40
 
41
- import { createHash } from "node:crypto";
41
+ import { hashOpaqueTokenSync } from "@cosmicdrift/kumiko-framework/crypto";
42
42
  import type { Redis } from "ioredis";
43
43
 
44
44
  // Exported so a caller that needs a byte-compatible key in a sibling Redis
45
45
  // namespace (e.g. a token↔binding side-mapping) can derive it without
46
46
  // re-implementing sha256-of-token — see auth-email-password/signup-token-store.ts.
47
47
  export function hashSingleUseToken(token: string): string {
48
- return createHash("sha256").update(token).digest("hex");
48
+ return hashOpaqueTokenSync(token);
49
49
  }
50
50
 
51
51
  export function createSingleUseTokenStore(prefixes: {
@@ -5,7 +5,11 @@
5
5
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
6
  import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
8
- import { reindexEntity, type SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
8
+ import {
9
+ isEntitySearchable,
10
+ reindexEntity,
11
+ type SearchAdapter,
12
+ } from "@cosmicdrift/kumiko-framework/search";
9
13
  import type { StreamBackfillCandidate, StreamBackfillFailure } from "./stream-tenant-backfill.ts";
10
14
 
11
15
  export type StreamBackfillStepFailure = StreamBackfillFailure & { readonly step: string };
@@ -88,13 +92,6 @@ export async function rebuildAndReindex(deps: {
88
92
  return { projectionsRebuilt, reindexedRows, failures };
89
93
  }
90
94
 
91
- function isEntitySearchable(registry: Registry, entityName: string): boolean {
92
- return (
93
- registry.getSearchableFields(entityName).length > 0 ||
94
- registry.getSearchPayloadExtensions(entityName).length > 0
95
- );
96
- }
97
-
98
95
  export function stepFailures(
99
96
  step: string,
100
97
  failed: readonly StreamBackfillFailure[],
@@ -0,0 +1,19 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+
4
+ // One TenantDb per tenant, so a job touching many rows of the same tenant does
5
+ // not rebuild the wrapper per row. The caller builds the TenantDb itself, so
6
+ // its createTenantDb call keeps the literal system-mode reason the guard needs.
7
+ export function createTenantDbCache(
8
+ create: (tenantId: TenantId) => TenantDb,
9
+ ): (tenantId: TenantId) => TenantDb {
10
+ const cache = new Map<TenantId, TenantDb>();
11
+ return (tenantId) => {
12
+ let existing = cache.get(tenantId);
13
+ if (!existing) {
14
+ existing = create(tenantId);
15
+ cache.set(tenantId, existing);
16
+ }
17
+ return existing;
18
+ };
19
+ }
@@ -0,0 +1,20 @@
1
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type SectionStatus =
5
+ | { kind: "idle" }
6
+ | { kind: "submitting" }
7
+ | { kind: "success"; messageKey: string }
8
+ | { kind: "error"; messageKey: string };
9
+
10
+ export function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode {
11
+ const t = useTranslation();
12
+ const { Banner } = usePrimitives();
13
+ if (status.kind === "success") {
14
+ return <Banner variant="info">{t(status.messageKey)}</Banner>;
15
+ }
16
+ if (status.kind === "error") {
17
+ return <Banner variant="error">{t(status.messageKey)}</Banner>;
18
+ }
19
+ return null;
20
+ }
@@ -0,0 +1,31 @@
1
+ import { useDispatcher } from "@cosmicdrift/kumiko-renderer";
2
+ import { useState } from "react";
3
+
4
+ // Busy flag + error key shared by the folders/tags management surfaces: every
5
+ // write goes through the dispatcher and a failed one surfaces its i18n key.
6
+ export function useDispatchedWrites(onWriteSucceeded?: () => Promise<void> | void) {
7
+ const dispatcher = useDispatcher();
8
+ const [busy, setBusy] = useState(false);
9
+ const [errorKey, setErrorKey] = useState<string | null>(null);
10
+
11
+ const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
12
+ const result = await dispatcher.write(type, payload);
13
+ if (!result.isSuccess) {
14
+ setErrorKey(result.error.i18nKey);
15
+ return false;
16
+ }
17
+ return true;
18
+ };
19
+
20
+ const apply = async (write: () => Promise<boolean>): Promise<void> => {
21
+ setBusy(true);
22
+ setErrorKey(null);
23
+ try {
24
+ if (await write()) await onWriteSucceeded?.();
25
+ } finally {
26
+ setBusy(false);
27
+ }
28
+ };
29
+
30
+ return { busy, setBusy, errorKey, setErrorKey, writeOk, apply };
31
+ }
@@ -6,7 +6,10 @@
6
6
  // subscription-stripe. The `apiKey` / `webhookSecret` / `tenantBindingKey` /
7
7
  // `clientToken` options are env→config bridge fallbacks.
8
8
 
9
- import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
9
+ import {
10
+ type SubscriptionProviderPlugin,
11
+ validateOneOffPriceIds,
12
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
10
13
  import {
11
14
  access,
12
15
  createSystemConfig,
@@ -122,25 +125,10 @@ export type SubscriptionPaddleOptions = {
122
125
  readonly nowSeconds?: () => number;
123
126
  };
124
127
 
125
- // Validated once at feature-build time so a typo fails app startup instead of
126
- // silently widening or narrowing the one-off allowlist at request time.
127
- function validateOneOffPriceIds(priceIds: readonly string[]): void {
128
- const seen = new Set<string>();
129
- for (const priceId of priceIds) {
130
- if (priceId.length === 0) {
131
- throw new Error("subscription-paddle: oneOffPriceIds must not contain an empty string");
132
- }
133
- if (seen.has(priceId)) {
134
- throw new Error(`subscription-paddle: duplicate oneOffPriceIds entry "${priceId}"`);
135
- }
136
- seen.add(priceId);
137
- }
138
- }
139
-
140
128
  export function createSubscriptionPaddleFeature(
141
129
  options: SubscriptionPaddleOptions = {},
142
130
  ): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionPaddleEnvSchema.shape> {
143
- validateOneOffPriceIds(options.oneOffPriceIds ?? []);
131
+ validateOneOffPriceIds("subscription-paddle", options.oneOffPriceIds ?? []);
144
132
  const now = options.now ?? (() => Temporal.Now.instant().epochMilliseconds);
145
133
  const feature: FeatureDefinition = defineFeature(SUBSCRIPTION_PADDLE_FEATURE, (r) => {
146
134
  r.describe(
@@ -2,10 +2,13 @@
2
2
  // cache misses, cached per price id.
3
3
 
4
4
  import {
5
+ createTtlPriceCache,
5
6
  KNOWN_RECURRING_INTERVALS,
6
7
  type ProviderPrice,
8
+ type TtlPriceCache,
7
9
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
8
10
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { chunk } from "@cosmicdrift/kumiko-framework/utils";
9
12
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
10
13
  import * as z from "zod";
11
14
  import type { PaddleCtxRuntime, PaddlePluginMethod } from "./runtime.ts";
@@ -61,10 +64,7 @@ function mapPaddlePrice(price: PaddlePrice): ProviderPrice {
61
64
  return { ...base, interval, intervalCount: interval ? cycle.frequency : null };
62
65
  }
63
66
 
64
- export type PaddlePriceCache = {
65
- readonly get: (priceId: string) => ProviderPrice | undefined;
66
- readonly set: (priceId: string, price: ProviderPrice) => void;
67
- };
67
+ export type PaddlePriceCache = TtlPriceCache<ProviderPrice>;
68
68
 
69
69
  export type PaddlePriceCacheOptions = {
70
70
  readonly ttlMs?: number;
@@ -75,32 +75,10 @@ export type PaddlePriceCacheOptions = {
75
75
  // trigger an audited secret read on every billing-plans render. Failed lookups
76
76
  // are never cached.
77
77
  export function createPaddlePriceCache({
78
- ttlMs = 600_000,
78
+ ttlMs,
79
79
  now = () => Temporal.Now.instant().epochMilliseconds,
80
80
  }: PaddlePriceCacheOptions = {}): PaddlePriceCache {
81
- const store = new Map<string, { readonly price: ProviderPrice; readonly expiresAt: number }>();
82
- return {
83
- get: (priceId) => {
84
- const entry = store.get(priceId);
85
- if (!entry) return undefined;
86
- if (entry.expiresAt <= now()) {
87
- store.delete(priceId);
88
- return undefined;
89
- }
90
- return entry.price;
91
- },
92
- set: (priceId, price) => {
93
- store.set(priceId, { price, expiresAt: now() + ttlMs });
94
- },
95
- };
96
- }
97
-
98
- function chunked<T>(items: readonly T[], size: number): T[][] {
99
- const chunks: T[][] = [];
100
- for (let start = 0; start < items.length; start += size) {
101
- chunks.push(items.slice(start, start + size));
102
- }
103
- return chunks;
81
+ return createTtlPriceCache<ProviderPrice>({ ttlMs, now });
104
82
  }
105
83
 
106
84
  async function fillPriceCache(
@@ -111,7 +89,7 @@ async function fillPriceCache(
111
89
  ): Promise<void> {
112
90
  const misses = [...new Set(priceIds.filter((priceId) => cache.get(priceId) === undefined))];
113
91
  const settled = await Promise.allSettled(
114
- chunked(misses, PRICES_PER_REQUEST).map(async (ids) =>
92
+ chunk(misses, PRICES_PER_REQUEST).map(async (ids) =>
115
93
  pricesResponseSchema.parse(
116
94
  await runtime.call(ctx, {
117
95
  method: "GET",
@@ -7,7 +7,10 @@
7
7
  // HandlerContext; the pre-tenant webhook reads secrets un-audited from the
8
8
  // system SecretsContext the foundation passes in.
9
9
 
10
- import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
10
+ import {
11
+ parseStoredSecret,
12
+ type SubscriptionProviderPlugin,
13
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
11
14
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
12
15
  import {
13
16
  type ConfigKeyHandle,
@@ -16,7 +19,6 @@ import {
16
19
  } from "@cosmicdrift/kumiko-framework/engine";
17
20
  import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
18
21
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
19
- import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
20
22
  import { createPaddleHttp, type PaddleRequest, type PaddleSend } from "./client.ts";
21
23
  import {
22
24
  PADDLE_BASE_URLS,
@@ -47,19 +49,6 @@ export type PaddleRuntimeDeps = {
47
49
  readonly fallback: PaddleFallbacks;
48
50
  };
49
51
 
50
- // backing:"secrets" values are stored JSON-serialized, so the store read has to
51
- // be parsed back; the env fallback is a plain string and stays raw. The error
52
- // must not carry the input: the JSON parser's own message would repeat the
53
- // credential into logs and, for webhooks, into the 401 body.
54
- function parseStoredSecret(raw: string | undefined): string | undefined {
55
- if (raw === undefined || raw.length === 0) return undefined;
56
- const parsed = parseJsonSafe<unknown>(raw, undefined);
57
- if (parsed === undefined) {
58
- throw new Error("subscription-paddle credential (backing:secrets) is not valid JSON");
59
- }
60
- return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
61
- }
62
-
63
52
  function hintFor(shortName: string): string {
64
53
  return `Set it via config:write:set on \`${SUBSCRIPTION_PADDLE_FEATURE}:config:${shortName}\` (or seed it from the matching PADDLE_* variable during the env bridge).`;
65
54
  }
@@ -108,7 +97,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
108
97
  { secrets: ctx.services.secrets, user: ctx.user },
109
98
  SUBSCRIPTION_PADDLE_FEATURE,
110
99
  ).get(SYSTEM_TENANT_ID, handle.name);
111
- value = parseStoredSecret(got?.reveal());
100
+ value = parseStoredSecret("subscription-paddle", got?.reveal());
112
101
  }
113
102
  if (!value && fallback && fallback.length > 0) value = fallback;
114
103
  if (!value) {
@@ -129,7 +118,7 @@ export function createPaddleRuntimes(deps: PaddleRuntimeDeps): PaddleRuntimes {
129
118
  if (systemSecrets) {
130
119
  // No auditCtx: every webhook would otherwise spam the audit trail.
131
120
  const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
132
- const stored = parseStoredSecret(got?.reveal());
121
+ const stored = parseStoredSecret("subscription-paddle", got?.reveal());
133
122
  if (stored) return stored;
134
123
  }
135
124
  if (fallback && fallback.length > 0) return fallback;
@@ -32,7 +32,10 @@
32
32
  // api key + webhook secret without auditing (a sanctioned framework-internal
33
33
  // read).
34
34
 
35
- import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
35
+ import {
36
+ type SubscriptionProviderPlugin,
37
+ validateOneOffPriceIds,
38
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
36
39
  import {
37
40
  access,
38
41
  createSystemConfig,
@@ -144,23 +147,6 @@ export type SubscriptionStripeOptions = {
144
147
  readonly oneOffPriceIds?: readonly string[];
145
148
  };
146
149
 
147
- // Validated once at feature-build time (not per-request) so a config typo
148
- // (a pasted-twice priceId, a blank string from a bad env-join) fails app
149
- // startup instead of silently widening — or narrowing — the one-off
150
- // allowlist at request time. Mirrors legal-pages' validateRoutes.
151
- function validateOneOffPriceIds(priceIds: readonly string[]): void {
152
- const seen = new Set<string>();
153
- for (const priceId of priceIds) {
154
- if (priceId.length === 0) {
155
- throw new Error("subscription-stripe: oneOffPriceIds must not contain an empty string");
156
- }
157
- if (seen.has(priceId)) {
158
- throw new Error(`subscription-stripe: duplicate oneOffPriceIds entry "${priceId}"`);
159
- }
160
- seen.add(priceId);
161
- }
162
- }
163
-
164
150
  /**
165
151
  * Factory for the subscription-stripe feature. Always mounts (no
166
152
  * key-presence guard) — activity is gated at runtime through config/secrets
@@ -171,7 +157,7 @@ function validateOneOffPriceIds(priceIds: readonly string[]): void {
171
157
  export function createSubscriptionStripeFeature(
172
158
  options: SubscriptionStripeOptions = {},
173
159
  ): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionStripeEnvSchema.shape> {
174
- validateOneOffPriceIds(options.oneOffPriceIds ?? []);
160
+ validateOneOffPriceIds("subscription-stripe", options.oneOffPriceIds ?? []);
175
161
  const feature: FeatureDefinition = defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
176
162
  r.describe(
177
163
  'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog), `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), `retrieveSubscription` (live provider-side snapshot for the `sync-subscriptions` backfill job, mapped identically to the webhook path), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',