@cosmicdrift/kumiko-framework 0.371.1 → 0.372.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/agents/examples/custom-handlers.md +3 -5
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/pdf-audit-export.md +1 -1
- package/agents/examples/user-data-rights.md +176 -0
- package/dist/api/jwt.js +2 -1
- package/dist/api/observability-middleware.d.ts +1 -2
- package/dist/api/observability-middleware.js +9 -8
- package/dist/api/pii-leak-guard.js +2 -1
- package/dist/api/route-label.d.ts +3 -0
- package/dist/api/route-label.js +17 -0
- package/dist/api/server.d.ts +1 -2
- package/dist/api/server.js +3 -5
- package/dist/config/index.d.ts +5 -0
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/located-timestamp.d.ts +7 -0
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +8 -2
- package/dist/db/table-builder.d.ts +45 -11
- package/dist/db/tenant-db.js +3 -1
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/factories.d.ts +2 -1
- package/dist/engine/factories.js +2 -0
- package/dist/engine/membership-roles.d.ts +1 -0
- package/dist/engine/membership-roles.js +8 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
- package/dist/entrypoint/index.d.ts +1 -0
- package/dist/entrypoint/index.js +0 -2
- package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
- package/dist/files/create-file-ref-from-bytes.js +12 -6
- package/dist/files/create-file-ref.d.ts +20 -2
- package/dist/files/create-file-ref.js +10 -1
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +25 -0
- package/dist/files/file-handle.d.ts +4 -1
- package/dist/files/file-handle.js +30 -4
- package/dist/files/file-ref-entity.d.ts +7 -7
- package/dist/files/file-ref-table.d.ts +7 -7
- package/dist/files/file-routes.js +6 -1
- package/dist/files/index.d.ts +3 -1
- package/dist/files/index.js +3 -1
- package/dist/files/provider-resolver.js +3 -1
- package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
- package/dist/files/sweep-orphaned-uploads.js +53 -0
- package/dist/files/upload-ledger.d.ts +10 -0
- package/dist/files/upload-ledger.js +47 -0
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/request-locale.d.ts +15 -4
- package/dist/i18n/request-locale.js +26 -9
- package/dist/jobs/job-runner.js +2 -4
- package/dist/pipeline/dispatch-shared.js +2 -4
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/rate-limit/index.d.ts +1 -0
- package/dist/rate-limit/index.js +1 -0
- package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
- package/dist/rate-limit/unlimited-resolver.js +21 -0
- package/dist/schema-cli.js +46 -5
- package/package.json +5 -5
- package/src/changes.json +42 -0
- package/src/scripts/codemod/README.md +22 -0
- 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
|
-
|
|
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
|
+
}
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/i18n/index.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Tags
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Tags
|
|
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
|
|
41
|
+
export function rankAcceptLanguage(header) {
|
|
42
42
|
if (header === undefined || header.length === 0)
|
|
43
|
-
return
|
|
44
|
-
|
|
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
|
-
|
|
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;
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -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";
|
package/dist/rate-limit/index.js
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/schema-cli.js
CHANGED
|
@@ -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 =
|
|
46
|
-
if (!Array.isArray(mod
|
|
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
|
|
84
|
+
const entityMetas = mod["ENTITY_METAS"];
|
|
50
85
|
assertUniqueTableNames(entityMetas);
|
|
51
|
-
return {
|
|
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.
|
|
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.
|
|
203
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
220
|
-
"@cosmicdrift/kumiko-testing": "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.
|