@cosmicdrift/kumiko-samples 0.388.0 → 0.389.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 (129) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +42 -26
  6. package/packages/bundled-features/src/audit/feature.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  14. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  15. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  16. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  17. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  18. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  19. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  20. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  21. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  22. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  23. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  24. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  25. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  26. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  27. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  28. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  29. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +20 -7
  30. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  31. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  32. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  33. package/packages/bundled-features/src/config/changes.json +6 -0
  34. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  35. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  36. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +1 -1
  37. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  38. package/packages/bundled-features/src/delivery/changes.json +12 -0
  39. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  40. package/packages/bundled-features/src/delivery/index.ts +5 -0
  41. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  42. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  43. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  44. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  45. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  47. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  48. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  50. package/packages/bundled-features/src/jobs/feature.ts +1 -0
  51. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  52. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  53. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  54. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  55. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  56. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  57. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  58. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  59. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  60. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  61. package/packages/bundled-features/src/personal-access-tokens/feature.ts +1 -0
  62. package/packages/bundled-features/src/secrets/changes.json +6 -0
  63. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  64. package/packages/bundled-features/src/sessions/changes.json +7 -0
  65. package/packages/bundled-features/src/sessions/feature.ts +1 -0
  66. package/packages/bundled-features/src/sessions/index.ts +1 -1
  67. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  68. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  69. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  70. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  71. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  72. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  73. package/packages/bundled-features/src/tags/feature.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/changes.json +36 -0
  75. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  76. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  78. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +30 -5
  79. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  80. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  81. package/packages/bundled-features/src/template-resolver/seeding.ts +10 -3
  82. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +52 -8
  83. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  84. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  85. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  86. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  87. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  88. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  89. package/packages/bundled-features/src/user/feature.ts +1 -0
  90. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  91. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  92. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  93. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  94. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  95. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  96. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  97. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +27 -8
  98. package/packages/bundled-features/src/waitlist/import.ts +23 -3
  99. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +2 -1
  100. package/packages/bundled-features/src/waitlist/options.ts +2 -1
  101. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  102. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  103. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  104. package/samples/apps/styleguide/src/app/server.ts +4 -1
  105. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  106. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  107. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  108. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  109. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  110. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  111. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  112. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  113. package/samples/apps/use-all-bundled/feature-manifest.json +16 -0
  114. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  115. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  116. package/samples/apps/use-all-bundled/package.json +2 -1
  117. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  118. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  119. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  120. package/samples/recipes/golden-path/src/feature.ts +2 -1
  121. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  122. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  123. package/samples/recipes/list-table-options/README.md +50 -0
  124. package/samples/recipes/list-table-options/package.json +11 -0
  125. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  126. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  127. package/samples/recipes/state-machine/src/events.ts +3 -2
  128. package/samples/recipes/tenant-isolation/README.md +1 -0
  129. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
@@ -1,6 +1,13 @@
1
1
  // Public API of the document-ingest-foundation bundled-feature.
2
2
 
3
3
  export { documentIngestAggregateId } from "./aggregate-id.ts";
4
+ export {
5
+ type AppendMissingExtractEventDeps,
6
+ type AppendMissingExtractEventOptions,
7
+ type AppendMissingExtractEventResult,
8
+ appendMissingDocumentExtractEvent,
9
+ type ExistingDocumentExtract,
10
+ } from "./append-missing-extract-event.ts";
4
11
  export {
5
12
  type DocumentExtractMeta,
6
13
  documentExtractEntity,
@@ -14,8 +21,12 @@ export {
14
21
  DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
15
22
  DOCUMENT_INGEST_SKIPPED_EVENT_QN,
16
23
  DOCUMENT_INGEST_SKIPPED_EVENT_SHORT,
24
+ type DocumentExtractFailedPayload,
25
+ type DocumentExtractReadyPayload,
17
26
  type DocumentIngestRequestedPayload,
18
27
  type DocumentIngestSkippedPayload,
28
+ documentExtractFailedPayloadSchema,
29
+ documentExtractReadyPayloadSchema,
19
30
  documentIngestRequestedPayloadSchema,
20
31
  documentIngestSkippedPayloadSchema,
21
32
  } from "./events.ts";
@@ -44,6 +44,7 @@ export function createFeatureTogglesFeature(
44
44
  options: FeatureTogglesOptions = {},
45
45
  ): FeatureDefinition {
46
46
  return defineFeature("feature-toggles", (r) => {
47
+ r.adoptsBundledLists();
47
48
  r.describe(
48
49
  'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "shared"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
49
50
  );
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "Redirects from /api paths fail at boot",
6
+ "detail": "Redirects run inside hostDispatch after static files and API routes, so a from of /api or /api/... was silently dead config. Boot validation now throws; the options and README already describe the HTML-fallback scope."
7
+ },
2
8
  {
3
9
  "version": "0.388.0",
4
10
  "type": "fix",
@@ -16,6 +16,11 @@ function validateRedirects(options: HostRoutingOptions): void {
16
16
  if (!REDIRECT_PATH_PATTERN.test(rule.from)) {
17
17
  throw new Error(`host-routing: redirect from must be a path like "/old", got "${rule.from}"`);
18
18
  }
19
+ if (rule.from === "/api" || rule.from.startsWith("/api/")) {
20
+ throw new Error(
21
+ `host-routing: redirect from must not be an /api path, it would never fire, got "${rule.from}"`,
22
+ );
23
+ }
19
24
  if (!REDIRECT_TARGET_PATTERN.test(rule.to)) {
20
25
  throw new Error(
21
26
  `host-routing: redirect to must be a same-origin path or an https URL without query, got "${rule.to}"`,
@@ -103,6 +103,7 @@ export function createInboundMailFoundationFeature() {
103
103
  return defineFeature(
104
104
  INBOUND_MAIL_FOUNDATION_FEATURE,
105
105
  (r) => {
106
+ r.adoptsBundledLists();
106
107
  r.describe(
107
108
  "Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push). The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.",
108
109
  );
@@ -65,6 +65,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
65
65
  );
66
66
  }
67
67
  return defineFeature("jobs", (r) => {
68
+ r.adoptsBundledLists();
68
69
  r.describe(
69
70
  "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops `queued` rows older than `retentionDays`.",
70
71
  );
@@ -1,7 +1,7 @@
1
1
  // Client-safe ledger surface: QN constants, pure recurring helpers
2
2
  // (projection + Soll/Ist merge), and Zod payload schemas for client-side
3
3
  // pre-validation before dispatch. The full `../ledger` entry also re-exports
4
- // the feature/handlers/executor, which pull postgres-js; importing it in a
4
+ // the feature/handlers/executor, which pull the DB driver; importing it in a
5
5
  // browser bundle fails on Node builtins. Client screens import dispatch QNs and
6
6
  // pure forecast/merge helpers from this module, then dispatch via the renderer's
7
7
  // useDispatcher.
@@ -42,12 +42,15 @@ async function loadBlocksFromJson(path: string): Promise<readonly LegalContentBl
42
42
 
43
43
  // Boot seed for runProdApp's `seeds: [...]`. The JSON template is authoritative
44
44
  // (ifExists "update"), so legally required text changes reach rows seeded once.
45
- export function legalContentSeed(source: LegalContentSeedSource): LegalContentSeedFn {
45
+ export function legalContentSeed(
46
+ source: LegalContentSeedSource,
47
+ options?: { readonly folder?: string },
48
+ ): LegalContentSeedFn {
46
49
  return async ({ db }) => {
47
50
  const blocks =
48
51
  "blocks" in source
49
52
  ? legalContentBlocksSchema.parse(source.blocks)
50
53
  : await loadBlocksFromJson(source.path);
51
- await seedLegalContentFromJson(db, blocks);
54
+ await seedLegalContentFromJson(db, blocks, options);
52
55
  };
53
56
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "SMTP authentication is optional",
6
+ "detail": "authUser is no longer a required key and the SMTP password secret no longer counts as missing in readiness. With an empty user the transport connects without auth, which suits internal relays; with a user set, a missing password still fails the send."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "breaking",
@@ -6,6 +6,7 @@ const tenantAdminWrite = access.withSystem(access.roles("TenantAdmin"));
6
6
  const tenantAdminRead = access.roles("TenantAdmin", "SystemAdmin");
7
7
 
8
8
  // required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
9
+ // authUser stays optional: empty means an unauthenticated relay.
9
10
  export const SMTP_CONFIG_KEYS = {
10
11
  host: createTenantConfig("text", {
11
12
  required: true,
@@ -41,7 +42,6 @@ export const SMTP_CONFIG_KEYS = {
41
42
  },
42
43
  }),
43
44
  authUser: createTenantConfig("text", {
44
- required: true,
45
45
  default: "",
46
46
  write: tenantAdminWrite,
47
47
  read: tenantAdminRead,
@@ -105,8 +105,7 @@ export function createMailTransportSmtpFeature() {
105
105
  return `${plaintext.slice(0, 3)}...${plaintext.slice(-2)}`;
106
106
  },
107
107
  scope: "tenant",
108
- // required: true ↔ the missing-secret throw in readPassword — keep in sync.
109
- required: true,
108
+ // Only needed once authUser is set (readPassword throws then), so readiness must not demand it.
110
109
  });
111
110
 
112
111
  const configKeys = r.config({ keys: SMTP_CONFIG_KEYS });
@@ -191,14 +190,13 @@ async function buildSmtpTransport(
191
190
  "from",
192
191
  SMTP_HINT,
193
192
  );
194
- const authUser = requireNonEmpty(
195
- await ctxConfig(sharedMailTransportSmtpFeature.exports.configKeys.authUser),
196
- FEATURE_NAME,
197
- "authUser",
198
- SMTP_HINT,
199
- );
200
-
201
- const password = await readPassword(ctx, tenantId);
193
+ const authUser = (
194
+ await ctxConfig(sharedMailTransportSmtpFeature.exports.configKeys.authUser)
195
+ )?.trim();
196
+ const auth =
197
+ authUser === undefined || authUser === ""
198
+ ? undefined
199
+ : { user: authUser, pass: await readPassword(ctx, tenantId) };
202
200
 
203
201
  let target: MailConnectTarget;
204
202
  try {
@@ -227,7 +225,7 @@ async function buildSmtpTransport(
227
225
  port,
228
226
  secure,
229
227
  from,
230
- auth: { user: authUser, pass: password },
228
+ ...(auth && { auth }),
231
229
  ...(target.servername && { servername: target.servername }),
232
230
  });
233
231
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "MANAGED_PAGES_FEATURE names the feature",
6
+ "detail": "The barrel exports the MANAGED_PAGES_FEATURE constant so other code can refer to the feature name without a string literal."
7
+ },
2
8
  {
3
9
  "version": "0.363.0",
4
10
  "type": "improvement",
@@ -0,0 +1 @@
1
+ export const MANAGED_PAGES_FEATURE = "managed-pages" as const;
@@ -10,6 +10,7 @@ import {
10
10
  } from "../page-render/index.ts";
11
11
  import { rateLimitedTextResponse, type SystemQueryFn } from "../shared/index.ts";
12
12
  import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding.ts";
13
+ import { MANAGED_PAGES_FEATURE } from "./constants.ts";
13
14
  import { createBrandingQuery } from "./handlers/branding.query.ts";
14
15
  import { bySlugQuery } from "./handlers/by-slug.query.ts";
15
16
  import { byTenantPublishedQuery } from "./handlers/by-tenant-published.query.ts";
@@ -153,7 +154,8 @@ export function createManagedPagesFeature(opts: ManagedPagesOptions): FeatureDef
153
154
  const defaultLang = opts.defaultLang ?? "en";
154
155
  const allowCustomCss = opts.allowCustomCss ?? false;
155
156
 
156
- return defineFeature("managed-pages", (r) => {
157
+ return defineFeature(MANAGED_PAGES_FEATURE, (r) => {
158
+ r.adoptsBundledLists();
157
159
  r.describe(
158
160
  "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
159
161
  );
@@ -21,6 +21,7 @@ export {
21
21
  // (`config:write:set`) — single source for the `managed-pages:config:branding-*`
22
22
  // strings, so apps + the per-tenant migration never hardcode them.
23
23
  export { BRANDING_QN, MANAGED_PAGES_CSS_FEATURE } from "./branding.ts";
24
+ export { MANAGED_PAGES_FEATURE } from "./constants.ts";
24
25
  export { createManagedPagesCssFeature } from "./css-gate.ts";
25
26
  export {
26
27
  createManagedPagesFeature,
@@ -69,7 +69,7 @@ export function createAddNoteHandler(
69
69
  let authorName: string | null = null;
70
70
  try {
71
71
  // read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
72
- // postgres-js poisons the whole tx after any error inside it, even one that's
72
+ // Postgres aborts the whole tx after any error inside it, even one that's
73
73
  // caught — so the lookup runs in a savepoint where available, otherwise directly
74
74
  // (pool statements are their own units).
75
75
  authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(), async (sp) => {
@@ -101,6 +101,7 @@ export function createPersonalAccessTokensFeature(
101
101
  ): FeatureDefinition {
102
102
  const { scopes } = options;
103
103
  return defineFeature(PAT_FEATURE, (r) => {
104
+ r.adoptsBundledLists();
104
105
  r.describe(
105
106
  "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — registered as an auth-foundation tokenVerifier provider, resolved generically by the middleware. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
106
107
  );
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "Feature-prefixed undeclared secret keys are not deletable",
6
+ "detail": "The delete handler still accepts legacy undeclared keys, but a key starting with \"<feature>:\" of a mounted feature now fails as unknown key. Feature-owned credentials keep their writeRoles protection."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "improvement",
@@ -28,6 +28,11 @@ function unknownKeyFailure(key: string): SecretKeyWriteCheck {
28
28
  };
29
29
  }
30
30
 
31
+ function isUnderMountedFeaturePrefix(registry: Registry, key: string): boolean {
32
+ const separator = key.indexOf(":");
33
+ return separator > 0 && registry.getFeature(key.slice(0, separator)) !== undefined;
34
+ }
35
+
31
36
  function namespaceAcceptsKey(namespace: SecretNamespaceDefinition, key: string): boolean {
32
37
  const suffix = key.slice(namespace.qualifiedPrefix.length);
33
38
  if (suffix.length === 0) return false;
@@ -38,7 +43,9 @@ function namespaceAcceptsKey(namespace: SecretNamespaceDefinition, key: string):
38
43
  // are writable through the API, so a tenant admin cannot plant arbitrary rows
39
44
  // next to the feature-owned ones. writeRoles narrows the handler access
40
45
  // further for a key or a whole namespace. Delete passes allowUndeclared: it
41
- // creates nothing, and legacy rows under since-undeclared keys must stay removable.
46
+ // creates nothing, and legacy rows under since-undeclared keys must stay removable,
47
+ // unless the key sits under a mounted feature's prefix: those rows belong to the
48
+ // feature and must not be removable by a tenant admin outside its writeRoles.
42
49
  export function checkSecretKeyWrite(
43
50
  registry: Registry,
44
51
  userRoles: readonly string[],
@@ -50,7 +57,7 @@ export function checkSecretKeyWrite(
50
57
  if (
51
58
  !keyDef &&
52
59
  !(namespace && namespaceAcceptsKey(namespace, key)) &&
53
- options.allowUndeclared !== true
60
+ (options.allowUndeclared !== true || isUnderMountedFeaturePrefix(registry, key))
54
61
  ) {
55
62
  return unknownKeyFailure(key);
56
63
  }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "breaking",
5
+ "title": "isPrincipalBlocked is no longer exported from sessions",
6
+ "detail": "The sessions export dropped its re-export of isPrincipalBlocked, which ignored isDeleted. isPrincipalRowBlocked from the user export covers status and isDeleted and is what the session callbacks use.",
7
+ "migration": "import isPrincipalRowBlocked (for a user row) or isPrincipalBlocked (for a bare status) from \"@cosmicdrift/kumiko-bundled-features/user\" instead of the sessions export"
8
+ },
2
9
  {
3
10
  "version": "0.384.0",
4
11
  "type": "improvement",
@@ -102,6 +102,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
102
102
  "sessions",
103
103
  (r) => {
104
104
  const adminRoles = access[options?.adminAccess ?? "admin"];
105
+ r.adoptsBundledLists();
105
106
  r.describe(
106
107
  "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
107
108
  );
@@ -15,7 +15,7 @@ export type {
15
15
  SessionCallbacksOptions,
16
16
  SessionMassRevoker,
17
17
  } from "./session-callbacks.ts";
18
- export { createSessionCallbacks, isPrincipalBlocked } from "./session-callbacks.ts";
18
+ export { createSessionCallbacks } from "./session-callbacks.ts";
19
19
  export type { SessionRevokedPayload } from "./session-revoked-event.ts";
20
20
  export {
21
21
  SESSION_REVOKED_AGGREGATE_TYPE,
@@ -18,12 +18,7 @@ import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
18
18
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
19
19
  import { encryptForDirectWrite } from "../shared/index.ts";
20
20
  import { tenantMembershipsTable } from "../tenant/index.ts";
21
- import {
22
- isPrincipalBlocked,
23
- isPrincipalRowBlocked,
24
- type UserStatus,
25
- userTable,
26
- } from "../user/index.ts";
21
+ import { isPrincipalRowBlocked, type UserStatus, userTable } from "../user/index.ts";
27
22
  import {
28
23
  DEFAULT_SESSION_EXPIRY_MS,
29
24
  LAST_SEEN_REFRESH_MS,
@@ -36,9 +31,6 @@ import {
36
31
  sessionRevokedSchema,
37
32
  } from "./session-revoked-event.ts";
38
33
 
39
- // Re-exported so existing `../sessions` importers keep working unchanged.
40
- export { isPrincipalBlocked };
41
-
42
34
  type SettledLookup<T> = { readonly ok: true; readonly value: T } | { readonly ok: false };
43
35
 
44
36
  function settleLookup<T>(lookup: Promise<T>): Promise<SettledLookup<T>> {
@@ -112,6 +112,29 @@ function fieldsResolvingToSubject(
112
112
  });
113
113
  }
114
114
 
115
+ // A user owning many records must not load them all at once; the id cursor keeps each page
116
+ // bounded while rows purged in place stay out of the way.
117
+ export const PURGE_CANDIDATE_PAGE_SIZE = 200;
118
+
119
+ async function* candidateRowsPaged(
120
+ db: DbRunner,
121
+ table: FileEntity["table"],
122
+ where: WhereObject,
123
+ ): AsyncGenerator<Record<string, unknown>> {
124
+ let cursor: unknown;
125
+ for (;;) {
126
+ const pageWhere: WhereObject = cursor === undefined ? where : { ...where, id: { gt: cursor } };
127
+ const page = await selectMany<Record<string, unknown>>(db, table, pageWhere, {
128
+ orderBy: { col: "id", direction: "asc" },
129
+ limit: PURGE_CANDIDATE_PAGE_SIZE,
130
+ });
131
+ yield* page;
132
+ // skip: a short page is the last one
133
+ if (page.length < PURGE_CANDIDATE_PAGE_SIZE) return;
134
+ cursor = page[page.length - 1]?.["id"];
135
+ }
136
+ }
137
+
115
138
  const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
116
139
  entityName: "fileRef",
117
140
  });
@@ -165,8 +188,7 @@ export async function purgeFilesReachingSubject(args: {
165
188
  for (const fileEntity of fileEntities) {
166
189
  const seenRowIds = new Set<string>();
167
190
  for (const where of candidateWheres(subject, recordTenantId, fileEntity)) {
168
- const rows = await selectMany<Record<string, unknown>>(db, fileEntity.table, where);
169
- for (const row of rows) {
191
+ for await (const row of candidateRowsPaged(db, fileEntity.table, where)) {
170
192
  const rowId = String(row["id"]);
171
193
  const tenantId = parseTenantId(row["tenantId"]);
172
194
  // skip: a row without a tenant cannot own tenant-scoped file refs
@@ -1,11 +1,11 @@
1
1
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
2
 
3
3
  // Per-unit sub-transaction (one user, one purged row), nesting-aware across both db shapes:
4
- // - top-level connection (postgres-js Sql) → `.begin` (BEGIN)
5
- // - TransactionSql (inside the dispatcher's outer tx, where every
4
+ // - top-level connection (Bun.SQL pool) → `.begin` (BEGIN)
5
+ // - TransactionSQL (inside the dispatcher's outer tx, where every
6
6
  // writeHandler already runs) → `.savepoint` (SAVEPOINT)
7
- // A TransactionSql has no `.begin`, so the previous unconditional `.begin`
8
- // threw "is not a function" on every user when invoked through the dispatcher
7
+ // A transaction handle cannot begin another transaction, so the previous unconditional `.begin`
8
+ // threw on every user when invoked through the dispatcher
9
9
  // (the cron path) → zero deletions in production, while direct-connection tests
10
10
  // stayed green. Selecting the available method makes the sub-tx work in both
11
11
  // contexts; on throw the savepoint rolls back just this user (others survive).
@@ -21,7 +21,7 @@ export async function runInSubTransaction<T>(
21
21
  savepoint?: (f: (tx: DbRunner) => Promise<T>) => Promise<T>;
22
22
  };
23
23
  // savepoint-first: empirically (Bun 1.3.14, not re-checked on 1.4.0) the
24
- // surfaces are NOT mutually exclusive — a TransactionSql exposes begin AND
24
+ // surfaces are NOT mutually exclusive — a TransactionSQL exposes begin AND
25
25
  // savepoint, only the top-level connection has begin alone. begin-first
26
26
  // picked the nested BEGIN inside a tx (the incident class above);
27
27
  // savepoint-first hits the savepoint there and falls back to begin top-level.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "Mail steps do not retry permanent rejections or throws",
6
+ "detail": "MailDispatchResult failures take an optional smtpCode; 5xx is permanent and recorded after the first attempt. A throwing mail runner is not retried, since the message may already be sent. A mail runner that wants a retry after a transport error should return a failure without smtpCode instead of throwing."
7
+ },
2
8
  {
3
9
  "version": "0.388.0",
4
10
  "type": "fix",
@@ -54,9 +54,17 @@ type DeliveryResult =
54
54
  | { readonly ok: true; readonly status: number }
55
55
  | { readonly ok: false; readonly error: string; readonly retryable: boolean };
56
56
 
57
- // Mail adapter failures carry no status to classify, so every one is retried.
57
+ const SMTP_PERMANENT_REJECTION_MIN = 500;
58
+ const SMTP_PERMANENT_REJECTION_MAX = 599;
59
+
60
+ // Without an SMTP code the adapter gave nothing to classify, so the failure counts as transient.
58
61
  function mailResultAsDelivery(result: MailDispatchResult): DeliveryResult {
59
- return result.ok ? result : { ...result, retryable: true };
62
+ if (result.ok) return result;
63
+ const isPermanent =
64
+ result.smtpCode !== undefined &&
65
+ result.smtpCode >= SMTP_PERMANENT_REJECTION_MIN &&
66
+ result.smtpCode <= SMTP_PERMANENT_REJECTION_MAX;
67
+ return { ok: false, error: result.error, retryable: !isPermanent };
60
68
  }
61
69
 
62
70
  export type DeliverDispatchRequestArgs = {
@@ -205,7 +213,13 @@ async function sendDispatch(
205
213
  aggregateId: target.aggregateId,
206
214
  errorName: err instanceof Error ? err.name : "unknown",
207
215
  });
208
- return { ok: false, error: DISPATCH_THREW_ERROR, retryable: true };
216
+ // A mail adapter has no idempotency key: a throw after the provider accepted the message
217
+ // would send it again, so only the webhook (which carries one) is retried.
218
+ return {
219
+ ok: false,
220
+ error: DISPATCH_THREW_ERROR,
221
+ retryable: dispatchSpec.stepKind === "webhook.send",
222
+ };
209
223
  }
210
224
  }
211
225
 
@@ -15,7 +15,8 @@ export type MailSpec = z.infer<typeof mailSpecSchema>;
15
15
 
16
16
  export type MailDispatchResult =
17
17
  | { readonly ok: true; readonly status: number }
18
- | { readonly ok: false; readonly error: string };
18
+ // smtpCode is the adapter's SMTP reply code; a 5xx is a permanent rejection and is not retried.
19
+ | { readonly ok: false; readonly error: string; readonly smtpCode?: number };
19
20
 
20
21
  let mailRunner: (spec: MailSpec) => Promise<MailDispatchResult> = async () => ({
21
22
  ok: false,
@@ -54,6 +54,7 @@ function registerTags(
54
54
  ownership: EntityDefinition["access"] | undefined,
55
55
  parents: readonly string[] | undefined,
56
56
  ): void {
57
+ r.adoptsBundledLists();
57
58
  r.describe(
58
59
  "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: { reason } } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
59
60
  );
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "copy-locale reads source files from the owning tenant's storage",
6
+ "detail": "The copy of linked files uses readFileRefContent with the provider of the fileRef's tenant. A source file whose fileRef row exists but whose bytes cannot be read fails the copy with template_resource_source_unreadable and releases the files already copied. A deleted fileRef is still left out of the copy."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "improvement",
11
+ "title": "replaceText explains finds across formatting or placeholders",
12
+ "detail": "replaceText matches inside single text nodes so formatting stays intact. A find that appears in the block line but crosses a formatting change now fails with text_spans_formatting, and a find with a {{placeholder}} fails with placeholder_not_replaceable, both with translated messages. Only a string that is not in the block at all still gives text_not_found."
13
+ },
14
+ {
15
+ "version": "0.389.0",
16
+ "type": "fix",
17
+ "title": "send-test-status follows the tenantEditable roles",
18
+ "detail": "send-test-status takes its access from templateEditAccess(policy.tenantEditable), like send-test. Roles that may send a test mail now get the status, and a TenantAdmin without an edit opt-in gets a 403 on both."
19
+ },
20
+ {
21
+ "version": "0.389.0",
22
+ "type": "fix",
23
+ "title": "overview follows the tenantEditable roles",
24
+ "detail": "The overview query takes its access from templateEditAccess(policy.tenantEditable). An opted-in role lists exactly the released templates, and a TenantAdmin without an opt-in is refused with a 403, matching the screen visibility and the write handlers."
25
+ },
26
+ {
27
+ "version": "0.389.0",
28
+ "type": "fix",
29
+ "title": "overview search and sort use the translated title",
30
+ "detail": "The overview query translates a seeded titleI18nKey with the request locale before it filters by search and sorts by title. A search for the visible title now matches, and the raw key is still searchable."
31
+ },
32
+ {
33
+ "version": "0.389.0",
34
+ "type": "fix",
35
+ "title": "legalContentSeed keeps and can set the content folder",
36
+ "detail": "seedTextBlock no longer resets the folder of an existing block to null when the seed passes no folder. legalContentSeed takes an optional folder, so apps do not need their own seed loop to keep legal blocks in the SystemAdmin content tree."
37
+ },
2
38
  {
3
39
  "version": "0.388.0",
4
40
  "type": "fix",
@@ -389,6 +389,23 @@ function removeBlockOp(
389
389
  return null;
390
390
  }
391
391
 
392
+ export const REPLACE_TEXT_ISSUE_CODES = {
393
+ notFound: "text_not_found",
394
+ spansFormatting: "text_spans_formatting",
395
+ placeholderNotReplaceable: "placeholder_not_replaceable",
396
+ } as const;
397
+
398
+ // document-blocks shows the line across formatting runs with placeholders as {{name}},
399
+ // so a find that is visible there but lies in no single text node needs its own explanation.
400
+ function textNotMatchedCode(block: JSONContent, find: string): string {
401
+ if (!textLinesOf(block).some((line) => line.includes(find))) {
402
+ return REPLACE_TEXT_ISSUE_CODES.notFound;
403
+ }
404
+ return find.includes("{{")
405
+ ? REPLACE_TEXT_ISSUE_CODES.placeholderNotReplaceable
406
+ : REPLACE_TEXT_ISSUE_CODES.spansFormatting;
407
+ }
408
+
392
409
  function replaceTextOp(
393
410
  state: OpState,
394
411
  op: Extract<DocumentOp, { op: "replaceText" }>,
@@ -401,7 +418,9 @@ function replaceTextOp(
401
418
  (sum, node) => sum + occurrencesOf(node.text ?? "", op.find),
402
419
  0,
403
420
  );
404
- if (count === 0) return patchIssue(`${path}.find`, "text_not_found", { find: op.find });
421
+ if (count === 0) {
422
+ return patchIssue(`${path}.find`, textNotMatchedCode(entry.node, op.find), { find: op.find });
423
+ }
405
424
  if (count > 1) return patchIssue(`${path}.find`, "text_ambiguous", { find: op.find, count });
406
425
  entry.node = replaceInContent([entry.node], op.find, op.replace)[0] ?? entry.node;
407
426
  return null;
@@ -29,7 +29,7 @@ import { makeResourceLinkPdfWrite } from "./handlers/resource-link-pdf.write.ts"
29
29
  import { makeResourceRemoveWrite } from "./handlers/resource-remove.write.ts";
30
30
  import { makeResourceUploadWrite } from "./handlers/resource-upload.write.ts";
31
31
  import { makeSendTestWrite } from "./handlers/send-test.write.ts";
32
- import { sendTestStatusQuery } from "./handlers/send-test-status.query.ts";
32
+ import { makeSendTestStatusQuery } from "./handlers/send-test-status.query.ts";
33
33
  import { makeSetWrite } from "./handlers/set.write.ts";
34
34
  import { tenantOverrideCountsQuery } from "./handlers/tenant-override-counts.query.ts";
35
35
  import { tenantOverridesQuery } from "./handlers/tenant-overrides.query.ts";
@@ -191,7 +191,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
191
191
  preview: r.queryHandler(previewQuery),
192
192
  documentBlocks: r.queryHandler(documentBlocksQuery),
193
193
  exportBundle: r.queryHandler(exportBundleQuery),
194
- sendTestStatus: r.queryHandler(sendTestStatusQuery),
194
+ sendTestStatus: r.queryHandler(makeSendTestStatusQuery(policy)),
195
195
  };
196
196
 
197
197
  for (const collection of collections) {