@cosmicdrift/kumiko-framework 0.394.0 → 0.396.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.
@@ -203,14 +203,14 @@ describe("entity-level write: create/update/delete blocked on foreign rows", ()
203
203
  });
204
204
 
205
205
  test("TeamMember eng CANNOT update the ops row (not visible → not modifiable)", async () => {
206
- // The row exists but entity-level ownership rejects the update.
207
- // version is guessed — doesn't matter, the ownership check fires first.
206
+ // The row exists, but a row the caller cannot read answers like a missing
207
+ // one, so the write does not reveal it. The guessed version never matters.
208
208
  const err = await stack.http.writeErr(
209
209
  "contracts:write:contract:update",
210
210
  { id: opsRowId, version: 1, changes: { propA: "hack" } },
211
211
  teamMemberEng,
212
212
  );
213
- expectErrorIncludes(err, "ownership_denied");
213
+ expectErrorIncludes(err, "not_found");
214
214
  });
215
215
 
216
216
  test("TeamMember eng CANNOT move their row to another team (teamId rewrite blocked)", async () => {
@@ -473,7 +473,7 @@ Secrets:
473
473
 
474
474
  ## managed-pages
475
475
 
476
- 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.
476
+ Tenant-editable, server-rendered public pages with per-tenant branding. Stores one `page` (Markdown or editor-document HTML body, per `bodyFormat`) 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 document bodies through the template-resolver document sanitizer, 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.
477
477
 
478
478
  Category: content
479
479
 
@@ -673,7 +673,7 @@ Category: data
673
673
 
674
674
  ## template-resolver
675
675
 
676
- Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. The same list decides delivery: a tenant override is served (mail, PDF, layouts, text blocks) only while its template is listed in tenantEditable; otherwise the system version is served. A locked override stays stored, a SystemAdmin sees it in the overview marked as not delivered, and listing the template makes it effective again. Tenant rows of a collection mounted with `access` are always served. A collection mounted without `access` is SystemAdmin only. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
676
+ Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. The same list decides delivery: a tenant override is served (mail, PDF, layouts, text blocks) only while its template is listed in tenantEditable; otherwise the system version is served. A locked override stays stored, a SystemAdmin sees it in the overview marked as not delivered, and listing the template makes it effective again. Tenant rows of a collection mounted with `access` are always served. A collection mounted without `access` is SystemAdmin only. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area that stores text, `document` a WYSIWYG document editor that stores sanitized HTML (bold, italic, headings, lists, links, variable chips). Both offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
677
677
 
678
678
  Category: notifications
679
679
 
package/agents/widgets.md CHANGED
@@ -77,7 +77,6 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
77
77
  | `ResizablePanelGroup` | |
78
78
  | `ResultPanel` | Result section for a calculator: SectionCard with an empty state (Banner) or a metric list (DetailList) plus optional extras (table, notes). |
79
79
  | `ResultTable` | Static, prop-driven result table for calculated rows (tranches, scenarios). |
80
- | `RichContentEditor` | |
81
80
  | `ScreenWidthProvider` | |
82
81
  | `SectionCard` | Section with a title and optional action slot (range switcher, filter) — replaces repeated `<section className={CARD}>…<h2>` blocks in custom screens. |
83
82
  | `SelectField` | Select field = Field + Input(kind:"select"). |
@@ -115,6 +114,7 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
115
114
  | --- | --- |
116
115
  | `useBrowserNavApi` | React hook that builds a NavApi from browser history. |
117
116
  | `useBrowserTokensApi` | Builds a TokensApi for the browser. |
117
+ | `useDispatchTarget` | Recommended production dispatch. |
118
118
  | `useDispatcher` | |
119
119
  | `useDispatcherStatus` | |
120
120
  | `useDraft` | Form state for calculator screens: a draft object plus `patch` for partial updates, and `field(name)` that provides `{ id, name, value, onChange }` props ready for field widgets (NumberField/MoneyField/PercentField). |
@@ -1,3 +1,4 @@
1
+ import type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
1
2
  import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
3
  import type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
3
4
  import type { ErasedSubjectHandling } from "../crypto/pii-field-encryption.ts";
@@ -44,6 +45,7 @@ export type ExecutorContext = {
44
45
  readonly owned: boolean;
45
46
  readonly streamVersion: number;
46
47
  }>;
48
+ readonly isRowReadable: (id: EntityId, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined) => Promise<boolean>;
47
49
  readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
48
50
  kind: "pass";
49
51
  } | {
@@ -10,6 +10,7 @@ import { isStreamArchived } from "../event-store/index.js";
10
10
  import { assertUnreachable } from "../utils/index.js";
11
11
  import { rehydrateCompoundTypes } from "./compound-types.js";
12
12
  import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
13
+ import { resolveReadOwnership } from "./event-store-executor-read-where.js";
13
14
  import { constraintOf, isUniqueViolation } from "./pg-error.js";
14
15
  import { toSnakeCase } from "./table-builder.js";
15
16
  import { columnHandleName, schemaTableName } from "./table-introspection.js";
@@ -355,6 +356,21 @@ export function buildExecutorContext(table, entity, options) {
355
356
  const version = first["__streamVersion"];
356
357
  return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
357
358
  }
359
+ // Same read predicate detail applies (access.read plus the parent-ref gate), evaluated
360
+ // for one row. The row's own isDeleted is not filtered because restore targets a
361
+ // soft-deleted row; includeDeleted below only concerns the parent host. Writers use it
362
+ // to treat an unreadable row as nonexistent.
363
+ async function isRowReadable(id, user, db, parentVisibility) {
364
+ const ownership = resolveReadOwnership({ entity, table }, String(schemaTableName(table)), user, db, parentVisibility, { includeDeleted: false, narrowTypes: undefined });
365
+ if (ownership.kind === "pass")
366
+ return true;
367
+ if (ownership.kind === "empty")
368
+ return false;
369
+ const tableName = String(schemaTableName(table));
370
+ const { whereParts, params } = buildRawIdWhere(db, { id }, ownership);
371
+ const rows = await executeRawQueryRead(tenantDbRunner(db), `SELECT 1 AS "__readable" FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`, params);
372
+ return rows.length > 0;
373
+ }
358
374
  // Row and MAX(version) in ONE statement: two reads can pair a post-write
359
375
  // version with a pre-write row, and update({version: detail.version}) would
360
376
  // then pass the optimistic lock and overwrite the newer write (lost update).
@@ -436,6 +452,7 @@ export function buildExecutorContext(table, entity, options) {
436
452
  loadById,
437
453
  assertStreamWritable,
438
454
  probeOwnershipWithStreamVersion,
455
+ isRowReadable,
439
456
  loadDetailSnapshot,
440
457
  loadExpectSnapshot,
441
458
  encryptForStorage,
@@ -24,8 +24,17 @@ import { tenantDbWriteRunner } from "./tenant-db-runner.js";
24
24
  // store-executor, split out of event-store-executor.ts. They read an explicit
25
25
  // ExecutorContext instead of capturing the factory's local scope directly.
26
26
  export function createWriteVerbs(ctx) {
27
- const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
27
+ const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, isRowReadable, loadExpectSnapshot, } = ctx;
28
28
  const entityInstructionFieldNames = instructionFieldNames(entity);
29
+ // A row the caller cannot read is reported as missing, like detail does, so a write
30
+ // can neither change it nor echo its content back. Framework system writes skip the
31
+ // check as they do for forget: their role never appears in a per-role read map.
32
+ async function unreadableRowFailure(id, user, db, parentVisibility) {
33
+ if (isFrameworkSystemUser(user) || (await isRowReadable(id, user, db, parentVisibility))) {
34
+ return undefined;
35
+ }
36
+ return writeFailure(new NotFoundError(entityName, id));
37
+ }
29
38
  // update and delete funnel through here, so custom handlers and nested writes cannot
30
39
  // bypass the lock whatever they return. Setting the lock (unlocked row) passes.
31
40
  // Framework system writes that name a writePurpose (retention, erasure) are exempt:
@@ -487,6 +496,9 @@ export function createWriteVerbs(ctx) {
487
496
  });
488
497
  if (!previous)
489
498
  return writeFailure(new NotFoundError(entityName, payload.id));
499
+ const unreadable = await unreadableRowFailure(payload.id, user, db, updateOptions?.parentVisibility);
500
+ if (unreadable)
501
+ return unreadable;
490
502
  const streamTenantId = updateOptions?.streamTenantId;
491
503
  const streamTenantMismatch = streamTenantOverrideFailure(db, previous, streamTenantId);
492
504
  if (streamTenantMismatch)
@@ -561,6 +573,9 @@ export function createWriteVerbs(ctx) {
561
573
  const existing = await loadById(payload.id, db);
562
574
  if (!existing)
563
575
  return writeFailure(new NotFoundError(entityName, payload.id));
576
+ const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
577
+ if (unreadable)
578
+ return unreadable;
564
579
  const streamTenantId = options?.streamTenantId;
565
580
  const streamTenantMismatch = streamTenantOverrideFailure(db, existing, streamTenantId);
566
581
  if (streamTenantMismatch)
@@ -645,6 +660,9 @@ export function createWriteVerbs(ctx) {
645
660
  const raw = await db.fetchOne(table, { id: payload.id });
646
661
  if (!raw)
647
662
  return writeFailure(new NotFoundError(entityName, payload.id));
663
+ const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
664
+ if (unreadable)
665
+ return unreadable;
648
666
  const streamTenantId = options?.streamTenantId;
649
667
  const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
650
668
  if (streamTenantMismatch)
@@ -716,6 +734,11 @@ export function createWriteVerbs(ctx) {
716
734
  }
717
735
  const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
718
736
  const streamTenantId = options?.streamTenantId;
737
+ // Before loadSoftDeletedRow: its not_deleted answer would confirm that an unreadable
738
+ // live row exists.
739
+ const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
740
+ if (unreadable)
741
+ return unreadable;
719
742
  const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
720
743
  if ("failure" in target)
721
744
  return target.failure;
@@ -289,6 +289,7 @@ export function defineEntityWriteHandler(name, entity, options) {
289
289
  // they rely on to intentionally force a fresh event/ciphertext.
290
290
  return executor.update(payload, event.user, db, {
291
291
  streamTenantId,
292
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
292
293
  skipUnchanged: true,
293
294
  preSave: runPreSave &&
294
295
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -301,7 +302,10 @@ export function defineEntityWriteHandler(name, entity, options) {
301
302
  const db = dbFor(ctx);
302
303
  const payload = event.payload; // @cast-boundary engine-payload
303
304
  const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
304
- return executor.delete(payload, event.user, db, { streamTenantId });
305
+ return executor.delete(payload, event.user, db, {
306
+ streamTenantId,
307
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
308
+ });
305
309
  };
306
310
  break;
307
311
  case "restore":
@@ -310,7 +314,10 @@ export function defineEntityWriteHandler(name, entity, options) {
310
314
  const db = dbFor(ctx);
311
315
  const payload = event.payload; // @cast-boundary engine-payload
312
316
  const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
313
- return executor.restore(payload, event.user, db, { streamTenantId });
317
+ return executor.restore(payload, event.user, db, {
318
+ streamTenantId,
319
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
320
+ });
314
321
  };
315
322
  break;
316
323
  default:
@@ -31,6 +31,10 @@ export function deleteFailureResponseFor(error) {
31
31
  const status = isOneOf(ERROR_STATUSES, error.httpStatus) ? error.httpStatus : 500;
32
32
  return { status, code: status === 404 ? "not_found" : error.code };
33
33
  }
34
+ // 401 (not 403) lets the client tell a revoked or expired session from a missing permission.
35
+ function unauthenticatedUploadResponse(c) {
36
+ return c.json({ error: "unauthenticated: sign in to upload files", code: "unauthenticated" }, 401);
37
+ }
34
38
  // 15 minutes — long enough for a download to start, short enough that a
35
39
  // leaked URL (e.g. from a browser history screenshot) isn't a long-lived
36
40
  // credential (see "Signed-URL default expiry" in core-files.md).
@@ -144,6 +148,10 @@ export function createFileRoutes(options) {
144
148
  // POST /files — multipart upload.
145
149
  api.post("/files", uploadBodyLimit, async (c) => {
146
150
  const user = getUser(c);
151
+ // Anonymous visitors share one user id and cannot bind pending uploads (bind-pending-file-refs),
152
+ // so an anonymous upload could never be used; 401 lets the client tell an ended session apart.
153
+ if (isAnonymousSessionUser(user))
154
+ return unauthenticatedUploadResponse(c);
147
155
  const body = await c.req.parseBody();
148
156
  const file = body["file"];
149
157
  if (!file || !(file instanceof File)) {
@@ -161,11 +169,11 @@ export function createFileRoutes(options) {
161
169
  }
162
170
  // Before any bytes are written, like the record check below. An unresolvable screen falls
163
171
  // through to storeFileAsFileRef, which answers 400; a known one must be submittable by the
164
- // caller. Anonymous callers cannot bind pending uploads, so they get no upload either.
172
+ // caller.
165
173
  if (actionForm !== undefined &&
166
174
  options.registry !== undefined &&
167
175
  options.registry.getScreen(actionForm)?.type === "actionForm" &&
168
- (isAnonymousSessionUser(user) || !mayUseActionForm(options.registry, actionForm, user))) {
176
+ !mayUseActionForm(options.registry, actionForm, user)) {
169
177
  return c.json({ error: "forbidden: no access to this form" }, 403);
170
178
  }
171
179
  if (entityId !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.394.0",
3
+ "version": "0.396.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,7 +203,7 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.394.0",
206
+ "@cosmicdrift/kumiko-types": "0.396.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "hono": "^4.13.1",
209
209
  "i18next": "^26.1.0",
@@ -225,7 +225,7 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.394.0",
228
+ "@cosmicdrift/kumiko-testing": "0.396.0",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.2",
231
231
  "meilisearch": "^0.58.0",
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.396.0",
4
+ "type": "breaking",
5
+ "title": "Entity writes on a row the caller cannot read fail with not_found",
6
+ "detail": "update, delete, restore and forget only checked access.write, so on an entity with an owner access.read rule and no access.write any tenant member with handler access could change or delete another member's row, and delete returned the decrypted row. They now apply the same access.read rule and parentRef host-visibility gate as detail and answer not_found, so the row's existence stays hidden. Framework system writes are exempt. A write the caller could not read but that failed the write rule before now reports not_found instead of ownership_denied.",
7
+ "migration": "Writes on rows the caller cannot read now fail with not_found; give such writers read access or run them as system. Custom handlers that call the executor directly get the parentRef gate by passing parentVisibility: { entities: ctx.registry.getAllEntities() }, as the generated handlers do."
8
+ },
9
+ {
10
+ "version": "0.395.0",
11
+ "type": "fix",
12
+ "title": "A file upload with an ended session ends up at the login",
13
+ "detail": "POST /files answered an anonymous caller (revoked or expired session) with 403 forbidden or a validation error, so the browser showed an upload error instead of the login. Anonymous callers can never use an upload (they cannot bind pending files), so every upload without a session now answers 401 with code unauthenticated before the body is read, and the file upload fields signal the ended session like any other request. Signed-in callers without permission still get 403."
14
+ },
2
15
  {
3
16
  "version": "0.394.0",
4
17
  "type": "fix",