@cosmicdrift/kumiko-framework 0.371.0 → 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
@@ -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
@@ -12,7 +12,7 @@ import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictErro
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
@@ -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
3
  export { createRateLimitResolver, 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";
@@ -0,0 +1,2 @@
1
+ import type { RateLimitResolver } from "@cosmicdrift/kumiko-types/rate-limit-types";
2
+ export declare function createUnlimitedRateLimitResolver(): RateLimitResolver;
@@ -0,0 +1,21 @@
1
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ // Resolver for callers that are bootstrap work rather than tenant traffic (boot seeds,
3
+ // seed migrations): a handler's `rateLimit` declaration must not fail them, and they must not
4
+ // drain the buckets real requests share. Nothing is stored, so the decision is always "full bucket".
5
+ export function createUnlimitedRateLimitResolver() {
6
+ function fullBucket(config) {
7
+ return {
8
+ allowed: true,
9
+ limit: config.limit,
10
+ remaining: config.limit,
11
+ retryAfterSeconds: 0,
12
+ windowSeconds: config.windowSeconds,
13
+ resetAt: Temporal.Instant.fromEpochMilliseconds(Date.now()),
14
+ };
15
+ }
16
+ return {
17
+ check: async (_bucket, config) => fullBucket(config),
18
+ enforce: async (_bucket, config) => fullBucket(config),
19
+ peek: async (_bucket, config) => fullBucket(config),
20
+ };
21
+ }
@@ -35,6 +35,41 @@ function withFileStoragePresence(env) {
35
35
  ? { ...env, [FILE_STORAGE_PROVIDER_ENV]: FILE_STORAGE_PROVIDER_BOOT_SENTINEL }
36
36
  : { ...env };
37
37
  }
38
+ function isStringSet(value) {
39
+ return value instanceof Set && [...value].every((entry) => typeof entry === "string");
40
+ }
41
+ function isBoolean(value) {
42
+ return typeof value === "boolean";
43
+ }
44
+ function validateBootOptionsError(detail) {
45
+ return new Error(`kumiko/schema.ts: \`VALIDATE_BOOT_OPTIONS\` ${detail}`);
46
+ }
47
+ function readOptionalOption(raw, key, isValid, expected) {
48
+ const value = Reflect.get(raw, key);
49
+ if (value === undefined)
50
+ return undefined;
51
+ if (!isValid(value))
52
+ throw validateBootOptionsError(`\`${key}\` must be ${expected}.`);
53
+ return value;
54
+ }
55
+ // Boundary check for `export const VALIDATE_BOOT_OPTIONS` in kumiko/schema.ts.
56
+ // Only the options that change what validateBoot reports are honoured; `env` and
57
+ // `entityFieldCipher` are runner-injected and deliberately ignored here.
58
+ function parseValidateBootOptions(raw) {
59
+ if (raw === undefined)
60
+ return {};
61
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
62
+ throw validateBootOptionsError("must be an object (ValidateBootOptions).");
63
+ }
64
+ const navAllowlist = readOptionalOption(raw, "navAllowlist", isStringSet, "a Set<string>");
65
+ const navAllowlistExempt = readOptionalOption(raw, "navAllowlistExempt", isStringSet, "a Set<string>");
66
+ const warnOnUniqueAccessRoles = readOptionalOption(raw, "warnOnUniqueAccessRoles", isBoolean, "a boolean");
67
+ return {
68
+ ...(navAllowlist !== undefined && { navAllowlist }),
69
+ ...(navAllowlistExempt !== undefined && { navAllowlistExempt }),
70
+ ...(warnOnUniqueAccessRoles !== undefined && { warnOnUniqueAccessRoles }),
71
+ };
72
+ }
38
73
  // Single load-path for kumiko/schema.ts (512/3) — `generate` and `validate`
39
74
  // both need ENTITY_METAS (and validate additionally wants FEATURES); this is
40
75
  // the one place that enforces "ENTITY_METAS must be an array" so the two
@@ -42,13 +77,17 @@ function withFileStoragePresence(env) {
42
77
  // want a graceful exit code (validate) catch it themselves.
43
78
  async function loadSchemaModFromApp(schemaFile) {
44
79
  // bun imports TS directly — no spawn needed.
45
- const mod = (await import(__rewriteRelativeImportExtension(schemaFile)));
46
- if (!Array.isArray(mod.ENTITY_METAS)) {
80
+ const mod = await import(__rewriteRelativeImportExtension(schemaFile));
81
+ if (!Array.isArray(mod["ENTITY_METAS"])) {
47
82
  throw new Error(`Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`);
48
83
  }
49
- const entityMetas = mod.ENTITY_METAS;
84
+ const entityMetas = mod["ENTITY_METAS"];
50
85
  assertUniqueTableNames(entityMetas);
51
- return { entityMetas, features: mod.FEATURES };
86
+ return {
87
+ entityMetas,
88
+ features: mod["FEATURES"],
89
+ validateBootOptions: mod["VALIDATE_BOOT_OPTIONS"],
90
+ };
52
91
  }
53
92
  function nextSequenceNumber(migrationsDir) {
54
93
  if (!existsSync(migrationsDir))
@@ -201,7 +240,8 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
201
240
  // 1. schema drift: would `generate` write a migration? (= an entity was
202
241
  // added/changed but never generated → missing table → prod 500)
203
242
  // 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
204
- // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES.
243
+ // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES;
244
+ // honours its optional VALIDATE_BOOT_OPTIONS export.
205
245
  // 3. migration-content drift: replay every committed *.sql file and
206
246
  // diff the result against .snapshot.json — catches a migration
207
247
  // file whose SQL body doesn't match what its own snapshot entry
@@ -257,6 +297,7 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
257
297
  if (Array.isArray(mod.FEATURES)) {
258
298
  try {
259
299
  validateBoot(mod.FEATURES, {
300
+ ...parseValidateBootOptions(schemaMod.validateBootOptions),
260
301
  env: withFileStoragePresence(process.env),
261
302
  });
262
303
  out.log(" ✓ boot: feature configuration is valid");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.371.0",
3
+ "version": "0.372.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -199,8 +199,8 @@
199
199
  "./package.json": "./package.json"
200
200
  },
201
201
  "dependencies": {
202
- "@cosmicdrift/kumiko-http": "0.371.0",
203
- "@cosmicdrift/kumiko-types": "0.371.0",
202
+ "@cosmicdrift/kumiko-http": "0.372.0",
203
+ "@cosmicdrift/kumiko-types": "0.372.0",
204
204
  "bullmq": "^5.76.7",
205
205
  "bun-types": "^1.3.13",
206
206
  "hono": "^4.13.1",
@@ -216,8 +216,8 @@
216
216
  "zod": "^4.4.3"
217
217
  },
218
218
  "devDependencies": {
219
- "@cosmicdrift/kumiko-dispatcher-live": "0.371.0",
220
- "@cosmicdrift/kumiko-testing": "0.371.0",
219
+ "@cosmicdrift/kumiko-dispatcher-live": "0.372.0",
220
+ "@cosmicdrift/kumiko-testing": "0.372.0",
221
221
  "bun-types": "^1.3.13",
222
222
  "pino-pretty": "^13.1.3"
223
223
  },
package/src/changes.json CHANGED
@@ -1,4 +1,46 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "improvement",
5
+ "title": "kumiko check validates the boot configuration with the validateBootOptions from kumiko.config.ts",
6
+ "detail": "Set validateBootOptions in defineAppConfig (navAllowlist, navAllowlistExempt, warnOnUniqueAccessRoles); the managed kumiko/schema.ts exports it as VALIDATE_BOOT_OPTIONS and kumiko check validates with it. Apps with a hand-written kumiko/schema.ts can export VALIDATE_BOOT_OPTIONS themselves.",
7
+ "migration": "Existing apps get the new line in kumiko/schema.ts through `kumiko-upgrade --apply` (managed file). The bin files belong to the app: pass `...(appConfig.validateBootOptions && { validateBootOptions: appConfig.validateBootOptions })` to runProdApp in bin/main.ts and to runDevApp in bin/server.ts (import appConfig from \"../kumiko.config\") so runtime and kumiko check agree."
8
+ },
9
+ {
10
+ "version": "0.372.0",
11
+ "type": "fix",
12
+ "title": "createEntity no longer collapses to never for inline field literals such as { type: \"reference\", entity }",
13
+ "detail": "The fields type parameter is const, so the literal type of each inline field survives without as const."
14
+ },
15
+ {
16
+ "version": "0.372.0",
17
+ "type": "breaking",
18
+ "title": "executor.detail() and executor.list() return EntityRow<E>, derived from the entity definition",
19
+ "detail": "Rows of createEntityExecutor / createEventStoreExecutor are typed per entity; wide entities keep Record<string, unknown>. createBooleanField is typed non-null. UserMfaRow in the bundled auth-mfa feature now declares enabledAt and lastUsedAt as Temporal.Instant, which is what the row always held.",
20
+ "migration": "Remove the `row as T` casts on executor.detail() / executor.list() results with `bun kumiko-upgrade --apply` (codemod remove-engine-payload-casts). Casts to a type the row does not satisfy stay and are listed as manual; replace them with a field-level check or a narrower type.",
21
+ "codemod": "scripts/codemod/remove-engine-payload-casts.ts",
22
+ "manualAfterCodemod": true
23
+ },
24
+ {
25
+ "version": "0.372.0",
26
+ "type": "fix",
27
+ "title": "A save cannot reference a file that belongs to another record",
28
+ "detail": "Creating or updating a record with a file id that is already bound to a different record of the same tenant is now rejected with invalid_element on that field, the same error as for a foreign tenant. Pending uploads, ids the record already holds and moves between its own file fields are unaffected."
29
+ },
30
+ {
31
+ "version": "0.372.0",
32
+ "type": "breaking",
33
+ "title": "Tokens carrying system or all lose those roles on verify",
34
+ "detail": "jwt.verify strips the system and all roles that session minting never grants, so a self-signed or leaked-key token cannot act as System.",
35
+ "migration": "Apps that mint their own tokens with the `system` role must stop doing so and use `createSystemUser` or system dispatch in-process instead. Tokens issued by the framework are unaffected. Tests that send a system user over `stack.http.*` must switch to `stack.dispatcher.write/query`."
36
+ },
37
+ {
38
+ "version": "0.372.0",
39
+ "type": "breaking",
40
+ "title": "http.route and the metric route label carry the route pattern, http.target is removed",
41
+ "detail": "Spans are named \"METHOD pattern\" instead of \"http.request\"; paths no route matched are labelled \"unmatched\". The PII ciphertext guard message names the pattern as well.",
42
+ "migration": "Dashboards, alerts and trace queries keyed on raw paths or on the span name \"http.request\" must switch to route patterns and the \"METHOD pattern\" span name. Read the query string from your access logs if you relied on http.target."
43
+ },
2
44
  {
3
45
  "version": "0.371.0",
4
46
  "type": "improvement",
@@ -118,3 +118,25 @@ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/import-extens
118
118
  Runs via `bun kumiko-upgrade --apply`. Without a mode flag it writes. `--check` exits 1 while any
119
119
  rewritable or manual site is left. The `import-extensions` guard (`kumiko check guards`) reports
120
120
  sites that are still on `.js`.
121
+
122
+ ## remove-engine-payload-casts.ts
123
+
124
+ `executor.detail()` and `executor.list()` from `createEntityExecutor` / `createEventStoreExecutor`
125
+ return the entity's own row type (`EntityRow<E>`), so `row["f"] as T`, `row as T` and
126
+ `(await executor.detail(…)) as unknown as T` are usually redundant. The codemod removes such a cast,
127
+ and its `@cast-boundary` marker comment when no other cast stays in the statement, only when the
128
+ operand derives from an executor read (directly, through `rows[0]`, property access, `??` or a
129
+ single-assignment variable) and the type checker says its type is assignable to the cast target. A
130
+ file is left untouched if removing the casts would add type errors to it.
131
+
132
+ It type-checks against the tsconfig at or above each file, so run it with the new framework version
133
+ installed. Reported as `manual` and left alone: casts the row type does not satisfy (for example a
134
+ nullable column cast to a non-null type, or `rows[0]` under `noUncheckedIndexedAccess`), and files
135
+ without a tsconfig. A call site that still needs a narrower type than the row gives keeps its cast.
136
+
137
+ ```bash
138
+ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/remove-engine-payload-casts.ts <dir> [--check | --dry-run | --write] [--no-format]
139
+ ```
140
+
141
+ Runs via `bun kumiko-upgrade --apply`. Without a mode flag it writes, then formats the changed files
142
+ with Biome. `--check` exits 1 while any rewritable or manual site is left.