@cosmicdrift/kumiko-framework 0.371.1 → 0.372.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 (65) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/custom-handlers.md +3 -5
  3. package/agents/examples/embedded-entity-form.md +1 -1
  4. package/agents/examples/pdf-audit-export.md +1 -1
  5. package/agents/examples/user-data-rights.md +176 -0
  6. package/dist/api/jwt.js +2 -1
  7. package/dist/api/observability-middleware.d.ts +1 -2
  8. package/dist/api/observability-middleware.js +9 -8
  9. package/dist/api/pii-leak-guard.js +2 -1
  10. package/dist/api/route-label.d.ts +3 -0
  11. package/dist/api/route-label.js +17 -0
  12. package/dist/api/server.d.ts +1 -2
  13. package/dist/api/server.js +3 -5
  14. package/dist/config/index.d.ts +5 -0
  15. package/dist/db/event-store-executor.d.ts +2 -1
  16. package/dist/db/index.d.ts +2 -2
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/located-timestamp.d.ts +7 -0
  19. package/dist/db/query.d.ts +1 -1
  20. package/dist/db/query.js +8 -2
  21. package/dist/db/table-builder.d.ts +45 -11
  22. package/dist/db/tenant-db.js +3 -1
  23. package/dist/engine/entity-handlers.d.ts +3 -3
  24. package/dist/engine/factories.d.ts +2 -1
  25. package/dist/engine/factories.js +2 -0
  26. package/dist/engine/membership-roles.d.ts +1 -0
  27. package/dist/engine/membership-roles.js +8 -0
  28. package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
  29. package/dist/entrypoint/index.d.ts +1 -0
  30. package/dist/entrypoint/index.js +0 -2
  31. package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
  32. package/dist/files/create-file-ref-from-bytes.js +12 -6
  33. package/dist/files/create-file-ref.d.ts +20 -2
  34. package/dist/files/create-file-ref.js +10 -1
  35. package/dist/files/feature.d.ts +1 -0
  36. package/dist/files/feature.js +25 -0
  37. package/dist/files/file-handle.d.ts +4 -1
  38. package/dist/files/file-handle.js +30 -4
  39. package/dist/files/file-ref-entity.d.ts +7 -7
  40. package/dist/files/file-ref-table.d.ts +7 -7
  41. package/dist/files/file-routes.js +6 -1
  42. package/dist/files/index.d.ts +3 -1
  43. package/dist/files/index.js +3 -1
  44. package/dist/files/provider-resolver.js +3 -1
  45. package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
  46. package/dist/files/sweep-orphaned-uploads.js +53 -0
  47. package/dist/files/upload-ledger.d.ts +10 -0
  48. package/dist/files/upload-ledger.js +47 -0
  49. package/dist/i18n/index.d.ts +1 -1
  50. package/dist/i18n/index.js +1 -1
  51. package/dist/i18n/request-locale.d.ts +15 -4
  52. package/dist/i18n/request-locale.js +26 -9
  53. package/dist/jobs/job-runner.js +2 -4
  54. package/dist/pipeline/dispatch-shared.js +2 -4
  55. package/dist/pipeline/index.d.ts +1 -0
  56. package/dist/pipeline/index.js +1 -0
  57. package/dist/rate-limit/index.d.ts +1 -0
  58. package/dist/rate-limit/index.js +1 -0
  59. package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
  60. package/dist/rate-limit/unlimited-resolver.js +21 -0
  61. package/dist/schema-cli.js +46 -5
  62. package/package.json +5 -5
  63. package/src/changes.json +42 -0
  64. package/src/scripts/codemod/README.md +22 -0
  65. package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
2
  import { type EventStoreExecutor } from "../db/event-store-executor.ts";
3
- import { type EntityTable } from "../db/table-builder.ts";
3
+ import { type EntityRow, type EntityTable } from "../db/table-builder.ts";
4
4
  import type { EntityCrudRegistrar, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions } from "./types/entity-handlers.ts";
5
5
  import type { AccessRule, EntityDefinition, EscapeHatchDeclaration, QueryHandlerDef, WriteHandlerDef } from "./types/index.ts";
6
6
  export type { EntityCrudHandlerDefaults, EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "./types/entity-handlers.ts";
@@ -53,9 +53,9 @@ export declare function defineEntityDeleteHandler(entityName: string, entity: En
53
53
  export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
54
54
  export declare function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
55
55
  export declare function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
56
- export declare function createEntityExecutor(entityName: string, entity: EntityDefinition): {
56
+ export declare function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): {
57
57
  readonly table: EntityTable;
58
- readonly executor: EventStoreExecutor;
58
+ readonly executor: EventStoreExecutor<EntityRow<E>>;
59
59
  };
60
60
  export declare function defineProjectionQueryHandler(name: string, projectionQualifiedName: string, options: {
61
61
  access: AccessRule;
@@ -32,6 +32,7 @@ export declare function createBooleanField<R extends true | false = false>(overr
32
32
  required?: R;
33
33
  }): BooleanFieldDef & {
34
34
  required: R;
35
+ default: boolean;
35
36
  };
36
37
  export declare function createSelectField<const TOptions extends readonly string[], R extends true | false = false>(opts: {
37
38
  options: TOptions;
@@ -171,7 +172,7 @@ export declare function createFileField(overrides?: Partial<Omit<FileFieldDef, "
171
172
  export declare function createImageField(overrides?: Partial<Omit<ImageFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): ImageFieldDef;
172
173
  export declare function createFilesField(overrides?: Partial<Omit<FilesFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FilesFieldDef;
173
174
  export declare function createImagesField(overrides?: Partial<Omit<ImagesFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): ImagesFieldDef;
174
- export declare function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
175
+ export declare function createEntity<const F, const T extends EntityTenancy = "tenant">(def: {
175
176
  readonly table?: string;
176
177
  readonly fields: F;
177
178
  readonly description?: string;
@@ -378,6 +378,8 @@ export function createImagesField(overrides) {
378
378
  // Issue um Constraint-Inferenz auf strukturellen Records. Stattdessen
379
379
  // validieren wir die Conformance via conditional return type: wenn F
380
380
  // nicht FieldsMap-compat ist, kollabiert der Return zu `never`.
381
+ // `const F`: inline field literals (`{ type: "reference", ... }`) keep their
382
+ // `type` literal without `as const`; otherwise it widens to string → `never`.
381
383
  export function createEntity(def) {
382
384
  return {
383
385
  softDelete: false,
@@ -2,4 +2,5 @@ export declare const FORBIDDEN_MEMBERSHIP_ROLES: ReadonlySet<string>;
2
2
  export declare function isForbiddenMembershipRole(role: string): boolean;
3
3
  export declare function findForbiddenMembershipRole(roles: readonly string[]): string | undefined;
4
4
  export declare function stripForbiddenMembershipRoles(roles: readonly string[]): readonly string[];
5
+ export declare function stripReservedTokenRoles(roles: readonly string[]): readonly string[];
5
6
  export declare function buildSessionRoles(globalRoles: readonly string[], membershipRoles: readonly string[]): readonly string[];
@@ -38,6 +38,14 @@ const NON_MINTABLE_GLOBAL_ROLES = new Set([
38
38
  function stripNonMintableGlobalRoles(roles) {
39
39
  return roles.filter((role) => !NON_MINTABLE_GLOBAL_ROLES.has(role));
40
40
  }
41
+ // Applied on JWT verify: a token signed with the right key (leaked or foreign)
42
+ // must not carry the in-process System identity or the "all" pseudo-role.
43
+ // "anonymous" stays: it only narrows access (openToAll excludes it), and
44
+ // stripping it would turn the token into an authenticated user with no roles.
45
+ const RESERVED_TOKEN_ROLES = new Set([...access.all, SYSTEM_ROLE]);
46
+ export function stripReservedTokenRoles(roles) {
47
+ return roles.filter((role) => !RESERVED_TOKEN_ROLES.has(role));
48
+ }
41
49
  // Single mint path for session roles: merges globalRoles with the stripped
42
50
  // membership portion and dedupes. Every place that builds a session's roles
43
51
  // from a membership should go through this instead of repeating
@@ -1,11 +1,17 @@
1
1
  // Tenant-level counterpart to projectAppSchemaForRoles: removes the UI surface
2
2
  // (screens, navs, content collections, workspaces) of features the tenant has
3
3
  // switched off via r.toggleable(). Entities and translations stay — enabled
4
- // features may still reference them. Run BEFORE the role projection: it keeps
4
+ // features may still reference them. Screens that declare a `fallback` (plus
5
+ // the same-feature screens those fallbacks name) are the exception: their
6
+ // visibleWhen probe is rejected by the feature gate for this tenant, which is
7
+ // what makes the renderer show the fallback (e.g. an upgrade notice). Dropping
8
+ // them would also drop every nav of another feature pointing at them, so the
9
+ // fallback could never be reached. Run BEFORE the role projection: it keeps
5
10
  // only targets that exist in the schema, so once the disabled screens/navs are
6
11
  // gone it also drops every action, redirect, nav and workspace member that
7
12
  // pointed at them. The one reference it does not resolve is a nav `parent`
8
13
  // pointing at a missing nav; that is pruned here.
14
+ import { resolveScreenTargetQn } from "./boot-validator/screens.js";
9
15
  import { isToggleableFeature } from "./effective-features.js";
10
16
  import { isValidQn, qualifyEntityName } from "./qualified-name.js";
11
17
  // Sorted, so the result doubles as a stable cache key. The trial gate is
@@ -50,6 +56,28 @@ function collectOrphanedNavQns(features, removedNavQns) {
50
56
  }
51
57
  return orphaned;
52
58
  }
59
+ // Fail-closed for the kept screens: the server gate rejects their handlers and
60
+ // queries for the tenant, only the schema entry stays so the fallback can render.
61
+ function screensKeptForFallback(feature) {
62
+ const qnOf = (screenId) => qualifyEntityName(feature.featureName, "screen", screenId);
63
+ const byQn = new Map(feature.screens.map((screen) => [qnOf(screen.id), screen]));
64
+ const kept = new Set();
65
+ const pending = feature.screens.filter((screen) => screen.fallback !== undefined);
66
+ for (const screen of pending)
67
+ kept.add(qnOf(screen.id));
68
+ while (pending.length > 0) {
69
+ const fallback = pending.pop()?.fallback;
70
+ if (fallback === undefined)
71
+ continue;
72
+ const targetQn = resolveScreenTargetQn(feature.featureName, fallback);
73
+ const target = byQn.get(targetQn);
74
+ if (target === undefined || kept.has(targetQn))
75
+ continue;
76
+ kept.add(targetQn);
77
+ pending.push(target);
78
+ }
79
+ return feature.screens.filter((screen) => kept.has(qnOf(screen.id)));
80
+ }
53
81
  export function stripDisabledFeaturesFromAppSchema(schema, registry, disabledFeatureNames) {
54
82
  if (disabledFeatureNames.length === 0)
55
83
  return schema;
@@ -66,7 +94,7 @@ export function stripDisabledFeaturesFromAppSchema(schema, registry, disabledFea
66
94
  const features = schema.features.map((feature) => {
67
95
  if (disabled.has(feature.featureName)) {
68
96
  const { navs: _navs, contentCollections: _collections, workspaces: _ws, ...rest } = feature;
69
- return { ...rest, screens: [] };
97
+ return { ...rest, screens: screensKeptForFallback(feature) };
70
98
  }
71
99
  if (orphanedNavQns.size === 0 || feature.navs === undefined)
72
100
  return feature;
@@ -17,6 +17,7 @@ export type BaseEntrypointOptions = {
17
17
  readonly jwtSecret: string | JwtKeyring;
18
18
  readonly jwtIssuer?: string;
19
19
  readonly observability?: ObservabilityProvider;
20
+ /** @deprecated No effect: the HTTP route label carries no query string anymore, so there is nothing left to configure. */
20
21
  readonly observabilityOptions?: ObservabilityOptions;
21
22
  readonly dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
22
23
  readonly systemHooks?: SystemHooks;
@@ -127,7 +127,6 @@ function buildApiServer(opts, lifecycle, dispatcherOverride, jobRunner, processL
127
127
  metrics: opts.metrics,
128
128
  extraRoutes: opts.extraRoutes,
129
129
  observability: opts.observability,
130
- observabilityOptions: opts.observabilityOptions,
131
130
  dispatcherOptions,
132
131
  systemHooks: opts.systemHooks,
133
132
  eventDedup: opts.eventDedup,
@@ -147,7 +146,6 @@ function buildWorkerServer(opts, lifecycle, jobRunner) {
147
146
  lifecycle,
148
147
  processLane: "worker",
149
148
  observability: opts.observability,
150
- observabilityOptions: opts.observabilityOptions,
151
149
  dispatcherOptions,
152
150
  systemHooks: opts.systemHooks,
153
151
  eventDedup: opts.eventDedup,
@@ -10,6 +10,19 @@ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
10
10
  // cannot be probed across tenants. `fileRefRows` holds only the writer's own
11
11
  // tenant's live rows, so a missing entry is exactly those two cases. Values
12
12
  // that are not UUIDs are skipped by fileRefIdsOf and never reach this check.
13
+ //
14
+ // Within the tenant, a newly named id must not be bound to a different record:
15
+ // the file routes authorize through the record a file is bound to, so naming
16
+ // record A's file in record B would hand B's readers A's file (and a later
17
+ // removal from B would not touch it). Pending uploads (no entityId), ids the
18
+ // record already holds in any file field (checked first, even if their row is gone)
19
+ // and moves between its own fields stay allowed. The error is the same as above so the owning record is not revealed.
20
+ function isBoundToOtherRecord(row, entityName, recordId) {
21
+ const boundId = row["entityId"];
22
+ if (boundId === null || boundId === undefined)
23
+ return false;
24
+ return row["entityType"] !== entityName || boundId !== recordId;
25
+ }
13
26
  export function assertFileRefsBelongToTenant(args) {
14
27
  const { registry, result, fileRefRows } = args;
15
28
  // skip: saves of unregistered aggregates (custom executors) declare no file fields
@@ -22,11 +35,24 @@ export function assertFileRefsBelongToTenant(args) {
22
35
  // skip: without the files feature the file_refs table is not part of the schema
23
36
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
24
37
  return;
38
+ const recordId = String(result.id);
39
+ const alreadyHeld = new Set();
40
+ for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
41
+ if (!isFileField(fieldDef))
42
+ continue;
43
+ for (const fileRefId of fileRefIdsOf(result.previous[fieldName]))
44
+ alreadyHeld.add(fileRefId);
45
+ }
25
46
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
26
47
  if (!isFileField(fieldDef))
27
48
  continue;
28
49
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
29
- if (fileRefRows.has(fileRefId))
50
+ // A value the record already holds is no new binding; its file_refs row may be gone
51
+ // (forget purged the file), and sending the unchanged field back must still save.
52
+ if (alreadyHeld.has(fileRefId))
53
+ continue;
54
+ const row = fileRefRows.get(fileRefId);
55
+ if (row !== undefined && !isBoundToOtherRecord(row, result.entityName, recordId))
30
56
  continue;
31
57
  throw new ValidationError({
32
58
  fields: [
@@ -1,4 +1,5 @@
1
- import { fetchOne } from "../db/query.js";
1
+ import { fetchOne, runnerCanBeginTransaction } from "../db/query.js";
2
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
2
3
  import { FrameworkReasons, InternalError, UnprocessableError, writeFailure, } from "../errors/index.js";
3
4
  import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcceptedFileBytes, } from "./create-file-ref.js";
4
5
  import { fileRefsTable } from "./file-ref-table.js";
@@ -22,9 +23,10 @@ export async function createFileRefFromBytes(ctx, input) {
22
23
  const outcome = await storeFileAsFileRef({
23
24
  user: ctx.user,
24
25
  tenantDb: ctx.db,
26
+ rowInOwnTransaction: runnerCanBeginTransaction(tenantDbRunner(ctx.db)),
25
27
  registry: ctx.registry,
26
28
  defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
27
- writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
29
+ writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
28
30
  deleteBytes: (key) => files.ref(key).delete(),
29
31
  }, {
30
32
  fileName: input.fileName,
@@ -48,7 +50,8 @@ function fileRejectedFailure(error) {
48
50
  }
49
51
  // Pre-transaction half of createFileRefFromBytes: validates and writes the binary while no
50
52
  // transaction holds a connection. Pair it with createFileRefFromUploadedBytes inside the handler.
51
- // A crash between this phase and the commit leaves the bytes in storage; nothing sweeps them.
53
+ // A crash between this phase and the commit leaves the bytes in storage; the upload ledger sweep
54
+ // removes them after its grace period.
52
55
  export async function uploadFileBytesBeforeTransaction(ctx, input) {
53
56
  const { files } = ctx;
54
57
  if (!files) {
@@ -60,7 +63,7 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
60
63
  user: ctx.user,
61
64
  registry: ctx.registry,
62
65
  defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
63
- writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
66
+ writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
64
67
  }, {
65
68
  fileName: input.fileName,
66
69
  declaredMimeType: input.mimeType,
@@ -80,8 +83,11 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
80
83
  const committedRow = await fetchOne(ctx.db, fileRefsTable, {
81
84
  storageKey: uploaded.storageKey,
82
85
  });
83
- if (!committedRow)
84
- await files.ref(uploaded.storageKey).delete();
86
+ // skip: a committed row owns the bytes, the commit already took the ledger entry
87
+ if (committedRow)
88
+ return;
89
+ await files.ref(uploaded.storageKey).delete();
90
+ await files.forgetUploadBytes(uploaded.storageKey);
85
91
  },
86
92
  };
87
93
  }
@@ -1,8 +1,26 @@
1
1
  import { type TenantDb } from "../db/tenant-db.ts";
2
2
  import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.ts";
3
- import type { WriteFailure } from "../errors/index.ts";
3
+ import { type WriteFailure } from "../errors/index.ts";
4
4
  export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
5
- declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
5
+ declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<{
6
+ readonly id: string;
7
+ readonly tenantId: string;
8
+ readonly version: number;
9
+ readonly insertedAt: Temporal.Instant;
10
+ readonly modifiedAt: Temporal.Instant | null;
11
+ readonly insertedById: string | null;
12
+ readonly modifiedById: string | null;
13
+ readonly isDeleted?: boolean | undefined;
14
+ readonly deletedAt?: Temporal.Instant | null | undefined;
15
+ readonly deletedById?: string | null | undefined;
16
+ readonly entityId: string | null;
17
+ readonly entityType: string | null;
18
+ readonly fieldName: string | null;
19
+ readonly fileName: string;
20
+ readonly mimeType: string;
21
+ readonly size: unknown;
22
+ readonly storageKey: string;
23
+ }>;
6
24
  export declare function getFileRefExecutor(): ReturnType<typeof buildFileRefExecutor>;
7
25
  type AttachedFieldResolution = {
8
26
  readonly kind: "unattached";
@@ -1,11 +1,13 @@
1
1
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
2
2
  import { runInOwnTransaction } from "../db/tenant-db.js";
3
3
  import { isFileField, } from "../engine/types/index.js";
4
+ import { InternalError } from "../errors/index.js";
4
5
  import { generateId } from "../utils/index.js";
5
6
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
6
7
  import { fileRefsTable } from "./file-ref-table.js";
7
8
  import { resolveContentType } from "./resolve-content-type.js";
8
9
  import { buildStorageKey, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
10
+ import { takeUploadLedgerEntry } from "./upload-ledger.js";
9
11
  export const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
10
12
  // Standard entity executor for fileRef: create/delete emit fileRef.created /
11
13
  // fileRef.deleted and write file_refs via applyEntityEvent in one tx
@@ -132,6 +134,13 @@ export async function createFileRefRow(deps, uploaded) {
132
134
  }, deps.user, deps.tenantDb);
133
135
  if (!result.isSuccess)
134
136
  return { failure: result };
137
+ // Throws instead of returning a failure: a returned failure would still commit in the
138
+ // own-transaction path, leaving a fileRef row whose bytes the sweep already deleted.
139
+ if (!(await takeUploadLedgerEntry(deps.tenantDb, uploaded.storageKey))) {
140
+ throw new InternalError({
141
+ message: "upload expired: the bytes were swept before the fileRef row was created, upload again",
142
+ });
143
+ }
135
144
  return {
136
145
  fileRef: {
137
146
  id: fileRefId,
@@ -148,7 +157,7 @@ export async function storeFileAsFileRef(deps, input) {
148
157
  if (upload.kind === "rejected")
149
158
  return upload;
150
159
  // A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
151
- // crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
160
+ // crash cannot, so the upload ledger sweep removes those bytes after its grace period.
152
161
  // Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
153
162
  // A throwing projection on fileRef.created fails the create by exception, not by WriteFailure.
154
163
  const deleteUploadedBytes = async () => {
@@ -6,6 +6,7 @@ export type FilesFeatureOptions = {
6
6
  readonly privilegedRoles?: readonly string[];
7
7
  readonly unclaimedUploadReaders?: readonly string[];
8
8
  readonly maxUploadSize?: string;
9
+ readonly uploadSweepGraceHours?: number;
9
10
  };
10
11
  export declare const FILES_FEATURE: "files";
11
12
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -1,6 +1,8 @@
1
1
  import { defineFeature } from "../engine/index.js";
2
2
  import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
3
3
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
4
+ import { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
5
+ import { fileUploadLedgerTable } from "./upload-ledger.js";
4
6
  export { fileRefEntity } from "./file-ref-entity.js";
5
7
  // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
6
8
  // den Standard-Entity-Executor (file-routes.ts: executor.create/delete →
@@ -14,7 +16,14 @@ export { fileRefEntity } from "./file-ref-entity.js";
14
16
  // Entity-Projektion (für rebuildProjection). Liegt im Framework neben
15
17
  // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
16
18
  export const FILES_FEATURE = "files";
19
+ function assertValidSweepGraceHours(hours) {
20
+ if (!Number.isInteger(hours) || hours < MIN_UPLOAD_SWEEP_GRACE_HOURS) {
21
+ throw new Error(`createFilesFeature: uploadSweepGraceHours must be a whole number of hours >= ${MIN_UPLOAD_SWEEP_GRACE_HOURS}, got ${String(hours)}`);
22
+ }
23
+ }
17
24
  export function createFilesFeature(options = {}) {
25
+ const uploadSweepGraceHours = options.uploadSweepGraceHours ?? DEFAULT_UPLOAD_SWEEP_GRACE_HOURS;
26
+ assertValidSweepGraceHours(uploadSweepGraceHours);
18
27
  return defineFeature(FILES_FEATURE, (r) => {
19
28
  r.describe("Exposes the `fileRef` entity from the framework core so that uploaded files — tracked in the `file_refs` table by `createFileRoutes` — participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and tenant-lifecycle cleanup deletes all refs on tenant destroy. Upload/download routes are registered by the server bootstrap when a `file-provider-*` feature is mounted; this feature carries their access/size policy via `createFilesFeature(opts?)`.");
20
29
  r.uiHints({
@@ -24,6 +33,22 @@ export function createFilesFeature(options = {}) {
24
33
  });
25
34
  r.entity({ name: FILE_REF_ENTITY_NAME, ...fileRefEntity });
26
35
  r.writeHandler(bindReferencedFilesWrite);
36
+ r.storeTable(fileUploadLedgerTable, { reason: "coordination.file_upload_ledger" });
37
+ r.job({
38
+ name: "sweep-orphaned-uploads",
39
+ trigger: { cron: "0 * * * *" },
40
+ perTenant: true,
41
+ concurrency: "skip",
42
+ handler: async (_payload, ctx) => {
43
+ await sweepOrphanedUploads({
44
+ db: ctx.db,
45
+ files: ctx.files,
46
+ now: ctx.tz.now(),
47
+ graceHours: uploadSweepGraceHours,
48
+ log: ctx.log,
49
+ });
50
+ },
51
+ });
27
52
  return { routeOptions: options };
28
53
  });
29
54
  }
@@ -1,7 +1,10 @@
1
1
  import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
2
+ import type { FileProviderResolver, FileUploadLedger } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
3
+ import type { TenantId } from "../engine/types/identifiers.ts";
2
4
  import { type FileStorageProvider } from "./types.ts";
3
5
  export type { FileContext, FileHandle };
4
6
  export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
5
- export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
7
+ export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string, uploadLedger?: FileUploadLedger): FileContext;
8
+ export declare function createTenantFileContext(resolver: FileProviderResolver, tenantId: TenantId): FileContext;
6
9
  export declare function deriveKey(key: string, suffix: string): string;
7
10
  export declare function storageKeyStemPrefix(key: string): string;
@@ -11,6 +11,7 @@
11
11
  // `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
12
12
  // the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
13
13
  import { isDerivativeSuffix } from "../derivatives/variant-key.js";
14
+ import { InternalError } from "../errors/index.js";
14
15
  import { assertSafeStorageKey } from "./types.js";
15
16
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
16
17
  // resolved (config + s3.secretAccessKey secret read) only when a handle method
@@ -42,19 +43,39 @@ export function createFileHandle(key, getProvider) {
42
43
  // With `tenantId`, `ref` and `list` only accept keys/prefixes inside `${tenantId}/` —
43
44
  // the key space is the isolation boundary in a store shared by several tenants, and
44
45
  // a request-derived key or prefix (or "") would otherwise reach every tenant's keys.
45
- export function createFileContext(resolve, tenantId) {
46
+ export function createFileContext(resolve, tenantId, uploadLedger) {
46
47
  let cached;
47
48
  const getProvider = () => {
48
49
  cached ??= resolve();
49
50
  return cached;
50
51
  };
52
+ const requireUploadLedger = () => {
53
+ if (uploadLedger === undefined) {
54
+ throw new InternalError({
55
+ message: "FileContext: no upload ledger is wired, so upload bytes would be unrecoverable after a crash. Build the context from a resolver made by withFileProviderResolver (or withUploadLedger)",
56
+ });
57
+ }
58
+ return uploadLedger;
59
+ };
60
+ const assertOwnKey = (method, key) => {
61
+ if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
62
+ throw new Error(`FileContext.${method}: key must start with "${tenantId}/"`);
63
+ }
64
+ };
51
65
  return {
52
66
  ref: (key) => {
53
- if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
54
- throw new Error(`FileContext.ref: key must start with "${tenantId}/"`);
55
- }
67
+ assertOwnKey("ref", key);
56
68
  return createFileHandle(key, getProvider);
57
69
  },
70
+ writeUploadBytes: async (key, data, mimeType) => {
71
+ assertOwnKey("writeUploadBytes", key);
72
+ await requireUploadLedger().record(key);
73
+ await (await getProvider()).write(key, data, mimeType);
74
+ },
75
+ forgetUploadBytes: async (key) => {
76
+ assertOwnKey("forgetUploadBytes", key);
77
+ await requireUploadLedger().forget(key);
78
+ },
58
79
  /** Validates only the storage key shape (assertSafeStorageKey), no tenant prefix: keys
59
80
  * may outlive a tenant handover. The caller must ensure the fileRef row came from a
60
81
  * tenant-filtered read. */
@@ -74,6 +95,11 @@ export function createFileContext(resolve, tenantId) {
74
95
  },
75
96
  };
76
97
  }
98
+ // The one way to build a tenant's FileContext from a resolver: carries the resolver's upload
99
+ // ledger, so every context agrees on where upload bytes are tracked.
100
+ export function createTenantFileContext(resolver, tenantId) {
101
+ return createFileContext(() => resolver(tenantId), tenantId, resolver.uploadLedgerFor?.(tenantId));
102
+ }
77
103
  // Splits a key into the part before the last dot of its last path segment
78
104
  // (the "stem") and the extension including the dot. No dot in the last
79
105
  // segment: the whole key is the stem, extension is empty.
@@ -1,6 +1,6 @@
1
1
  export { FILE_REF_ENTITY_NAME } from "./types.ts";
2
2
  export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
3
- storageKey: {
3
+ readonly storageKey: {
4
4
  readonly type: "text";
5
5
  readonly description?: string;
6
6
  readonly maxLength?: number;
@@ -23,7 +23,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
23
23
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
24
24
  required: true;
25
25
  };
26
- fileName: {
26
+ readonly fileName: {
27
27
  readonly type: "text";
28
28
  readonly description?: string;
29
29
  readonly maxLength?: number;
@@ -46,7 +46,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
46
46
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
47
47
  required: true;
48
48
  };
49
- mimeType: {
49
+ readonly mimeType: {
50
50
  readonly type: "text";
51
51
  readonly description?: string;
52
52
  readonly maxLength?: number;
@@ -69,7 +69,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
69
69
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
70
70
  required: true;
71
71
  };
72
- size: {
72
+ readonly size: {
73
73
  readonly type: "bigInt";
74
74
  readonly description?: string;
75
75
  readonly required?: boolean;
@@ -81,7 +81,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
81
81
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
82
82
  required: true;
83
83
  };
84
- entityType: {
84
+ readonly entityType: {
85
85
  readonly type: "text";
86
86
  readonly description?: string;
87
87
  readonly maxLength?: number;
@@ -104,7 +104,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
104
104
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
105
105
  required: false;
106
106
  };
107
- entityId: {
107
+ readonly entityId: {
108
108
  readonly type: "text";
109
109
  readonly description?: string;
110
110
  readonly maxLength?: number;
@@ -127,7 +127,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
127
127
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
128
128
  required: false;
129
129
  };
130
- fieldName: {
130
+ readonly fieldName: {
131
131
  readonly type: "text";
132
132
  readonly description?: string;
133
133
  readonly maxLength?: number;
@@ -1,5 +1,5 @@
1
1
  export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable<import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
- storageKey: {
2
+ readonly storageKey: {
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
@@ -22,7 +22,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
22
22
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
23
23
  required: true;
24
24
  };
25
- fileName: {
25
+ readonly fileName: {
26
26
  readonly type: "text";
27
27
  readonly description?: string;
28
28
  readonly maxLength?: number;
@@ -45,7 +45,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
45
45
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
46
46
  required: true;
47
47
  };
48
- mimeType: {
48
+ readonly mimeType: {
49
49
  readonly type: "text";
50
50
  readonly description?: string;
51
51
  readonly maxLength?: number;
@@ -68,7 +68,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
68
68
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
69
69
  required: true;
70
70
  };
71
- size: {
71
+ readonly size: {
72
72
  readonly type: "bigInt";
73
73
  readonly description?: string;
74
74
  readonly required?: boolean;
@@ -80,7 +80,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
80
80
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
81
81
  required: true;
82
82
  };
83
- entityType: {
83
+ readonly entityType: {
84
84
  readonly type: "text";
85
85
  readonly description?: string;
86
86
  readonly maxLength?: number;
@@ -103,7 +103,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
103
103
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
104
104
  required: false;
105
105
  };
106
- entityId: {
106
+ readonly entityId: {
107
107
  readonly type: "text";
108
108
  readonly description?: string;
109
109
  readonly maxLength?: number;
@@ -126,7 +126,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
126
126
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
127
127
  required: false;
128
128
  };
129
- fieldName: {
129
+ readonly fieldName: {
130
130
  readonly type: "text";
131
131
  readonly description?: string;
132
132
  readonly maxLength?: number;
@@ -16,6 +16,7 @@ import { fileRefsTable } from "./file-ref-table.js";
16
16
  import { RecordWriteDecisions } from "./record-write-check.js";
17
17
  import { neutralFileNameForMimeType } from "./resolve-content-type.js";
18
18
  import { parseMaxSize, resolveServedContentType } from "./types.js";
19
+ import { createFileUploadLedger } from "./upload-ledger.js";
19
20
  const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
20
21
  // 15 minutes — long enough for a download to start, short enough that a
21
22
  // leaked URL (e.g. from a browser history screenshot) isn't a long-lived
@@ -171,13 +172,17 @@ export function createFileRoutes(options) {
171
172
  }
172
173
  const data = new Uint8Array(await file.arrayBuffer());
173
174
  const storageProvider = await options.resolveProvider(user.tenantId);
175
+ const uploadLedger = createFileUploadLedger(db, user.tenantId);
174
176
  const outcome = await storeFileAsFileRef({
175
177
  user,
176
178
  tenantDb: tenantDbFor(user.tenantId),
177
179
  rowInOwnTransaction: true,
178
180
  registry: options.registry,
179
181
  defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
180
- writeBytes: (key, bytes, mimeType) => storageProvider.write(key, bytes, mimeType),
182
+ writeBytes: async (key, bytes, mimeType) => {
183
+ await uploadLedger.record(key);
184
+ await storageProvider.write(key, bytes, mimeType);
185
+ },
181
186
  deleteBytes: (key) => storageProvider.delete(key),
182
187
  }, { fileName: file.name, declaredMimeType: file.type, data, entityType, entityId, fieldName });
183
188
  if (outcome.kind === "rejected")
@@ -2,7 +2,7 @@ export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, Uploaded
2
2
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
3
3
  export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
4
4
  export type { FileContext, FileHandle } from "./file-handle.ts";
5
- export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.ts";
5
+ export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
6
6
  export { fileRefEntity } from "./file-ref-entity.ts";
7
7
  export { fileRefsTable } from "./file-ref-table.ts";
8
8
  export type { FileAccessDecision, FileAccessGuard, FileRef, FileRoutesOptions, } from "./file-routes.ts";
@@ -14,7 +14,9 @@ export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, Fil
14
14
  export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.ts";
15
15
  export { resolveContentType } from "./resolve-content-type.ts";
16
16
  export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
17
+ export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.ts";
17
18
  export type { FileContentValidationResult, FileMetadata, FileStorageProvider, FileValidationOptions, SignedUrlOptions, UploadMimeTypeResolution, WriteStreamOptions, } from "./types.ts";
18
19
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.ts";
20
+ export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.ts";
19
21
  export type { ZipEntry } from "./zip-stream.ts";
20
22
  export { createZipStream } from "./zip-stream.ts";
@@ -2,7 +2,7 @@ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytes
2
2
  export { createFilesFeature, FILES_FEATURE } from "./feature.js";
3
3
  // `createFileHandle` is an implementation detail — construct handles via
4
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
5
- export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
5
+ export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
6
6
  export { fileRefEntity } from "./file-ref-entity.js";
7
7
  export { fileRefsTable } from "./file-ref-table.js";
8
8
  export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.js";
@@ -11,5 +11,7 @@ export { createLocalProvider } from "./local-provider.js";
11
11
  export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.js";
12
12
  export { resolveContentType } from "./resolve-content-type.js";
13
13
  export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
14
+ export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
14
15
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.js";
16
+ export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
15
17
  export { createZipStream } from "./zip-stream.js";