@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,4 +1,5 @@
1
1
  import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
2
+ import { createLruCache } from "@cosmicdrift/kumiko-framework/utils";
2
3
  import { renderSafeMarkdown } from "../page-render/index.ts";
3
4
  import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
4
5
  import type { BodyFormat } from "./constants.ts";
@@ -29,8 +30,7 @@ export type PageBodyRendererDeps = {
29
30
  };
30
31
 
31
32
  // Every write bumps the version, so a (page, version) key never serves stale
32
- // HTML. A Map keeps insertion order: delete+reinsert on a hit makes the first
33
- // key the least recently used one.
33
+ // HTML.
34
34
  function cacheKeyOf(page: RenderablePageBody): string {
35
35
  return [page.tenantId, page.slug, page.lang, page.version, page.updatedAt, page.bodyFormat].join(
36
36
  "\u0000",
@@ -47,7 +47,7 @@ export function createPageBodyRenderer(
47
47
  const sanitizeDocument = deps.sanitizeDocument ?? sanitizeDocumentHtml;
48
48
  const renderMarkdown = deps.renderMarkdown ?? renderSafeMarkdown;
49
49
  const maxEntries = deps.maxEntries ?? MAX_CACHED_BODIES;
50
- const cache = new Map<string, string>();
50
+ const cache = createLruCache<string, string>({ maxEntries });
51
51
 
52
52
  function renderUncached(page: RenderablePageBody, log: WarnLogger): string {
53
53
  if (page.bodyFormat === "markdown") return renderMarkdown(page.body);
@@ -63,18 +63,10 @@ export function createPageBodyRenderer(
63
63
  return (page, log = defaultLog) => {
64
64
  const key = cacheKeyOf(page);
65
65
  const cached = cache.get(key);
66
- if (cached !== undefined) {
67
- cache.delete(key);
68
- cache.set(key, cached);
69
- return cached;
70
- }
66
+ if (cached !== undefined) return cached;
71
67
  const html = renderUncached(page, log);
72
68
  if (html.length > MAX_CACHED_BODY_LENGTH) return html;
73
69
  cache.set(key, html);
74
- if (cache.size > maxEntries) {
75
- const oldest = cache.keys().next();
76
- if (!oldest.done) cache.delete(oldest.value);
77
- }
78
70
  return html;
79
71
  };
80
72
  }
@@ -20,7 +20,7 @@ import {
20
20
  type EntityDefinition,
21
21
  type FeatureRegistrar,
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
- import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.ts";
23
+ import { assertNoWhereRuleInOwnershipWrite, resolveFeatureAccess } from "../shared/index.ts";
24
24
  import { createNoteEntryEntity, noteMentionEntity } from "./entity.ts";
25
25
  import { createNoteEntryExecutor } from "./executor.ts";
26
26
  import { createAddNoteHandler } from "./handlers/add-note.write.ts";
@@ -104,12 +104,6 @@ export type NotesHistoryFeatureOptions = {
104
104
  readonly parents?: readonly string[];
105
105
  };
106
106
 
107
- function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
108
- if (opts.access !== undefined) return opts.access;
109
- if (opts.roles !== undefined) return { roles: opts.roles };
110
- return DEFAULT_NOTES_HISTORY_ACCESS;
111
- }
112
-
113
107
  // Options wrapper. Without options returns the module-level singleton (no
114
108
  // rebuild). access/roles/ownership build a fresh feature-definition.
115
109
  export function createNotesHistoryFeature(
@@ -135,6 +129,11 @@ export function createNotesHistoryFeature(
135
129
  );
136
130
  }
137
131
  return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
138
- registerNotesHistory(r, resolveAccess(opts), opts.ownership, opts.parents),
132
+ registerNotesHistory(
133
+ r,
134
+ resolveFeatureAccess(opts, DEFAULT_NOTES_HISTORY_ACCESS),
135
+ opts.ownership,
136
+ opts.parents,
137
+ ),
139
138
  );
140
139
  }
@@ -4,6 +4,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  import * as z from "zod";
5
5
  import { decryptStoredPii } from "../../shared/index.ts";
6
6
  import { isExpiredAt } from "../expiry.ts";
7
+ import { parseScopeNames } from "../parse-scope-names.ts";
7
8
  import { apiTokenTable } from "../schema/api-token.ts";
8
9
 
9
10
  // `sort` arrives raw from the client's query string. selectMany's orderBy has
@@ -28,15 +29,6 @@ function tokenStatus(row: {
28
29
  return "active";
29
30
  }
30
31
 
31
- function parseScopeNames(raw: string): string[] {
32
- try {
33
- const parsed: unknown = JSON.parse(raw);
34
- return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
35
- } catch {
36
- return [];
37
- }
38
- }
39
-
40
32
  // The caller's own tokens — metadata only, never the hash or plaintext.
41
33
  // Includes revoked/expired rows so the UI can show history; `prefix` is the
42
34
  // only fragment of the secret ever exposed.
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
1
  import { generateToken, PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
2
+ import { hashOpaqueTokenSync } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { PAT_PREFIX_DISPLAY_LENGTH } from "./web/constants.ts";
4
4
 
5
5
  // A PAT is high-entropy (32 random bytes from generateToken). A single SHA-256
@@ -8,7 +8,7 @@ import { PAT_PREFIX_DISPLAY_LENGTH } from "./web/constants.ts";
8
8
  // passwords). Only the hash is persisted — the plaintext exists once, in the
9
9
  // create response.
10
10
  export function hashPatToken(rawToken: string): string {
11
- return createHash("sha256").update(rawToken).digest("hex");
11
+ return hashOpaqueTokenSync(rawToken);
12
12
  }
13
13
 
14
14
  export function mintPatToken(): { raw: string; hash: string; prefix: string } {
@@ -0,0 +1,9 @@
1
+ // Drops non-string entries rather than rejecting the whole array.
2
+ export function parseScopeNames(raw: string): string[] {
3
+ try {
4
+ const parsed: unknown = JSON.parse(raw);
5
+ return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
6
+ } catch {
7
+ return [];
8
+ }
9
+ }
@@ -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
- }
@@ -21,9 +21,7 @@ const features = [
21
21
  const app = createKumikoApp({
22
22
  features,
23
23
  extraContext: ({ registry }) => ({
24
- rendererFoundation: createRendererFoundationApi(
25
- collectRendererPlugins(registry),
26
- ),
24
+ rendererFoundation: createRendererFoundationApi(collectRendererPlugins(registry)),
27
25
  }),
28
26
  });
29
27
  ```
@@ -39,7 +37,11 @@ async function sendMail(ctx, tenantId) {
39
37
  const result = await renderer.render(
40
38
  {
41
39
  kind: "mail",
42
- payload: { slug: "billing:notify:invoice-ready", locale: "de", variables: { name: "Frau Schmidt" } },
40
+ payload: {
41
+ slug: "billing:notify:invoice-ready",
42
+ locale: "de",
43
+ variables: { name: "Frau Schmidt" },
44
+ },
43
45
  },
44
46
  { db: ctx.db, registry: ctx.registry, tenantId, config: ctx.config },
45
47
  );
@@ -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
  }
@@ -11,7 +11,11 @@ const { pdfBytes, pageCount } = await renderTablePdf(
11
11
  {
12
12
  title: "Prüfprotokoll",
13
13
  meta: [{ label: "Anlage", value: "Kessel 3" }],
14
- columns: [{ header: "Datum" }, { header: "Prüfer", weight: 2 }, { header: "Kosten", align: "right" }],
14
+ columns: [
15
+ { header: "Datum" },
16
+ { header: "Prüfer", weight: 2 },
17
+ { header: "Kosten", align: "right" },
18
+ ],
15
19
  rows: [["03.02.2026", "Müller", "120,00 €"]],
16
20
  footerText: "Kumiko GmbH",
17
21
  pageLabel: "Seite {page} von {pages}",
@@ -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(