@cosmicdrift/kumiko-framework 0.371.1 → 0.373.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 (79) 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/rate-limiting.md +65 -1
  6. package/agents/examples/user-data-rights.md +176 -0
  7. package/dist/api/jwt.js +2 -1
  8. package/dist/api/observability-middleware.d.ts +1 -2
  9. package/dist/api/observability-middleware.js +9 -8
  10. package/dist/api/pii-leak-guard.js +2 -1
  11. package/dist/api/route-label.d.ts +3 -0
  12. package/dist/api/route-label.js +17 -0
  13. package/dist/api/server.d.ts +1 -2
  14. package/dist/api/server.js +3 -5
  15. package/dist/config/index.d.ts +5 -0
  16. package/dist/db/event-store-executor.d.ts +2 -1
  17. package/dist/db/index.d.ts +2 -2
  18. package/dist/db/index.js +1 -1
  19. package/dist/db/located-timestamp.d.ts +7 -0
  20. package/dist/db/query.d.ts +1 -1
  21. package/dist/db/query.js +8 -2
  22. package/dist/db/table-builder.d.ts +45 -11
  23. package/dist/db/tenant-db.js +3 -1
  24. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  25. package/dist/engine/boot-validator/entity-handler.js +47 -16
  26. package/dist/engine/entity-handlers.d.ts +3 -3
  27. package/dist/engine/factories.d.ts +2 -1
  28. package/dist/engine/factories.js +2 -0
  29. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  30. package/dist/engine/feature-ast/extractors/hooks.js +8 -8
  31. package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
  32. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  33. package/dist/engine/membership-roles.d.ts +1 -0
  34. package/dist/engine/membership-roles.js +8 -0
  35. package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
  36. package/dist/engine/types/index.d.ts +2 -2
  37. package/dist/engine/types/index.js +1 -1
  38. package/dist/entrypoint/index.d.ts +1 -0
  39. package/dist/entrypoint/index.js +0 -2
  40. package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
  41. package/dist/files/bind-pending-file-refs.d.ts +31 -1
  42. package/dist/files/bind-pending-file-refs.js +80 -6
  43. package/dist/files/create-file-ref-from-bytes.js +12 -6
  44. package/dist/files/create-file-ref.d.ts +20 -2
  45. package/dist/files/create-file-ref.js +10 -1
  46. package/dist/files/feature.d.ts +1 -0
  47. package/dist/files/feature.js +25 -0
  48. package/dist/files/file-handle.d.ts +4 -1
  49. package/dist/files/file-handle.js +30 -4
  50. package/dist/files/file-ref-entity.d.ts +7 -7
  51. package/dist/files/file-ref-table.d.ts +7 -7
  52. package/dist/files/file-routes.js +6 -1
  53. package/dist/files/index.d.ts +5 -1
  54. package/dist/files/index.js +4 -1
  55. package/dist/files/load-save-file-refs.js +3 -1
  56. package/dist/files/provider-resolver.js +3 -1
  57. package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
  58. package/dist/files/sweep-orphaned-uploads.js +53 -0
  59. package/dist/files/upload-ledger.d.ts +10 -0
  60. package/dist/files/upload-ledger.js +47 -0
  61. package/dist/i18n/index.d.ts +1 -1
  62. package/dist/i18n/index.js +1 -1
  63. package/dist/i18n/request-locale.d.ts +15 -4
  64. package/dist/i18n/request-locale.js +26 -9
  65. package/dist/jobs/job-runner.js +2 -4
  66. package/dist/pipeline/dispatch-shared.js +57 -39
  67. package/dist/pipeline/index.d.ts +1 -0
  68. package/dist/pipeline/index.js +1 -0
  69. package/dist/rate-limit/index.d.ts +2 -1
  70. package/dist/rate-limit/index.js +1 -0
  71. package/dist/rate-limit/resolver.d.ts +1 -1
  72. package/dist/rate-limit/resolver.js +104 -2
  73. package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
  74. package/dist/rate-limit/unlimited-resolver.js +22 -0
  75. package/dist/schema-cli.js +46 -5
  76. package/package.json +5 -5
  77. package/src/changes.json +55 -0
  78. package/src/scripts/codemod/README.md +22 -0
  79. package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
@@ -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")
@@ -1,8 +1,10 @@
1
+ export type { BindPendingFileRefOutcome, PendingFileRef } from "./bind-pending-file-refs.ts";
2
+ export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.ts";
1
3
  export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.ts";
2
4
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
3
5
  export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
4
6
  export type { FileContext, FileHandle } from "./file-handle.ts";
5
- export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.ts";
7
+ export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
6
8
  export { fileRefEntity } from "./file-ref-entity.ts";
7
9
  export { fileRefsTable } from "./file-ref-table.ts";
8
10
  export type { FileAccessDecision, FileAccessGuard, FileRef, FileRoutesOptions, } from "./file-routes.ts";
@@ -14,7 +16,9 @@ export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, Fil
14
16
  export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.ts";
15
17
  export { resolveContentType } from "./resolve-content-type.ts";
16
18
  export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
19
+ export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.ts";
17
20
  export type { FileContentValidationResult, FileMetadata, FileStorageProvider, FileValidationOptions, SignedUrlOptions, UploadMimeTypeResolution, WriteStreamOptions, } from "./types.ts";
18
21
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.ts";
22
+ export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.ts";
19
23
  export type { ZipEntry } from "./zip-stream.ts";
20
24
  export { createZipStream } from "./zip-stream.ts";
@@ -1,8 +1,9 @@
1
+ export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.js";
1
2
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
2
3
  export { createFilesFeature, FILES_FEATURE } from "./feature.js";
3
4
  // `createFileHandle` is an implementation detail — construct handles via
4
5
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
5
- export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
6
+ export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
6
7
  export { fileRefEntity } from "./file-ref-entity.js";
7
8
  export { fileRefsTable } from "./file-ref-table.js";
8
9
  export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.js";
@@ -11,5 +12,7 @@ export { createLocalProvider } from "./local-provider.js";
11
12
  export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.js";
12
13
  export { resolveContentType } from "./resolve-content-type.js";
13
14
  export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
15
+ export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
14
16
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.js";
17
+ export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
15
18
  export { createZipStream } from "./zip-stream.js";
@@ -33,12 +33,14 @@ export async function loadFileRefRows(db, ids, tenantId) {
33
33
  // skip: nothing referenced, nothing to load
34
34
  if (ids.length === 0)
35
35
  return new Map();
36
+ // Versions before rows: a bind committing in between leaves a stale row paired with an older
37
+ // version, so the binding update conflicts instead of overwriting the winner's entityId.
38
+ const streamVersions = await getStreamVersions(tenantDbRunner(db), [...ids], db.tenantId);
36
39
  const rows = await selectMany(db, fileRefsTable, {
37
40
  id: { in: [...ids] },
38
41
  tenantId,
39
42
  isDeleted: false,
40
43
  });
41
- const streamVersions = await getStreamVersions(tenantDbRunner(db), rows.map((row) => String(row["id"])), db.tenantId);
42
44
  return new Map(rows.map((row) => {
43
45
  const id = String(row["id"]);
44
46
  const streamVersion = streamVersions.get(id);
@@ -15,6 +15,7 @@ import { EXT_FILE_PROVIDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT
15
15
  import { SYSTEM_USER_ID } from "../engine/system-user.js";
16
16
  import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
17
17
  import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
18
+ import { withUploadLedger } from "./upload-ledger.js";
18
19
  const FEATURE_NAME = "file-foundation";
19
20
  // extension-usage `options` is engine-payload (unknown) — structurally validate
20
21
  // instead of casting blind.
@@ -95,7 +96,7 @@ export function makeFileProviderResolver(deps) {
95
96
  cache.delete(message.tenantId);
96
97
  });
97
98
  deps.cacheSync?.onResync(() => cache.clear());
98
- return (tenantId) => {
99
+ const resolveProvider = (tenantId) => {
99
100
  const cached = cache.get(tenantId);
100
101
  if (cached)
101
102
  return cached;
@@ -118,4 +119,5 @@ export function makeFileProviderResolver(deps) {
118
119
  cache.set(tenantId, built);
119
120
  return built;
120
121
  };
122
+ return deps.db ? withUploadLedger(resolveProvider, deps.db) : resolveProvider;
121
123
  }
@@ -0,0 +1,16 @@
1
+ import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
2
+ import type { Logger } from "@cosmicdrift/kumiko-types/logger";
3
+ import { type TenantDb } from "../db/tenant-db.ts";
4
+ export declare const ORPHANED_UPLOAD_SWEEP_BATCH = 500;
5
+ export declare const DEFAULT_UPLOAD_SWEEP_GRACE_HOURS = 24;
6
+ export declare const MIN_UPLOAD_SWEEP_GRACE_HOURS = 1;
7
+ type SweepLog = Pick<Logger, "warn"> | undefined;
8
+ export declare function sweepOrphanedUploads(args: {
9
+ readonly db: TenantDb;
10
+ readonly files: FileContext | undefined;
11
+ readonly now: Temporal.Instant;
12
+ readonly graceHours: number;
13
+ readonly log: SweepLog;
14
+ readonly pageSize?: number;
15
+ }): Promise<void>;
16
+ export {};
@@ -0,0 +1,53 @@
1
+ import { selectMany } from "../db/query.js";
2
+ import { runInOwnTransaction } from "../db/tenant-db.js";
3
+ import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
4
+ import { fileRefsTable } from "./file-ref-table.js";
5
+ import { fileUploadLedgerTable, takeUploadLedgerEntry } from "./upload-ledger.js";
6
+ export const ORPHANED_UPLOAD_SWEEP_BATCH = 500;
7
+ export const DEFAULT_UPLOAD_SWEEP_GRACE_HOURS = 24;
8
+ export const MIN_UPLOAD_SWEEP_GRACE_HOURS = 1;
9
+ // Each candidate gets its own short transaction that takes the ledger row first. A commit that
10
+ // holds the row makes the sweep wait and then find it gone; a sweep that took the row first makes
11
+ // the commit find it gone and fail, so bytes and fileRef row can never end up apart.
12
+ async function sweepOrphanedUpload(txDb, files, storageKey) {
13
+ const taken = await takeUploadLedgerEntry(txDb, storageKey);
14
+ // skip: a commit took the row in the meantime and owns the bytes now
15
+ if (!taken)
16
+ return;
17
+ const committedRows = await selectMany(txDb, fileRefsTable, { storageKey }, { limit: 1, fields: ["id"] });
18
+ // skip: a fileRef row claims the bytes, only the stale ledger row goes
19
+ if (committedRows.length > 0)
20
+ return;
21
+ const failedKeys = await deleteFileRefBytes(files, { storageKey }, "upload-ledger-sweep");
22
+ if (failedKeys.length > 0)
23
+ throw new Error("bytes not fully deleted");
24
+ }
25
+ // Deletes the bytes (original and derivatives) of uploads that never got a fileRef row within the
26
+ // grace period. Walks the ledger page by page along storage_key, so rows that keep failing or are
27
+ // locked cannot hide the rows behind them; a failed row rolls back and the next run retries it.
28
+ export async function sweepOrphanedUploads(args) {
29
+ const { db, files, now, graceHours, log } = args;
30
+ const pageSize = args.pageSize ?? ORPHANED_UPLOAD_SWEEP_BATCH;
31
+ if (!files) {
32
+ log?.warn("[upload-ledger-sweep] no file storage available, orphaned uploads kept");
33
+ return;
34
+ }
35
+ const cutoff = now.subtract({ hours: graceHours });
36
+ let cursor;
37
+ for (;;) {
38
+ const rows = await selectMany(db, fileUploadLedgerTable, { createdAt: { lt: cutoff }, ...(cursor !== undefined && { storageKey: { gt: cursor } }) }, { orderBy: { col: "storageKey", direction: "asc" }, fields: ["storageKey"], limit: pageSize });
39
+ const lastRow = rows[rows.length - 1];
40
+ // skip: an empty page means every due row has been visited
41
+ if (lastRow === undefined)
42
+ return;
43
+ cursor = lastRow.storageKey;
44
+ for (const { storageKey } of rows) {
45
+ try {
46
+ await runInOwnTransaction(db, (txDb) => sweepOrphanedUpload(txDb, files, storageKey));
47
+ }
48
+ catch (err) {
49
+ log?.warn(`[upload-ledger-sweep] orphaned upload not deleted, retried next run: ${err instanceof Error ? err.message : String(err)}`);
50
+ }
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,10 @@
1
+ import type { FileProviderResolver, FileUploadLedger } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
2
+ import type { FileStorageProvider } from "@cosmicdrift/kumiko-types/file-storage-provider-types";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-types/identifiers";
4
+ import type { DbConnection } from "../db/connection.ts";
5
+ import { type EntityTableMeta } from "../db/entity-table-meta.ts";
6
+ import { type TenantDb } from "../db/tenant-db.ts";
7
+ export declare const fileUploadLedgerTable: EntityTableMeta;
8
+ export declare function createFileUploadLedger(db: DbConnection | TenantDb, tenantId: TenantId): FileUploadLedger;
9
+ export declare function withUploadLedger(resolver: (tenantId: TenantId) => Promise<FileStorageProvider>, db: DbConnection | TenantDb): FileProviderResolver;
10
+ export declare function takeUploadLedgerEntry(txDb: TenantDb, storageKey: string): Promise<boolean>;
@@ -0,0 +1,47 @@
1
+ import { defineUnmanagedTable } from "../db/entity-table-meta.js";
2
+ import { createTenantDb } from "../db/tenant-db.js";
3
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
4
+ const CREATED_AT_INDEX = "store_file_upload_ledger_created_at_idx";
5
+ // One row per upload whose bytes are in storage but whose fileRef row is not committed yet. The
6
+ // commit path deletes it inside the transaction that creates the fileRef; the sweep finds the
7
+ // rows that never got there. Unmanaged: a coordination aid, not state with an event history.
8
+ export const fileUploadLedgerTable = defineUnmanagedTable({
9
+ tableName: "store_file_upload_ledger",
10
+ indexes: [{ name: CREATED_AT_INDEX, columns: ["created_at"] }],
11
+ columns: [
12
+ { name: "storage_key", pgType: "text", notNull: true, primaryKey: true },
13
+ { name: "tenant_id", pgType: "uuid", notNull: true },
14
+ { name: "created_at", pgType: "timestamptz", notNull: true },
15
+ ],
16
+ });
17
+ function isTenantDb(db) {
18
+ return "tenantId" in db && "fetchOne" in db && typeof db.fetchOne === "function";
19
+ }
20
+ // The ledger writes on a connection of its own, never on a handler transaction: a rollback of the
21
+ // handler must not take the row with it, or the bytes it uploaded would be unfindable.
22
+ export function createFileUploadLedger(db, tenantId) {
23
+ const runner = isTenantDb(db) ? tenantDbRunner(db) : db;
24
+ const ledgerDb = createTenantDb({ db: runner, tenantId });
25
+ return {
26
+ record: async (storageKey) => {
27
+ await ledgerDb.insertOne(fileUploadLedgerTable, {
28
+ storageKey,
29
+ tenantId,
30
+ createdAt: Temporal.Now.instant(),
31
+ });
32
+ },
33
+ forget: async (storageKey) => {
34
+ await ledgerDb.deleteMany(fileUploadLedgerTable, { storageKey });
35
+ },
36
+ };
37
+ }
38
+ export function withUploadLedger(resolver, db) {
39
+ return Object.assign(resolver, {
40
+ uploadLedgerFor: (tenantId) => createFileUploadLedger(db, tenantId),
41
+ });
42
+ }
43
+ // Runs inside the transaction that creates the fileRef row. False means no ledger row was left to
44
+ // take: the sweep already removed the bytes, so the row must not be committed.
45
+ export async function takeUploadLedgerEntry(txDb, storageKey) {
46
+ return (await txDb.deleteMany(fileUploadLedgerTable, { storageKey })) > 0;
47
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Registry } from "../engine/types/index.ts";
2
2
  export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.ts";
3
- export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.ts";
3
+ export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, rankAcceptLanguage, rankPreferredLocales, resolveHeaderLocale, } from "./request-locale.ts";
4
4
  export { SETTINGS_HUB_I18N } from "./settings-hub-keys.ts";
5
5
  export type I18nOptions = {
6
6
  defaultLocale: string;
@@ -1,6 +1,6 @@
1
1
  import { resolveTranslationValue } from "../ui-types/plural.js";
2
2
  export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
3
- export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
3
+ export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, rankAcceptLanguage, rankPreferredLocales, resolveHeaderLocale, } from "./request-locale.js";
4
4
  export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
5
5
  export function createI18n(registry, options) {
6
6
  const translations = registry.getAllTranslations();
@@ -5,12 +5,23 @@ export declare function isValidLocaleTag(value: string): boolean;
5
5
  * hit registered keys regardless of header casing. */
6
6
  export declare function canonicalizeLocaleTag(tag: string): string;
7
7
  /**
8
- * Picks the best well-formed tag from an Accept-Language header (RFC 9110
9
- * §12.5.4): parses "tag;q=x" pairs, sorts by q descending (header order
10
- * breaks ties), and returns the first tag that passes isValidLocaleTag.
11
- * Tags with q=0 (explicitly excluded) are dropped entirely.
8
+ * Well-formed tags of an Accept-Language header (RFC 9110 §12.5.4) in
9
+ * preference order: "tag;q=x" pairs sorted by q descending, header order
10
+ * breaking ties. Tags with q=0 (explicitly excluded) and malformed tags are
11
+ * dropped. Tags are returned as sent, not canonicalized or deduped.
12
12
  */
13
+ export declare function rankAcceptLanguage(header: string | undefined): string[];
14
+ /** Picks the best well-formed tag from an Accept-Language header, see rankAcceptLanguage. */
13
15
  export declare function pickAcceptLanguage(header: string | undefined): string | undefined;
16
+ /**
17
+ * Ordered locale preference of a page request: a valid `?lang=` first, then
18
+ * every Accept-Language tag by q. Canonicalized and deduped (case-insensitive),
19
+ * so a resolver can walk the list and take the first locale it has content for.
20
+ */
21
+ export declare function rankPreferredLocales(options: {
22
+ readonly queryLang?: string | null | undefined;
23
+ readonly acceptLanguage?: string | null | undefined;
24
+ }): string[];
14
25
  /**
15
26
  * Request-layer resolution: an explicit, validated X-Locale header wins;
16
27
  * otherwise the best Accept-Language tag; otherwise undefined — no signal
@@ -33,15 +33,15 @@ export function canonicalizeLocaleTag(tag) {
33
33
  .join("-");
34
34
  }
35
35
  /**
36
- * Picks the best well-formed tag from an Accept-Language header (RFC 9110
37
- * §12.5.4): parses "tag;q=x" pairs, sorts by q descending (header order
38
- * breaks ties), and returns the first tag that passes isValidLocaleTag.
39
- * Tags with q=0 (explicitly excluded) are dropped entirely.
36
+ * Well-formed tags of an Accept-Language header (RFC 9110 §12.5.4) in
37
+ * preference order: "tag;q=x" pairs sorted by q descending, header order
38
+ * breaking ties. Tags with q=0 (explicitly excluded) and malformed tags are
39
+ * dropped. Tags are returned as sent, not canonicalized or deduped.
40
40
  */
41
- export function pickAcceptLanguage(header) {
41
+ export function rankAcceptLanguage(header) {
42
42
  if (header === undefined || header.length === 0)
43
- return undefined;
44
- const candidates = header
43
+ return [];
44
+ return header
45
45
  .split(",")
46
46
  .map((part, index) => {
47
47
  const [tagRaw, ...params] = part.trim().split(";");
@@ -54,8 +54,25 @@ export function pickAcceptLanguage(header) {
54
54
  return { tag, q, index };
55
55
  })
56
56
  .filter((c) => c !== undefined && c.q > 0)
57
- .sort((a, b) => b.q - a.q || a.index - b.index);
58
- return candidates[0]?.tag;
57
+ .sort((a, b) => b.q - a.q || a.index - b.index)
58
+ .map((c) => c.tag);
59
+ }
60
+ /** Picks the best well-formed tag from an Accept-Language header, see rankAcceptLanguage. */
61
+ export function pickAcceptLanguage(header) {
62
+ return rankAcceptLanguage(header)[0];
63
+ }
64
+ /**
65
+ * Ordered locale preference of a page request: a valid `?lang=` first, then
66
+ * every Accept-Language tag by q. Canonicalized and deduped (case-insensitive),
67
+ * so a resolver can walk the list and take the first locale it has content for.
68
+ */
69
+ export function rankPreferredLocales(options) {
70
+ const ranked = [];
71
+ if (options.queryLang != null && isValidLocaleTag(options.queryLang)) {
72
+ ranked.push(options.queryLang);
73
+ }
74
+ ranked.push(...rankAcceptLanguage(options.acceptLanguage ?? undefined));
75
+ return [...new Set(ranked.map(canonicalizeLocaleTag))];
59
76
  }
60
77
  /**
61
78
  * Request-layer resolution: an explicit, validated X-Locale header wins;
@@ -12,7 +12,7 @@ import { createSystemUser } from "../engine/system-user.js";
12
12
  import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
13
13
  import { InternalError } from "../errors/index.js";
14
14
  import { isKumikoError } from "../errors/kumiko-error.js";
15
- import { createFileContext } from "../files/file-handle.js";
15
+ import { createTenantFileContext } from "../files/file-handle.js";
16
16
  import { createFallbackLogger } from "../logging/index.js";
17
17
  import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
18
18
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
@@ -903,9 +903,7 @@ export function createJobRunner(options) {
903
903
  // through the same _fileProviderResolver for jobs as for write-handlers,
904
904
  // otherwise event-triggered jobs silently get an unresolved ctx.files.
905
905
  const fileResolver = context._fileProviderResolver;
906
- const files = fileResolver
907
- ? createFileContext(() => fileResolver(tenantId), tenantId)
908
- : context.files;
906
+ const files = fileResolver ? createTenantFileContext(fileResolver, tenantId) : context.files;
909
907
  const jobSystemUser = createSystemUser(tenantId);
910
908
  // Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
911
909
  // must resolve for jobs the same way they do for write-handlers, or an
@@ -5,14 +5,14 @@ import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTrans
5
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
6
  import { mayOverrideTenant } from "../engine/cross-tenant.js";
7
7
  import { createSystemUser } from "../engine/system-user.js";
8
- import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
8
+ import { declaredRateLimitOptions, isRateLimitDisabled, resolveAgentExposure, } from "../engine/types/index.js";
9
9
  import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
10
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
11
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
12
12
  import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
13
13
  import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
14
14
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
15
- import { createFileContext } from "../files/file-handle.js";
15
+ import { createTenantFileContext } from "../files/file-handle.js";
16
16
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
17
17
  import { createFallbackLogger } from "../logging/utils.js";
18
18
  import { createEscapeHatchReporter, createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
@@ -459,9 +459,7 @@ readOnlyForTenant = false) {
459
459
  // _fileProviderResolver when a file-provider plugin is mounted; falls back
460
460
  // to a statically-injected context.files (tests).
461
461
  const fileResolver = context._fileProviderResolver;
462
- const files = fileResolver
463
- ? createFileContext(() => fileResolver(user.tenantId), user.tenantId)
464
- : context.files;
462
+ const files = fileResolver ? createTenantFileContext(fileResolver, user.tenantId) : context.files;
465
463
  // ctx.derivatives builds on ctx.files (needs a FileContext to read the
466
464
  // original + write the variant) plus db (to look up the FileRef row) — so
467
465
  // it can only be constructed exactly when files+db both resolved; falls
@@ -1109,16 +1107,28 @@ const DEFAULT_SYSTEM_SCOPE_RATE_LIMIT = {
1109
1107
  limit: 600,
1110
1108
  windowSeconds: 60,
1111
1109
  };
1112
- function resolveEffectiveRateLimit(rateLimit, isSystemScope, user, resolver) {
1113
- if (rateLimit)
1114
- return rateLimit;
1110
+ function resolveEffectiveRateLimits(rateLimit, isSystemScope, user, resolver) {
1111
+ const declared = declaredRateLimitOptions(rateLimit);
1112
+ if (declared.length > 0)
1113
+ return declared;
1115
1114
  if (!isSystemScope)
1116
- return undefined;
1115
+ return [];
1117
1116
  if (!resolver)
1118
- return undefined;
1117
+ return [];
1119
1118
  if (isSystemIdentity(user))
1120
- return undefined;
1121
- return DEFAULT_SYSTEM_SCOPE_RATE_LIMIT;
1119
+ return [];
1120
+ return [DEFAULT_SYSTEM_SCOPE_RATE_LIMIT];
1121
+ }
1122
+ // A single bucket keeps the plain enforce path; several go through one atomic
1123
+ // all-or-nothing call so a rejection by one bucket never charges the others.
1124
+ async function enforceBuckets(resolver, requests) {
1125
+ const [only, ...rest] = requests;
1126
+ // skip: no bucket to charge
1127
+ if (!only)
1128
+ return;
1129
+ await (rest.length === 0
1130
+ ? resolver.enforce(only.bucket, only.config)
1131
+ : resolver.enforceAll(requests));
1122
1132
  }
1123
1133
  // L3 rate limit gate. Called by both query and write paths before
1124
1134
  // access-check. Reasoning:
@@ -1137,42 +1147,48 @@ export async function enforceRateLimit(ctx, rateLimit, handlerName, user, isSyst
1137
1147
  // skip: handler explicitly opts out via rateLimit: { disabled: true }
1138
1148
  if (isRateLimitDisabled(rateLimit))
1139
1149
  return;
1140
- const effective = resolveEffectiveRateLimit(rateLimit, isSystemScope, user, context.rateLimit);
1150
+ const effective = resolveEffectiveRateLimits(rateLimit, isSystemScope, user, context.rateLimit);
1141
1151
  // skip: no rateLimit declared and not systemScope (or resolved to no-op) — nothing to enforce
1142
- if (!effective)
1152
+ if (effective.length === 0)
1143
1153
  return;
1144
1154
  const reqCtx = requestContext.get();
1145
- const bucket = buildBucketKey(effective, {
1146
- handlerName,
1147
- user,
1148
- ip: reqCtx?.ip,
1149
- });
1150
- // skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
1151
- // requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
1152
- if (bucket.kind === "skip")
1153
- return;
1154
- if (!context.rateLimit) {
1155
- throw new InternalError({
1156
- message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
1157
- });
1158
- }
1159
1155
  // A nested dispatch re-hitting a bucket its entry dispatch already charged
1160
1156
  // (e.g. a per:"ip" handler delegating via queryAs) must not cost a second token.
1161
1157
  // The mark is set only after a successful enforce and includes the limit
1162
1158
  // parameters: a denied or failed check never lets a later nested call through,
1163
1159
  // and a nested handler with a stricter limit on the same bucket is still checked.
1164
1160
  const charged = reqCtx?.chargedRateLimitBuckets;
1165
- const chargedKey = `${bucket.key}|${effective.limit}|${effective.windowSeconds}|${effective.cost}`;
1166
- // skip: this exact bucket, limit and window was already charged by the entry dispatch
1167
- if (charged?.has(chargedKey))
1161
+ const pending = [];
1162
+ for (const option of effective) {
1163
+ const bucket = buildBucketKey(option, { handlerName, user, ip: reqCtx?.ip });
1164
+ // skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
1165
+ // requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
1166
+ if (bucket.kind === "skip")
1167
+ continue;
1168
+ const chargedKey = `${bucket.key}|${option.limit}|${option.windowSeconds}|${option.cost}`;
1169
+ // skip: this exact bucket, limit and window was already charged by the entry dispatch
1170
+ if (charged?.has(chargedKey))
1171
+ continue;
1172
+ pending.push({
1173
+ request: {
1174
+ bucket: bucket.key,
1175
+ config: { limit: option.limit, windowSeconds: option.windowSeconds, cost: option.cost },
1176
+ },
1177
+ chargedKey,
1178
+ });
1179
+ }
1180
+ // skip: every bucket was skipped or already charged — nothing left to enforce
1181
+ if (pending.length === 0)
1168
1182
  return;
1169
- try {
1170
- await context.rateLimit.enforce(bucket.key, {
1171
- limit: effective.limit,
1172
- windowSeconds: effective.windowSeconds,
1173
- cost: effective.cost,
1183
+ if (!context.rateLimit) {
1184
+ throw new InternalError({
1185
+ message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
1174
1186
  });
1175
- charged?.add(chargedKey);
1187
+ }
1188
+ try {
1189
+ await enforceBuckets(context.rateLimit, pending.map(({ request }) => request));
1190
+ for (const { chargedKey } of pending)
1191
+ charged?.add(chargedKey);
1176
1192
  }
1177
1193
  catch (error) {
1178
1194
  // Backend failure (Redis down): fail closed as 503, same as L1/L2.
@@ -1205,6 +1221,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1205
1221
  message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
1206
1222
  });
1207
1223
  }
1224
+ const requests = [];
1208
1225
  for (const { per, limit, windowSeconds, cost, normalize } of limits) {
1209
1226
  const value = data[per.payloadField];
1210
1227
  // skip: optional field not sent — nothing to bucket on
@@ -1215,9 +1232,10 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1215
1232
  message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
1216
1233
  });
1217
1234
  }
1218
- const key = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
1219
- await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
1235
+ const bucket = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
1236
+ requests.push({ bucket, config: { limit, windowSeconds, cost } });
1220
1237
  }
1238
+ await enforceBuckets(context.rateLimit, requests);
1221
1239
  }
1222
1240
  // Build the per-hook context every auth-claims invocation gets. Claims
1223
1241
  // hooks run OUTSIDE any request transaction (login is itself the root
@@ -1,6 +1,7 @@
1
1
  export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.ts";
2
2
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.ts";
3
3
  export { normalizePayloadBucketValue } from "../rate-limit/bucket.ts";
4
+ export { createUnlimitedRateLimitResolver } from "../rate-limit/unlimited-resolver.ts";
4
5
  export type { AppendDomainEventCoreDeps } from "./append-event-core.ts";
5
6
  export { appendDomainEventCore } from "./append-event-core.ts";
6
7
  export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.ts";
@@ -1,5 +1,6 @@
1
1
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
2
2
  export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
3
+ export { createUnlimitedRateLimitResolver } from "../rate-limit/unlimited-resolver.js";
3
4
  export { appendDomainEventCore } from "./append-event-core.js";
4
5
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
5
6
  export { createCascadeDeleteHook } from "./cascade-handler.js";
@@ -1,3 +1,4 @@
1
1
  export { type BucketContext, type BucketResult, buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.ts";
2
2
  export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.ts";
3
- export { createRateLimitResolver, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
3
+ export { createRateLimitResolver, type RateLimitBucketRequest, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
4
+ export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.ts";
@@ -1,3 +1,4 @@
1
1
  export { buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.js";
2
2
  export { authEndpointRateLimit, globalIpRateLimit, httpRouteRateLimit, } from "./middleware.js";
3
3
  export { createRateLimitResolver, } from "./resolver.js";
4
+ export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.js";
@@ -1,6 +1,6 @@
1
1
  import type { RateLimitResolver } from "@cosmicdrift/kumiko-types/rate-limit-types";
2
2
  import type { Redis } from "ioredis";
3
- export type { RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
3
+ export type { RateLimitBucketRequest, RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
4
4
  export type RateLimitResolverOptions = {
5
5
  readonly redis: Redis;
6
6
  readonly keyPrefix?: string;