@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1

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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -1,9 +1,10 @@
1
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
1
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
2
  export { resolveErrorDocsUrl } from "./docs-url.js";
3
3
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
4
4
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
5
5
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
6
6
  export { buildErrorLog, serializeError } from "./serialize.js";
7
+ export { subjectErasedConflict } from "./subject-erased.js";
7
8
  export { toKumikoError } from "./to-kumiko-error.js";
8
9
  export { buildInvalidTransitionDetails } from "./transition-details.js";
9
10
  export { failNotFound, failTransition, failUnprocessable, reraiseAsKumikoError, toWriteErrorInfo, writeFailure, } from "./write-error-info.js";
@@ -12,6 +12,7 @@ export declare const FrameworkReasons: {
12
12
  readonly publicIntakeRequired: "public_intake_required";
13
13
  readonly irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk";
14
14
  readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
15
+ readonly subjectErased: "subject_erased";
15
16
  };
16
17
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
17
18
  export declare const AgentReasons: {
@@ -61,6 +61,9 @@ export const FrameworkReasons = {
61
61
  // delegation via ctx.write/writeAs from a lower-risk handler does not
62
62
  // inherit the gate.
63
63
  instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk",
64
+ // ConflictError: a write touched a personal-data field of a data subject whose
65
+ // key was erased (crypto-shredding); the subject can no longer receive data.
66
+ subjectErased: "subject_erased",
64
67
  };
65
68
  // Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
66
69
  // sister catalog to FrameworkReasons instead of extending it — see the
@@ -0,0 +1,2 @@
1
+ import { ConflictError } from "./classes.js";
2
+ export declare function subjectErasedConflict(cause?: unknown): ConflictError;
@@ -0,0 +1,9 @@
1
+ import { ConflictError } from "./classes.js";
2
+ import { FrameworkReasons } from "./reasons.js";
3
+ export function subjectErasedConflict(cause) {
4
+ return new ConflictError({
5
+ message: "the data subject was erased — its personal data can no longer be written",
6
+ details: { reason: FrameworkReasons.subjectErased },
7
+ ...(cause instanceof Error && { cause }),
8
+ });
9
+ }
@@ -8,7 +8,7 @@
8
8
  // Allowlist: samples/*/migration/, scripts/migrations/, die Definition
9
9
  // selbst, das Guard-Script selbst.
10
10
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
11
- import { claimXactId } from "../db/queries/event-store.js";
11
+ import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
12
12
  import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
13
13
  import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
14
14
  function mapEventUniqueViolation(e, event) {
@@ -26,13 +26,15 @@ export async function appendRaw(runner, event) {
26
26
  try {
27
27
  // See db/queries/event-store.ts's claimXactId — gap-finality needs a
28
28
  // real xact id assigned before this holder's insert.
29
- await claimXactId(runner);
30
- if (event.expectedVersion === 0) {
31
- await insertRawFirst(runner, event, newVersion, eventVersion);
32
- }
33
- else {
34
- await insertRawSubsequent(runner, event, newVersion, eventVersion);
35
- }
29
+ await runAtomicEventWrite(runner, async (tx) => {
30
+ await claimXactId(tx);
31
+ if (event.expectedVersion === 0) {
32
+ await insertRawFirst(tx, event, newVersion, eventVersion);
33
+ }
34
+ else {
35
+ await insertRawSubsequent(tx, event, newVersion, eventVersion);
36
+ }
37
+ });
36
38
  }
37
39
  catch (e) {
38
40
  if (isUniqueViolation(e)) {
@@ -90,8 +92,10 @@ export async function appendRawBatch(runner, events) {
90
92
  return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
91
93
  });
92
94
  try {
93
- await claimXactId(runner);
94
- await insertRawEventBatch(runner, valuesClauses.join(", "), params);
95
+ await runAtomicEventWrite(runner, async (tx) => {
96
+ await claimXactId(tx);
97
+ await insertRawEventBatch(tx, valuesClauses.join(", "), params);
98
+ });
95
99
  }
96
100
  catch (e) {
97
101
  if (isUniqueViolation(e)) {
@@ -24,6 +24,7 @@ export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, ten
24
24
  readonly includeArchived?: boolean;
25
25
  }): Promise<readonly StoredEvent[]>;
26
26
  export declare function getStreamVersion(db: DbRunner, aggregateId: string, tenantId: TenantId): Promise<number>;
27
+ export declare function getStreamVersions(db: DbRunner, aggregateIds: readonly string[], tenantId: TenantId): Promise<Map<string, number>>;
27
28
  /** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
28
29
  * a caller can probe whether an aggregateId has any events regardless of tenant
29
30
  * membership. Only call from seed/system-internal paths (idempotency checks
@@ -4,7 +4,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
6
6
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
7
- import { claimXactId, insertSubsequentEventRow, notifyPgChannel, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
7
+ import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
8
8
  import { insertOne, selectMany } from "../db/query.js";
9
9
  import { isStreamArchived } from "./archive.js";
10
10
  import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
@@ -50,14 +50,16 @@ export async function append(db, event) {
50
50
  const newVersion = toStore.expectedVersion + 1;
51
51
  const eventVersion = toStore.eventVersion ?? 1;
52
52
  try {
53
- await claimXactId(db);
54
- const row = toStore.expectedVersion === 0
55
- ? await insertFirstEvent(db, toStore, newVersion, eventVersion, seedCreatedAt)
56
- : await insertSubsequentEvent(db, toStore, newVersion, eventVersion, seedCreatedAt);
57
- // NOTIFY after the INSERT: outside a transaction each statement commits
58
- // on its own, so a NOTIFY sent first would wake the dispatcher before
59
- // the row exists.
60
- await notifyPgChannel(db, EVENTS_PUBSUB_CHANNEL);
53
+ const row = await runAtomicEventWrite(db, async (runner) => {
54
+ await claimXactId(runner);
55
+ const inserted = toStore.expectedVersion === 0
56
+ ? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
57
+ : await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
58
+ // NOTIFY after the INSERT and inside the same transaction: PG delivers it on
59
+ // commit, so the dispatcher cannot wake before the row is visible.
60
+ await notifyPgChannel(runner, EVENTS_PUBSUB_CHANNEL);
61
+ return inserted;
62
+ });
61
63
  return buildStoredEvent(toStore, newVersion, eventVersion, row);
62
64
  }
63
65
  catch (e) {
@@ -221,6 +223,9 @@ export async function loadAggregateAsOf(db, aggregateId, tenantId, asOf, options
221
223
  export async function getStreamVersion(db, aggregateId, tenantId) {
222
224
  return selectStreamMaxVersion(db, aggregateId, tenantId);
223
225
  }
226
+ export async function getStreamVersions(db, aggregateIds, tenantId) {
227
+ return selectStreamMaxVersions(db, aggregateIds, tenantId);
228
+ }
224
229
  /** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
225
230
  * a caller can probe whether an aggregateId has any events regardless of tenant
226
231
  * membership. Only call from seed/system-internal paths (idempotency checks
@@ -1,7 +1,7 @@
1
1
  // sql now comes from native dialect
2
2
  import { bigserial, index, instant, integer, jsonb, table as pgTable, sql, text, uniqueIndex, uuid, } from "../db/dialect.js";
3
3
  import { tableExists } from "../db/index.js";
4
- import { ensureIdempotencyKeyIndex } from "../db/queries/event-store.js";
4
+ import { ensureIdempotencyKeyIndex, ensureMembershipPayloadTenantIndex, } from "../db/queries/event-store.js";
5
5
  import { unsafePushTables } from "../stack/index.js";
6
6
  import { createArchivedStreamsTable } from "./archive.js";
7
7
  import { createSnapshotsTable } from "./snapshot.js";
@@ -61,6 +61,7 @@ export async function createEventsTable(db) {
61
61
  // that predate the idempotency-key index get healed the same way
62
62
  // ensureSnapshotVersionColumn heals kumiko_snapshots.
63
63
  await ensureIdempotencyKeyIndex(db);
64
+ await ensureMembershipPayloadTenantIndex(db);
64
65
  await createArchivedStreamsTable(db);
65
66
  await createSnapshotsTable(db);
66
67
  }
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
3
3
  export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
4
  export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
5
- export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
5
+ export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
8
8
  export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadLetterRow, rebuildDeadLetterTable, recordRebuildDeadLetters, type SkippedApply, } from "./rebuild-dead-letter.js";
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
3
3
  export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
4
  export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
5
- export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
5
+ export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent } from "./provenance-append.js";
8
8
  export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetterTable, recordRebuildDeadLetters, } from "./rebuild-dead-letter.js";
@@ -2,6 +2,6 @@ import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-han
2
2
  import type { FileStorageProvider } from "./types.js";
3
3
  export type { FileContext, FileHandle };
4
4
  export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
5
- export declare function createFileContext(resolve: () => Promise<FileStorageProvider>): FileContext;
5
+ export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
6
6
  export declare function deriveKey(key: string, suffix: string): string;
7
7
  export declare function storageKeyStemPrefix(key: string): string;
@@ -7,8 +7,10 @@
7
7
  // the bytes, and big files stay in the storage layer where they belong.
8
8
  //
9
9
  // `derive(suffix)` is the primitive for thumbnail/variant keys: it inserts a
10
- // suffix before the file extension — `foo/bar.jpg` + `"medium"` →
11
- // `foo/bar.medium.jpg`. Stable, reversible, no extra lookup tables.
10
+ // suffix before the file extension — `foo/bar.jpg` + `"medium-<16 hex>"` →
11
+ // `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
12
+ // the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
13
+ import { isDerivativeSuffix } from "../derivatives/variant-key.js";
12
14
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
13
15
  // resolved (config + s3.secretAccessKey secret read) only when a handle method
14
16
  // actually does I/O, never on every request that merely builds a ctx.
@@ -19,7 +21,15 @@ export function createFileHandle(key, getProvider) {
19
21
  write: async (data, mimeType) => (await getProvider()).write(key, data, mimeType),
20
22
  delete: async () => (await getProvider()).delete(key),
21
23
  exists: async () => (await getProvider()).exists(key),
22
- derive: (suffix) => createFileHandle(deriveKey(key, suffix), getProvider),
24
+ derive: (suffix) => {
25
+ // A suffix outside the derivative grammar would write a key that the
26
+ // forget/tenant-destroy sweep (isDerivativeKeyOf) can never recognize,
27
+ // so the binary would survive erasure.
28
+ if (!isDerivativeSuffix(suffix)) {
29
+ throw new Error(`FileHandle.derive: suffix "${suffix}" must be <name>-<16 hex chars>; build it with variantSuffix(name, spec)`);
30
+ }
31
+ return createFileHandle(deriveKey(key, suffix), getProvider);
32
+ },
23
33
  };
24
34
  }
25
35
  // `resolve` is a tenant-bound thunk (the caller binds the tenant). The result
@@ -27,15 +37,29 @@ export function createFileHandle(key, getProvider) {
27
37
  // produces — never a process-global cache, since each FileContext is bound to a
28
38
  // single request/event tenant; sharing would leak one tenant's provider (and
29
39
  // its bucket/credentials) to another.
30
- export function createFileContext(resolve) {
40
+ //
41
+ // With `tenantId`, `ref` and `list` only accept keys/prefixes inside `${tenantId}/` —
42
+ // the key space is the isolation boundary in a store shared by several tenants, and
43
+ // a request-derived key or prefix (or "") would otherwise reach every tenant's keys.
44
+ export function createFileContext(resolve, tenantId) {
31
45
  let cached;
32
46
  const getProvider = () => {
33
47
  cached ??= resolve();
34
48
  return cached;
35
49
  };
36
50
  return {
37
- ref: (key) => createFileHandle(key, getProvider),
38
- list: async (prefix) => (await getProvider()).list(prefix),
51
+ ref: (key) => {
52
+ if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
53
+ throw new Error(`FileContext.ref: key must start with "${tenantId}/"`);
54
+ }
55
+ return createFileHandle(key, getProvider);
56
+ },
57
+ list: async (prefix) => {
58
+ if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
59
+ throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
60
+ }
61
+ return (await getProvider()).list(prefix);
62
+ },
39
63
  };
40
64
  }
41
65
  // Splits a key into the part before the last dot of its last path segment
@@ -13,7 +13,7 @@ import { buildContentDispositionHeader } from "./content-disposition.js";
13
13
  import { createFileContext } from "./file-handle.js";
14
14
  import { fileRefEntity } from "./file-ref-entity.js";
15
15
  import { fileRefsTable } from "./file-ref-table.js";
16
- import { resolveContentType } from "./resolve-content-type.js";
16
+ import { neutralFileNameForMimeType, resolveContentType } from "./resolve-content-type.js";
17
17
  import { buildStorageKey, parseMaxSize, resolveServedContentType, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
18
18
  const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
19
19
  // 15 minutes — long enough for a download to start, short enough that a
@@ -97,6 +97,7 @@ export function createFileRoutes(options) {
97
97
  const api = new Hono();
98
98
  const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
99
99
  const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
100
+ const fileNameIsPersonal = piiSubjectFields.includes("fileName");
100
101
  function kmsContextFor() {
101
102
  return { requestId: requestContext.get()?.requestId ?? "file-routes" };
102
103
  }
@@ -311,7 +312,7 @@ export function createFileRoutes(options) {
311
312
  }
312
313
  // Built per request: createFileContext caches the resolved provider, so
313
314
  // one shared across requests would serve tenant A's store to tenant B.
314
- const files = createFileContext(() => options.resolveProvider(user.tenantId));
315
+ const files = createFileContext(() => options.resolveProvider(user.tenantId), user.tenantId);
315
316
  const derivatives = createDerivativesContext({
316
317
  files,
317
318
  registry,
@@ -397,10 +398,15 @@ export function createFileRoutes(options) {
397
398
  const expiresInSeconds = SIGNED_URL_DEFAULT_EXPIRY_SECONDS;
398
399
  const url = await storageProvider.getSignedUrl(fileRef.storageKey, expiresInSeconds, {
399
400
  // Hint the provider to set Content-Disposition so the browser prompts
400
- // with the original filename instead of the UUID-based storage key.
401
+ // with a readable filename instead of the UUID-based storage key.
401
402
  // Sanitised via buildContentDispositionHeader — the same attacker-
402
403
  // controlled fileName reaches the provider's presigned response.
403
- contentDisposition: buildContentDispositionHeader(await resolveFileName(fileRef)),
404
+ // A personal fileName never goes into the URL: the signed query string
405
+ // lands in storage access logs and proxies and outlives a crypto-shred.
406
+ // The streaming route keeps the real name in a response header only.
407
+ contentDisposition: buildContentDispositionHeader(fileNameIsPersonal
408
+ ? neutralFileNameForMimeType(fileRef.mimeType)
409
+ : await resolveFileName(fileRef)),
404
410
  });
405
411
  const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
406
412
  return c.json({ url, expiresAt });
@@ -1,3 +1,5 @@
1
+ /** A name derived from the MIME type alone, for places a personal file name must not appear. */
2
+ export declare function neutralFileNameForMimeType(mimeType: string): string;
1
3
  /**
2
4
  * Content type for a file whose sender may not have declared a useful one
3
5
  * (mail attachments commonly arrive as application/octet-stream). A concrete
@@ -26,6 +26,14 @@ const EXTENSION_FALLBACK = {
26
26
  csv: "text/csv",
27
27
  txt: "text/plain",
28
28
  };
29
+ const EXTENSION_BY_MIME_TYPE = new Map(Object.entries(EXTENSION_FALLBACK)
30
+ .reverse()
31
+ .map(([extension, mime]) => [mime, extension]));
32
+ /** A name derived from the MIME type alone, for places a personal file name must not appear. */
33
+ export function neutralFileNameForMimeType(mimeType) {
34
+ const extension = EXTENSION_BY_MIME_TYPE.get(normalizeMimeType(mimeType));
35
+ return extension === undefined ? "download" : `download.${extension}`;
36
+ }
29
37
  function hasPrefix(bytes, prefix) {
30
38
  return prefix.every((byte, i) => bytes[i] === byte);
31
39
  }
@@ -10,6 +10,8 @@ export declare const PROJECTION_DETAIL_ENTITY = "__projection-detail__";
10
10
  * drawn from the host record's display entity). */
11
11
  export declare const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
12
12
  export declare function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string;
13
+ export declare function handlerTitleKey(handlerQn: string): string;
14
+ export declare function handlerFieldLabelKey(handlerQn: string, fieldName: string): string;
13
15
  export declare function booleanFacetOptionKeys(featureName: string, entityName: string, fieldName: string): readonly string[];
14
16
  export declare function selectFacetOptionKey(featureName: string, entityName: string, fieldName: string, value: string): string;
15
17
  export declare function screenTitleKey(screenId: string): string;
@@ -13,6 +13,12 @@ export const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
13
13
  export function fieldLabelKey(featureName, entityName, fieldName) {
14
14
  return `${featureName}:entity:${entityName}:field:${fieldName}`;
15
15
  }
16
+ export function handlerTitleKey(handlerQn) {
17
+ return `${handlerQn}:title`;
18
+ }
19
+ export function handlerFieldLabelKey(handlerQn, fieldName) {
20
+ return `${handlerQn}:field:${fieldName}`;
21
+ }
16
22
  export function booleanFacetOptionKeys(featureName, entityName, fieldName) {
17
23
  const base = `${featureName}:entity:${entityName}:field:${fieldName}:option`;
18
24
  return [`${base}:true`, `${base}:false`];
@@ -116,6 +122,8 @@ function pushDashboardPanelKeys(out, panel) {
116
122
  function pushDashboardChartRangeKeys(out, panel) {
117
123
  for (const option of panel.ranges?.options ?? [])
118
124
  pushKey(out, option.label);
125
+ for (const kind of Object.values(panel.markerKinds ?? {}))
126
+ pushKey(out, kind.label);
119
127
  }
120
128
  function pushDashboardFilterKeys(out, filter) {
121
129
  pushKey(out, filter.label);
@@ -1,2 +1,2 @@
1
- export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
1
+ export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
2
2
  export { createJobRunner, serializeJobSubject } from "./job-runner.js";
@@ -19,6 +19,14 @@ export type JobMeta = {
19
19
  payload?: string | undefined;
20
20
  attempt?: number | undefined;
21
21
  priority?: number | undefined;
22
+ tenantVisibleRun?: {
23
+ readonly tenantId: string;
24
+ readonly subject: string | null;
25
+ } | undefined;
26
+ };
27
+ export type JobQueuedMeta = {
28
+ readonly tenantId: string;
29
+ readonly subject: string | null;
22
30
  };
23
31
  export type JobOutcomeMeta = {
24
32
  readonly tenantId: string;
@@ -27,6 +35,9 @@ export type JobOutcomeMeta = {
27
35
  readonly subject: string | null;
28
36
  readonly messageKey: string | null;
29
37
  } | undefined;
38
+ readonly tenantVisibleRun?: {
39
+ readonly subject: string | null;
40
+ } | undefined;
30
41
  };
31
42
  export type JobSubjectValue = string | number | boolean | null;
32
43
  export declare function serializeJobSubject(subject: Readonly<Record<string, JobSubjectValue>>): string;
@@ -53,5 +64,7 @@ export type JobRunnerOptions = {
53
64
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
54
65
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
55
66
  onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
67
+ onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
68
+ onJobDropped?: (jobName: string, jobId: string) => void;
56
69
  };
57
70
  export declare function createJobRunner(options: JobRunnerOptions): JobRunner;