@cosmicdrift/kumiko-framework 0.366.0 → 0.367.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.
@@ -133,6 +133,10 @@ import {
133
133
  tenantRetentionOverrideEntity,
134
134
  } from "@cosmicdrift/kumiko-bundled-features/data-retention";
135
135
  import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
136
+ import {
137
+ tenantMembershipEntity,
138
+ tenantMembershipsTable,
139
+ } from "@cosmicdrift/kumiko-bundled-features/tenant";
136
140
  import {
137
141
  createUserFeature,
138
142
  USER_STATUS,
@@ -144,7 +148,7 @@ import {
144
148
  runForgetCleanup,
145
149
  runUserExport,
146
150
  } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
147
- import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
151
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
148
152
  import { EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
149
153
  import {
150
154
  setupTestStack,
@@ -185,23 +189,7 @@ beforeAll(async () => {
185
189
  await unsafeCreateEntityTable(stack.db, noteEntity);
186
190
  await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
187
191
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
188
- await asRawClient(stack.db).unsafe(`
189
- CREATE TABLE IF NOT EXISTS read_tenant_memberships (
190
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
191
- tenant_id UUID NOT NULL,
192
- user_id TEXT NOT NULL,
193
- version INTEGER NOT NULL DEFAULT 0,
194
- inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
195
- modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
196
- inserted_by_id TEXT,
197
- modified_by_id TEXT,
198
- is_deleted BOOLEAN NOT NULL DEFAULT false,
199
- deleted_at TIMESTAMPTZ,
200
- deleted_by_id TEXT,
201
- roles TEXT NOT NULL DEFAULT '[]',
202
- UNIQUE(user_id, tenant_id)
203
- )
204
- `);
192
+ await unsafeCreateEntityTable(stack.db, tenantMembershipEntity);
205
193
  });
206
194
 
207
195
  afterAll(async () => {
@@ -233,14 +221,11 @@ async function seedAlice(opts: { gracePeriodEnd?: Instant; status?: string } = {
233
221
  status: opts.status ?? USER_STATUS.Active,
234
222
  gracePeriodEnd: opts.gracePeriodEnd ?? null,
235
223
  });
236
- await asRawClient(stack.db).unsafe(
237
- `
238
- INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
239
- VALUES ($1, $2, '["Member"]')
240
- ON CONFLICT DO NOTHING
241
- `,
242
- [TENANT_A, ALICE_ID],
243
- );
224
+ await seedRow(stack.db, tenantMembershipsTable, {
225
+ tenantId: TENANT_A,
226
+ userId: ALICE_ID,
227
+ roles: '["Member"]',
228
+ });
244
229
  }
245
230
 
246
231
  async function seedNote(id: string, title: string): Promise<void> {
@@ -106,7 +106,7 @@ Requires: `delivery`, `secrets`
106
106
 
107
107
  ## channel-email
108
108
 
109
- Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails.
109
+ Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
110
110
 
111
111
  Category: notifications
112
112
 
@@ -206,7 +206,7 @@ Exposes APIs: `retention.policyFor`
206
206
 
207
207
  ## delivery
208
208
 
209
- The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.
209
+ The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
210
210
 
211
211
  Category: notifications
212
212
 
@@ -413,7 +413,7 @@ German UI and mail copy for framework screens. Opt-in; not included by includeBu
413
413
 
414
414
  ## mail-foundation
415
415
 
416
- Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences.
416
+ Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.
417
417
 
418
418
  Category: notifications
419
419
 
@@ -538,7 +538,7 @@ Requires: `template-resolver`
538
538
 
539
539
  ## renderer-mail-html
540
540
 
541
- A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
541
+ A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout, and an optional `siteUrl` resolver supplies the site link of the card when the tenant branding has no site URL (without either, the link is left out). The brand row and `data-resource="logo"` images use the branding logo upload, or the branding logo https address when no file is uploaded. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
542
542
 
543
543
  Requires: `renderer-foundation`, `template-resolver`
544
544
 
@@ -1,6 +1,5 @@
1
1
  import { unsafeReadRetrying } from "../query.js";
2
2
  import { tableExists } from "../schema-inspection.js";
3
- import { SOFT_DELETE_LIVE_ROW_PREDICATE } from "../table-builder.js";
4
3
  export async function selectUserByEmail(db, email) {
5
4
  const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
6
5
  FROM read_users
@@ -27,11 +26,12 @@ export async function selectAllTenants(db) {
27
26
  }
28
27
  // Column names are fixed fragments; every filter value is a bound parameter.
29
28
  // template-resolver is an optional feature — without its table the answer is
30
- // "no templates", not an error.
29
+ // "no templates", not an error. The template entity has no soft delete, so the
30
+ // table has no is_deleted column to filter on.
31
31
  export async function selectTemplateResources(db, filter) {
32
32
  if (!(await tableExists(db, "read_template_resources")))
33
33
  return [];
34
- const conditions = ["tenant_id = $1", SOFT_DELETE_LIVE_ROW_PREDICATE];
34
+ const conditions = ["tenant_id = $1"];
35
35
  const params = [filter.tenantId];
36
36
  for (const [column, value] of [
37
37
  ["slug", filter.slug],
@@ -26,6 +26,8 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
26
26
  readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
27
27
  readonly localizedTemplate?: () => NotificationLocalizedTemplate;
28
28
  readonly renamedFrom?: readonly string[];
29
+ readonly systemOnly?: boolean;
30
+ readonly unsubscribe?: boolean;
29
31
  }), definition?: {
30
32
  readonly trigger?: {
31
33
  readonly on: NameOrRef;
@@ -39,6 +41,8 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
39
41
  readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
40
42
  readonly localizedTemplate?: () => NotificationLocalizedTemplate;
41
43
  readonly renamedFrom?: readonly string[];
44
+ readonly systemOnly?: boolean;
45
+ readonly unsubscribe?: boolean;
42
46
  }): void;
43
47
  translations(def: TranslationsDef): void;
44
48
  defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {
@@ -220,6 +220,8 @@ export function buildConfigEventsJobsMethods(state, name) {
220
220
  ...(resolvedDefinition.renamedFrom !== undefined
221
221
  ? { renamedFrom: resolvedDefinition.renamedFrom }
222
222
  : {}),
223
+ ...(resolvedDefinition.systemOnly === true ? { systemOnly: true } : {}),
224
+ ...(resolvedDefinition.unsubscribe === true ? { unsubscribe: true } : {}),
223
225
  };
224
226
  },
225
227
  translations(def) {
@@ -108,6 +108,9 @@ const RESERVED_TEMPLATE_VARIABLE_NAMES = new Set([
108
108
  "link",
109
109
  "money",
110
110
  "date",
111
+ "richText",
112
+ "stepIs",
113
+ "percent",
111
114
  ]);
112
115
  const KEBAB_NOTIFICATION_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
113
116
  function isTemplateVariableName(value) {
@@ -23,6 +23,9 @@ export declare const templateVariable: {
23
23
  readonly date: (opts?: CommonOptions & {
24
24
  readonly example?: string;
25
25
  }) => TemplateScalarVariableDef;
26
+ readonly percent: (opts?: CommonOptions & {
27
+ readonly example?: number;
28
+ }) => TemplateScalarVariableDef;
26
29
  readonly list: (item: Readonly<Record<string, TemplateScalarVariableDef>>, opts?: CommonOptions & {
27
30
  readonly example?: readonly Readonly<Record<string, unknown>>[];
28
31
  }) => TemplateListVariableDef;
@@ -16,6 +16,7 @@ export const templateVariable = {
16
16
  type: "date",
17
17
  ...opts,
18
18
  }),
19
+ percent: (opts = {}) => ({ type: "percent", ...opts }),
19
20
  list: (item, opts = {}) => ({ type: "list", item, ...opts }),
20
21
  };
21
22
  const LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
@@ -48,6 +49,8 @@ function scalarToZod(def) {
48
49
  return moneySchema;
49
50
  case "date":
50
51
  return dateSchema;
52
+ case "percent":
53
+ return z.number().finite();
51
54
  }
52
55
  }
53
56
  function variableToZod(def) {
@@ -19,6 +19,7 @@ export type CreateFileRefInput = {
19
19
  readonly entityId?: string;
20
20
  };
21
21
  };
22
+ export declare function filesUnavailableMessage(caller: string): string;
22
23
  export declare function createFileRefFromBytes(ctx: CreateFileRefContext, input: CreateFileRefInput): Promise<WriteResult<CreatedFileRef>>;
23
24
  export type UploadFileBytesContext = Pick<CreateFileRefContext, "db" | "files" | "registry" | "user">;
24
25
  export declare function uploadFileBytesBeforeTransaction(ctx: UploadFileBytesContext, input: CreateFileRefInput): Promise<BeforeTransactionResult<UploadedFileBytes>>;
@@ -4,6 +4,11 @@ import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcc
4
4
  import { fileRefsTable } from "./file-ref-table.js";
5
5
  import { readFilesRouteOptions } from "./file-routes.js";
6
6
  import { assertSafeStorageKey } from "./types.js";
7
+ // The handler context has no HTTP/job/seed marker to name, so the message states the actual
8
+ // cause: the dispatcher's AppContext carries no file-provider resolver.
9
+ export function filesUnavailableMessage(caller) {
10
+ return `${caller}: ctx.files is unavailable because no file-provider resolver is wired on this dispatcher context. Mount a file-provider-* feature and build the dispatcher context with withFileProviderResolver (HTTP, job and seed dispatchers each need it)`;
11
+ }
7
12
  // Server-side counterpart of POST /files for handlers/jobs that produce bytes
8
13
  // themselves (generated PDFs, exports). Same validation, storage layout and
9
14
  // fileRef.created event as an upload; the tenant is always ctx.user.tenantId.
@@ -11,7 +16,7 @@ export async function createFileRefFromBytes(ctx, input) {
11
16
  const { files } = ctx;
12
17
  if (!files) {
13
18
  return writeFailure(new InternalError({
14
- message: "createFileRefFromBytes: ctx.files is unavailable — mount a file-provider feature to store files",
19
+ message: filesUnavailableMessage("createFileRefFromBytes"),
15
20
  }));
16
21
  }
17
22
  const outcome = await storeFileAsFileRef({
@@ -48,7 +53,7 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
48
53
  const { files } = ctx;
49
54
  if (!files) {
50
55
  return writeFailure(new InternalError({
51
- message: "uploadFileBytesBeforeTransaction: ctx.files is unavailable — mount a file-provider feature to store files",
56
+ message: filesUnavailableMessage("uploadFileBytesBeforeTransaction"),
52
57
  }));
53
58
  }
54
59
  const outcome = await writeAcceptedFileBytes({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.366.0",
3
+ "version": "0.367.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -210,8 +210,8 @@
210
210
  "./package.json": "./package.json"
211
211
  },
212
212
  "dependencies": {
213
- "@cosmicdrift/kumiko-http": "0.366.0",
214
- "@cosmicdrift/kumiko-types": "0.366.0",
213
+ "@cosmicdrift/kumiko-http": "0.367.0",
214
+ "@cosmicdrift/kumiko-types": "0.367.0",
215
215
  "bullmq": "^5.76.7",
216
216
  "bun-types": "^1.3.13",
217
217
  "hono": "^4.13.1",
@@ -227,7 +227,7 @@
227
227
  "zod": "^4.4.3"
228
228
  },
229
229
  "devDependencies": {
230
- "@cosmicdrift/kumiko-dispatcher-live": "0.366.0",
230
+ "@cosmicdrift/kumiko-dispatcher-live": "0.367.0",
231
231
  "bun-types": "^1.3.13",
232
232
  "pino-pretty": "^13.1.3"
233
233
  },
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "fix",
5
+ "title": "Seed context finds template resources again",
6
+ "detail": "findTemplateResources in the seed context filtered on an is_deleted column that the template resource table does not have, so every seed that read template resources failed. The query now reads the live rows only."
7
+ },
8
+ {
9
+ "version": "0.367.0",
10
+ "type": "improvement",
11
+ "title": "Seeds can store files and see why a write failed",
12
+ "detail": "ProdSeedFn deps now carry createFileRef(tenantId, input), which stores bytes as a fileRef with the same validation and storage layout as POST /files, without a helper write handler. A seed write that fails with internal_error now names the handler and the cause message instead of a bare \"internal error\". The seed dispatcher gets the app's dispatcherOptions (and the job runner in all-in-one mode), like the HTTP dispatcher. The \"ctx.files is unavailable\" message of createFileRefFromBytes names the real cause: the dispatcher context has no file-provider resolver."
13
+ },
2
14
  {
3
15
  "version": "0.366.0",
4
16
  "type": "breaking",