@cosmicdrift/kumiko-framework 0.382.0 → 0.383.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/examples/api-worker-split.md +57 -0
- package/agents/examples/record-detail-layout.md +20 -18
- package/agents/examples/screens-nav.md +3 -2
- package/agents/examples/writeform-section.md +2 -1
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-middleware.d.ts +1 -0
- package/dist/api/auth-middleware.js +17 -0
- package/dist/api/auth-routes.d.ts +1 -0
- package/dist/api/auth-routes.js +14 -4
- package/dist/api/client-error-route.d.ts +12 -0
- package/dist/api/client-error-route.js +113 -0
- package/dist/api/request-context.d.ts +6 -0
- package/dist/api/request-context.js +9 -0
- package/dist/api/routes.js +7 -4
- package/dist/api/server.js +11 -1
- package/dist/db/connection.d.ts +11 -0
- package/dist/db/connection.js +63 -3
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/migrate-runner.js +4 -0
- package/dist/db/queries/backfill-pii.js +5 -1
- package/dist/db/queries/event-store.js +9 -2
- package/dist/db/statement-timeout.d.ts +2 -0
- package/dist/db/statement-timeout.js +18 -0
- package/dist/db/tenant-db.js +5 -12
- package/dist/engine/boot-validator/access-declarations.js +17 -18
- package/dist/engine/boot-validator/query-output-columns.js +1 -2
- package/dist/engine/boot-validator/screens.js +12 -16
- package/dist/engine/boot-validator/url-prefill-fields.js +1 -3
- package/dist/engine/build-config-feature-schema.js +4 -4
- package/dist/engine/entity-handlers.js +5 -7
- package/dist/engine/escape-hatch-declaration.d.ts +1 -1
- package/dist/engine/escape-hatch-declaration.js +5 -2
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +35 -0
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/feature-config-events-jobs.js +6 -4
- package/dist/engine/feature-ui-extensions.js +12 -6
- package/dist/engine/iban.d.ts +2 -0
- package/dist/engine/iban.js +19 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/project-app-schema-for-roles.js +1 -1
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +8 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +9 -0
- package/dist/engine/screen-helpers.d.ts +3 -3
- package/dist/engine/screen-helpers.js +1 -2
- package/dist/engine/steps/_read-source.js +2 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/entrypoint/index.d.ts +1 -0
- package/dist/entrypoint/index.js +1 -0
- package/dist/event-store/event-store.d.ts +0 -3
- package/dist/event-store/event-store.js +1 -26
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/snapshot.js +6 -1
- package/dist/files/file-ref-entity.d.ts +6 -6
- package/dist/files/file-ref-table.d.ts +6 -6
- package/dist/i18n/required-surface-keys.js +0 -1
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +74 -7
- package/dist/logging/index.d.ts +2 -1
- package/dist/logging/index.js +2 -1
- package/dist/logging/pino-logger.d.ts +1 -0
- package/dist/logging/pino-logger.js +35 -0
- package/dist/logging/request-log-fields.d.ts +1 -0
- package/dist/logging/request-log-fields.js +21 -0
- package/dist/logging/scrub.d.ts +2 -0
- package/dist/logging/scrub.js +21 -0
- package/dist/logging/utils.d.ts +1 -0
- package/dist/logging/utils.js +40 -6
- package/dist/migrations/pii-event-backfill.js +5 -1
- package/dist/observability/standard-metrics.d.ts +1 -0
- package/dist/observability/standard-metrics.js +9 -0
- package/dist/pipeline/msp-rebuild.js +5 -1
- package/dist/pipeline/projection-rebuild.js +5 -1
- package/dist/rate-limit/middleware.d.ts +8 -3
- package/dist/rate-limit/middleware.js +28 -10
- package/dist/rate-limit/resolver.js +10 -0
- package/dist/redis/client.d.ts +2 -0
- package/dist/redis/client.js +20 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/search/purge-subject.js +6 -8
- package/dist/search/reindex-entity.js +11 -10
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/package.json +3 -3
- package/src/changes.json +70 -0
package/dist/db/connection.js
CHANGED
|
@@ -4,6 +4,13 @@ import { readPositiveIntEnv } from "../utils/env-parse.js";
|
|
|
4
4
|
// so a plain end() (no timeout = wait for queries to finish) hangs forever —
|
|
5
5
|
// close() must always bound how long shutdown waits.
|
|
6
6
|
export const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
|
7
|
+
// Keyed by the pool so withoutStatementTimeout can open a sibling with identical connect options.
|
|
8
|
+
const unboundedSiblingFactories = new WeakMap();
|
|
9
|
+
export function openUnboundedSiblingPool(db) {
|
|
10
|
+
if (typeof db !== "function" && (typeof db !== "object" || db === null))
|
|
11
|
+
return undefined;
|
|
12
|
+
return unboundedSiblingFactories.get(db)?.();
|
|
13
|
+
}
|
|
7
14
|
export function createDbConnection({ url, ...options }) {
|
|
8
15
|
const pgOptions = {};
|
|
9
16
|
if (options.maxConnections !== undefined)
|
|
@@ -18,13 +25,51 @@ export function createDbConnection({ url, ...options }) {
|
|
|
18
25
|
pgOptions.ssl = options.ssl;
|
|
19
26
|
if (options.onNotice !== undefined)
|
|
20
27
|
pgOptions.onnotice = options.onNotice;
|
|
28
|
+
const serverParameters = {};
|
|
29
|
+
if (options.statementTimeoutMs !== undefined) {
|
|
30
|
+
serverParameters["statement_timeout"] = options.statementTimeoutMs;
|
|
31
|
+
}
|
|
32
|
+
if (options.idleInTransactionSessionTimeoutMs !== undefined) {
|
|
33
|
+
serverParameters["idle_in_transaction_session_timeout"] =
|
|
34
|
+
options.idleInTransactionSessionTimeoutMs;
|
|
35
|
+
}
|
|
36
|
+
if (options.lockTimeoutMs !== undefined)
|
|
37
|
+
serverParameters["lock_timeout"] = options.lockTimeoutMs;
|
|
38
|
+
if (Object.keys(serverParameters).length > 0)
|
|
39
|
+
pgOptions.connection = serverParameters;
|
|
21
40
|
const client = postgres(url, pgOptions);
|
|
41
|
+
// A run that never settles (a caller gave up on it via its own timeout) never reaches the
|
|
42
|
+
// sibling's own close; close() ends it so shutdown and DROP DATABASE are not blocked by it.
|
|
43
|
+
const openSiblings = new Set();
|
|
44
|
+
unboundedSiblingFactories.set(client, () => {
|
|
45
|
+
// Two connections are enough for lock-in-tx plus DDL; the cap keeps parallel rebuilds from exhausting max_connections.
|
|
46
|
+
const sibling = postgres(url, {
|
|
47
|
+
...pgOptions,
|
|
48
|
+
max: 4,
|
|
49
|
+
connection: { ...pgOptions.connection, statement_timeout: 0 },
|
|
50
|
+
});
|
|
51
|
+
openSiblings.add(sibling);
|
|
52
|
+
return {
|
|
53
|
+
client: sibling,
|
|
54
|
+
close: async (timeoutSeconds) => {
|
|
55
|
+
openSiblings.delete(sibling);
|
|
56
|
+
await sibling.end({ timeout: timeoutSeconds });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
});
|
|
22
60
|
return {
|
|
23
61
|
db: client,
|
|
24
62
|
client,
|
|
25
|
-
close: () =>
|
|
26
|
-
timeout
|
|
27
|
-
|
|
63
|
+
close: async () => {
|
|
64
|
+
const timeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
|
|
65
|
+
unboundedSiblingFactories.delete(client);
|
|
66
|
+
const pools = [client, ...openSiblings];
|
|
67
|
+
openSiblings.clear();
|
|
68
|
+
const results = await Promise.allSettled(pools.map((pool) => pool.end({ timeout })));
|
|
69
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
70
|
+
if (failure !== undefined)
|
|
71
|
+
throw failure.reason;
|
|
72
|
+
},
|
|
28
73
|
};
|
|
29
74
|
}
|
|
30
75
|
// DB_PROVIDER=bun selected the removed Bun.SQL driver; ignoring it would boot
|
|
@@ -49,6 +94,21 @@ export function dbConnectionOptionsFromEnv(env = process.env) {
|
|
|
49
94
|
opts.connectTimeoutSeconds = connect;
|
|
50
95
|
return opts;
|
|
51
96
|
}
|
|
97
|
+
export const DEFAULT_SERVER_STATEMENT_TIMEOUT_MS = 60_000;
|
|
98
|
+
// Handlers that await external calls inside a tx must not be killed; five minutes still ends leaked transactions.
|
|
99
|
+
export const DEFAULT_SERVER_IDLE_IN_TRANSACTION_TIMEOUT_MS = 300_000;
|
|
100
|
+
// Long-running servers (prod app, worker) bound runaway statements and tx held
|
|
101
|
+
// open by a stuck handler; one-shot CLIs keep the unbounded driver default.
|
|
102
|
+
export function serverTimeoutOptionsFromEnv(env = process.env) {
|
|
103
|
+
const lockTimeoutMs = readPositiveIntEnv(env, "DATABASE_LOCK_TIMEOUT_MS");
|
|
104
|
+
return {
|
|
105
|
+
statementTimeoutMs: readPositiveIntEnv(env, "DATABASE_STATEMENT_TIMEOUT_MS") ??
|
|
106
|
+
DEFAULT_SERVER_STATEMENT_TIMEOUT_MS,
|
|
107
|
+
idleInTransactionSessionTimeoutMs: readPositiveIntEnv(env, "DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_MS") ??
|
|
108
|
+
DEFAULT_SERVER_IDLE_IN_TRANSACTION_TIMEOUT_MS,
|
|
109
|
+
...(lockTimeoutMs !== undefined && { lockTimeoutMs }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
52
112
|
// Outside-tx access runs on its own pool: sharing the write pool deadlocks once
|
|
53
113
|
// pool-max concurrent writes each hold a tx and then wait for a second connection.
|
|
54
114
|
export const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
|
package/dist/db/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { collectTableMetas } from "./collect-table-metas.ts";
|
|
|
5
5
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.ts";
|
|
6
6
|
export { seedConfigValues } from "./config-seed.ts";
|
|
7
7
|
export type { CreateDbConnectionOptions, DbConnection, DbConnectionHandle, DbConnectionOptions, DbRow, DbRunner, DbTx, } from "./connection.ts";
|
|
8
|
-
export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.ts";
|
|
8
|
+
export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "./connection.ts";
|
|
9
9
|
export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cursor.ts";
|
|
10
10
|
export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.ts";
|
|
11
11
|
export type { SchemaTable, SelectQuery, TableColumns } from "./dialect.ts";
|
|
@@ -36,6 +36,7 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.ts";
|
|
|
36
36
|
export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.ts";
|
|
37
37
|
export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
|
|
38
38
|
export { tableExists } from "./schema-inspection.ts";
|
|
39
|
+
export { withoutStatementTimeout } from "./statement-timeout.ts";
|
|
39
40
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, type ExecutorManagedRunner, type ExecutorManagedWriteCapability, physicalColumnName, toSnakeCase, toTableName, type Writable, type WritableSchemaTable, type WritableTable, } from "./table-builder.ts";
|
|
40
41
|
export type { CreateTenantDbOptions, ExecutorManagedTenantDb, OutsideTransactionContext, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
|
|
41
42
|
export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
|
package/dist/db/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recor
|
|
|
4
4
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
5
5
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
6
6
|
export { seedConfigValues } from "./config-seed.js";
|
|
7
|
-
export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.js";
|
|
7
|
+
export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "./connection.js";
|
|
8
8
|
export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
9
9
|
export { bigint, bigserial, boolean, extractTableName, index, instant, instantToDriver, integer, jsonb, moneyAmount, numeric, primaryKey, serial, sql, table, text, timestamp, uniqueIndex, uuid, } from "./dialect.js";
|
|
10
10
|
export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "./eagerload.js";
|
|
@@ -29,5 +29,6 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
|
|
|
29
29
|
export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migration-sql.js";
|
|
30
30
|
export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
31
31
|
export { tableExists } from "./schema-inspection.js";
|
|
32
|
+
export { withoutStatementTimeout } from "./statement-timeout.js";
|
|
32
33
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
33
34
|
export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
|
@@ -239,6 +239,8 @@ export async function runMigrations(db, migrations) {
|
|
|
239
239
|
// which would leave the lock held on a session nobody unlocks.
|
|
240
240
|
const conn = await reserveConnection(db);
|
|
241
241
|
try {
|
|
242
|
+
// Server pools cap statement_timeout; a migration (backfill, index build) must not hit it.
|
|
243
|
+
await conn.unsafe("SET statement_timeout = 0");
|
|
242
244
|
await conn.unsafe(MIGRATIONS_TABLE_DDL);
|
|
243
245
|
await conn.unsafe(`SELECT pg_advisory_lock(${MIGRATION_ADVISORY_LOCK_KEY})`);
|
|
244
246
|
try {
|
|
@@ -282,6 +284,8 @@ export async function runMigrations(db, migrations) {
|
|
|
282
284
|
}
|
|
283
285
|
}
|
|
284
286
|
finally {
|
|
287
|
+
// A dead connection fails RESET; the pool drops it, so the migration's own outcome wins.
|
|
288
|
+
await conn.unsafe("RESET statement_timeout").catch(() => undefined);
|
|
285
289
|
await conn.release();
|
|
286
290
|
}
|
|
287
291
|
}
|
|
@@ -67,6 +67,7 @@ import { collectPiiEncryptedFields, nonEmptyString, resolveEventSubject, resolve
|
|
|
67
67
|
import { resolveTableName } from "../entity-table-meta.js";
|
|
68
68
|
import { asRawClient } from "../query.js";
|
|
69
69
|
import { tableExists } from "../schema-inspection.js";
|
|
70
|
+
import { withoutStatementTimeout } from "../statement-timeout.js";
|
|
70
71
|
import { toSnakeCase } from "../table-builder.js";
|
|
71
72
|
import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
|
|
72
73
|
const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
|
|
@@ -90,7 +91,10 @@ function requireLocalKeySubjectKms(caller) {
|
|
|
90
91
|
}
|
|
91
92
|
return kms;
|
|
92
93
|
}
|
|
93
|
-
export
|
|
94
|
+
export function backfillEventPiiEncryption(db, registry, options = {}) {
|
|
95
|
+
return withoutStatementTimeout(db, (unboundedDb) => backfillAllEventBatches(unboundedDb, registry, options));
|
|
96
|
+
}
|
|
97
|
+
async function backfillAllEventBatches(db, registry, options) {
|
|
94
98
|
requireLocalKeySubjectKms("backfillEventPiiEncryption");
|
|
95
99
|
const total = {
|
|
96
100
|
scannedEvents: 0,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { constraintOf, isLockNotAvailable, isTableAlreadyExists, isUniqueViolation, } from "../pg-error.js";
|
|
2
2
|
import { asRawClient, runInNewTransaction, runnerCanBeginTransaction, unsafeReadRetrying, } from "../query.js";
|
|
3
|
+
import { withoutStatementTimeout } from "../statement-timeout.js";
|
|
3
4
|
import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
|
|
4
5
|
// Gap-finality (event-dispatcher pending_gaps) needs every holder of an
|
|
5
6
|
// event id to already have a *real* xact id by the time it inserts — Postgres
|
|
@@ -48,7 +49,10 @@ async function indexValidity(client, indexName = IDEMPOTENCY_INDEX_NAME) {
|
|
|
48
49
|
`WHERE c.relname = $1 AND i.indrelid = '"kumiko_events"'::regclass`, [indexName]);
|
|
49
50
|
return rows[0]?.indisvalid;
|
|
50
51
|
}
|
|
51
|
-
export
|
|
52
|
+
export function ensureIdempotencyKeyIndex(db) {
|
|
53
|
+
return withoutStatementTimeout(db, buildIdempotencyKeyIndex);
|
|
54
|
+
}
|
|
55
|
+
async function buildIdempotencyKeyIndex(db) {
|
|
52
56
|
const client = asRawClient(db);
|
|
53
57
|
try {
|
|
54
58
|
// 1) A prior CONCURRENTLY build that got killed mid-flight (crash, deploy
|
|
@@ -89,7 +93,10 @@ export async function ensureIdempotencyKeyIndex(db) {
|
|
|
89
93
|
// tenant_id column — cross-tenant SystemAdmin adds land under the actor's
|
|
90
94
|
// tenant), which no other index covers: without this it scans every event.
|
|
91
95
|
// Same CONCURRENTLY + INVALID-rebuild handling as the idempotency index.
|
|
92
|
-
export
|
|
96
|
+
export function ensureMembershipPayloadTenantIndex(db) {
|
|
97
|
+
return withoutStatementTimeout(db, buildMembershipPayloadTenantIndex);
|
|
98
|
+
}
|
|
99
|
+
async function buildMembershipPayloadTenantIndex(db) {
|
|
93
100
|
const client = asRawClient(db);
|
|
94
101
|
try {
|
|
95
102
|
if ((await indexValidity(client, MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME)) === false) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { openUnboundedSiblingPool } from "./connection.js";
|
|
2
|
+
const UNBOUNDED_POOL_CLOSE_TIMEOUT_SECONDS = 5;
|
|
3
|
+
// Server pools cap statement_timeout (see serverTimeoutOptionsFromEnv); index builds, reindex,
|
|
4
|
+
// PII backfill and projection rebuilds legitimately run longer. The cap is a connect-time session
|
|
5
|
+
// setting, so the run gets its own short-lived pool with the same options minus the cap: one shared
|
|
6
|
+
// connection would deadlock code that holds a lock in a tx while its DDL runs on another connection.
|
|
7
|
+
// Handles not created by createDbConnection (tx, savepoint, siblings) are passed through.
|
|
8
|
+
export async function withoutStatementTimeout(db, run) {
|
|
9
|
+
const unbounded = openUnboundedSiblingPool(db);
|
|
10
|
+
if (unbounded === undefined)
|
|
11
|
+
return run(db);
|
|
12
|
+
try {
|
|
13
|
+
return await run(unbounded.client);
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
await unbounded.close(UNBOUNDED_POOL_CLOSE_TIMEOUT_SECONDS);
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
2
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
3
|
import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
|
|
4
|
+
import { assertReason } from "../engine/escape-hatch-reason.js";
|
|
4
5
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
6
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
7
|
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
@@ -152,9 +153,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
152
153
|
}
|
|
153
154
|
return sourceRunner(forwardedDeclaredStep ? reason : undefined);
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
-
throw new Error("unsafeRaw requires a non-empty reason");
|
|
157
|
-
}
|
|
156
|
+
assertReason("ctx.systemDb.unsafeRaw(reason)", reason);
|
|
158
157
|
if (gate && !hasGrant(gate.grant)) {
|
|
159
158
|
throw new AccessDeniedError({
|
|
160
159
|
message: 'ctx.systemDb.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
@@ -189,9 +188,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
189
188
|
return rows;
|
|
190
189
|
},
|
|
191
190
|
acknowledgeCrossTenant(reason) {
|
|
192
|
-
|
|
193
|
-
throw new Error("acknowledgeCrossTenant requires a non-empty reason");
|
|
194
|
-
}
|
|
191
|
+
assertReason("acknowledgeCrossTenant(reason)", reason);
|
|
195
192
|
report("acknowledge-cross-tenant", reason);
|
|
196
193
|
return db;
|
|
197
194
|
},
|
|
@@ -206,9 +203,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
206
203
|
return requireSystemOutsideTransactionDb();
|
|
207
204
|
},
|
|
208
205
|
acknowledgeCrossTenant(reason) {
|
|
209
|
-
|
|
210
|
-
throw new Error("acknowledgeCrossTenant requires a non-empty reason");
|
|
211
|
-
}
|
|
206
|
+
assertReason("acknowledgeCrossTenant(reason)", reason);
|
|
212
207
|
const result = requireSystemOutsideTransactionDb();
|
|
213
208
|
report("acknowledge-cross-tenant", reason);
|
|
214
209
|
return result;
|
|
@@ -306,9 +301,7 @@ const crossTenantUseReporters = new WeakMap();
|
|
|
306
301
|
// `deferReport` leaves the audit entry to reportConventionCrossTenantUse, so a caller that learns
|
|
307
302
|
// which foreign row it touches can attach that row as the entry's target.
|
|
308
303
|
export function acknowledgeConventionCrossTenant(tenantDb, reason, options) {
|
|
309
|
-
|
|
310
|
-
throw new Error("acknowledgeConventionCrossTenant requires a non-empty reason");
|
|
311
|
-
}
|
|
304
|
+
assertReason("acknowledgeConventionCrossTenant", reason);
|
|
312
305
|
const rebind = crossTenantRebinders.get(tenantDb);
|
|
313
306
|
if (!rebind) {
|
|
314
307
|
throw new InternalError({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { escapeHatchGrantsProblem, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
-
import {
|
|
2
|
+
import { reasonProblem } from "../escape-hatch-reason.js";
|
|
3
3
|
import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
|
|
4
4
|
import { ANONYMOUS_ROLE } from "../system-user.js";
|
|
5
5
|
import { collectZodObjectKeys } from "./zod-shape.js";
|
|
@@ -24,16 +24,16 @@ function candidatePersonalFieldNames(feature, handlerName, handler, honorOwnerBi
|
|
|
24
24
|
}
|
|
25
25
|
return names;
|
|
26
26
|
}
|
|
27
|
-
// Not an object with a
|
|
28
|
-
function
|
|
27
|
+
// Not an object with a string `reason` (incl. deprecated `openToAll: true`) counts as malformed.
|
|
28
|
+
function openToAllReasonOf(access) {
|
|
29
29
|
if (!("openToAll" in access))
|
|
30
|
-
return
|
|
30
|
+
return undefined;
|
|
31
31
|
const openToAll = access.openToAll;
|
|
32
32
|
if (typeof openToAll !== "object" || openToAll === null)
|
|
33
|
-
return
|
|
33
|
+
return "";
|
|
34
34
|
if (!("reason" in openToAll) || typeof openToAll.reason !== "string")
|
|
35
|
-
return
|
|
36
|
-
return openToAll.reason
|
|
35
|
+
return "";
|
|
36
|
+
return openToAll.reason;
|
|
37
37
|
}
|
|
38
38
|
function hasOpenToAll(access) {
|
|
39
39
|
return "openToAll" in access;
|
|
@@ -42,11 +42,16 @@ function declaresTenantMembersPersonalData(access) {
|
|
|
42
42
|
return declaredPersonalData(access) === "tenant-members";
|
|
43
43
|
}
|
|
44
44
|
function validateOpenToAllReason(feature, kind, handlerName, access) {
|
|
45
|
+
const reason = openToAllReasonOf(access);
|
|
46
|
+
// skip: no openToAll declared
|
|
47
|
+
if (reason === undefined)
|
|
48
|
+
return;
|
|
49
|
+
const problem = reasonProblem(reason);
|
|
45
50
|
// skip: reason is well-formed, nothing to validate
|
|
46
|
-
if (
|
|
51
|
+
if (problem === undefined)
|
|
47
52
|
return;
|
|
48
53
|
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid ` +
|
|
49
|
-
|
|
54
|
+
`{ openToAll: ... } — ${problem}. Must be { reason: "<explanation>" } for why any ` +
|
|
50
55
|
"authenticated user may call this handler. `{ openToAll: true }` is no longer accepted.");
|
|
51
56
|
}
|
|
52
57
|
function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
|
|
@@ -55,15 +60,9 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
|
|
|
55
60
|
if (grantsProblem !== undefined) {
|
|
56
61
|
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${grantsProblem}`);
|
|
57
62
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
|
|
62
|
-
"or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
|
|
63
|
-
}
|
|
64
|
-
const placeholderProblem = codemodPlaceholderReasonProblem(escapeHatch.reason);
|
|
65
|
-
if (placeholderProblem !== undefined) {
|
|
66
|
-
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${placeholderProblem}`);
|
|
63
|
+
const problem = reasonProblem(escapeHatch.reason);
|
|
64
|
+
if (problem !== undefined) {
|
|
65
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${problem}`);
|
|
67
66
|
}
|
|
68
67
|
}
|
|
69
68
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { isPagedQueryHandler } from "../define-handler.js";
|
|
2
2
|
import { normalizeListColumn, sectionFieldSpecs } from "../screen-helpers.js";
|
|
3
|
-
import { metricField } from "../types/index.js";
|
|
4
3
|
import { buildQueryHandlerMap } from "./projection-list-screens.js";
|
|
5
4
|
import { validateActionFieldRefs } from "./screens.js";
|
|
6
5
|
import { getZodObjectShape, getZodRowShape } from "./zod-shape.js";
|
|
@@ -106,7 +105,7 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
|
|
|
106
105
|
checkFieldExists(recordShape, field, () => `${prefix} recordTitleField references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
107
106
|
}
|
|
108
107
|
for (const metric of screen.metrics ?? []) {
|
|
109
|
-
const field =
|
|
108
|
+
const field = metric.field;
|
|
110
109
|
checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
|
|
111
110
|
}
|
|
112
111
|
checkDetailSectionFields(recordShape, prefix, screen);
|
|
@@ -16,7 +16,6 @@ import { parseRefTarget } from "../parse-ref-target.js";
|
|
|
16
16
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
17
17
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
|
|
18
18
|
import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
19
|
-
import { metricField } from "../types/index.js";
|
|
20
19
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
21
20
|
import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
|
|
22
21
|
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
@@ -719,11 +718,11 @@ function validateFormMoneyCurrency(featureName, screenId, context, fieldName, fd
|
|
|
719
718
|
// `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
|
|
720
719
|
// every EditLayout-walking screen type instead of four hand-rolled copies.
|
|
721
720
|
function validateFieldsXorGroups(errorPrefix, section) {
|
|
722
|
-
if (section.fields
|
|
723
|
-
throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups —
|
|
724
|
-
|
|
721
|
+
if (section.fields !== undefined && section.groups !== undefined) {
|
|
722
|
+
throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — remove ` +
|
|
723
|
+
`\`fields: []\` when using groups.`);
|
|
725
724
|
}
|
|
726
|
-
if (section.fields
|
|
725
|
+
if ((section.fields?.length ?? 0) === 0 && (section.groups?.length ?? 0) === 0) {
|
|
727
726
|
throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
|
|
728
727
|
`fields (or groups) to it.`);
|
|
729
728
|
}
|
|
@@ -1363,8 +1362,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1363
1362
|
if (screen.layout.mode === "tabs") {
|
|
1364
1363
|
validateTabSections(feature.name, screenId, "projectionDetail", screen.layout.sections);
|
|
1365
1364
|
for (const section of screen.layout.sections) {
|
|
1366
|
-
|
|
1367
|
-
|
|
1365
|
+
if (isFieldsEditSection(section) &&
|
|
1366
|
+
"groups" in section &&
|
|
1367
|
+
section.actions !== undefined) {
|
|
1368
1368
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.id}" ` +
|
|
1369
1369
|
`declares both groups and actions in tabs mode — actions has no single group's Card to sit ` +
|
|
1370
1370
|
`in, and an extra title-less Card around the group cards would nest a Card inside a Card. ` +
|
|
@@ -1374,14 +1374,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1374
1374
|
}
|
|
1375
1375
|
if (screen.metrics !== undefined) {
|
|
1376
1376
|
for (const metric of screen.metrics) {
|
|
1377
|
-
const field =
|
|
1378
|
-
|
|
1379
|
-
if (!hasOwnLabel && screen.fieldLabels?.[field] === undefined) {
|
|
1377
|
+
const field = metric.field;
|
|
1378
|
+
if (typeof metric.label !== "string" || metric.label.length === 0) {
|
|
1380
1379
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" has ` +
|
|
1381
|
-
`no
|
|
1380
|
+
`no "label" — every metric needs an i18n label key, ` +
|
|
1382
1381
|
`there is no fallback to the raw column name.`);
|
|
1383
1382
|
}
|
|
1384
|
-
const navigate =
|
|
1383
|
+
const navigate = metric.navigate;
|
|
1385
1384
|
if (navigate !== undefined) {
|
|
1386
1385
|
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
1387
1386
|
if (navigate.tab !== undefined &&
|
|
@@ -1525,14 +1524,11 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1525
1524
|
`(writeForm) has zero fields — drop the section or add fields to it.`);
|
|
1526
1525
|
}
|
|
1527
1526
|
// A ref to a fieldDef the form never renders would never resolve.
|
|
1528
|
-
const writeFormFieldNames = new Set(
|
|
1529
|
-
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1530
|
-
section.fields.map((f) => normalizeEditField(f).field));
|
|
1527
|
+
const writeFormFieldNames = new Set(section.fields.map((f) => normalizeEditField(f).field));
|
|
1531
1528
|
for (const [defName, fdef] of Object.entries(section.fieldDefs)) {
|
|
1532
1529
|
validateFormSelectOptions(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef, writeFormFieldNames);
|
|
1533
1530
|
rejectWriteOnlyFormField(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef);
|
|
1534
1531
|
}
|
|
1535
|
-
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1536
1532
|
for (const f of section.fields) {
|
|
1537
1533
|
const fieldName = normalizeEditField(f).field;
|
|
1538
1534
|
if (section.fieldDefs[fieldName] === undefined) {
|
|
@@ -29,9 +29,7 @@ function projectionDetailNavigateActions(screen) {
|
|
|
29
29
|
pick: [relatedListParentParamField(section)],
|
|
30
30
|
}),
|
|
31
31
|
]),
|
|
32
|
-
...(screen.metrics ?? []).flatMap((metric) =>
|
|
33
|
-
? []
|
|
34
|
-
: scopeActions([metric.navigate], undefined)),
|
|
32
|
+
...(screen.metrics ?? []).flatMap((metric) => metric.navigate === undefined ? [] : scopeActions([metric.navigate], undefined)),
|
|
35
33
|
...subtitlePartNavigates(screen).flatMap((navigate) => scopeActions([navigate], undefined)),
|
|
36
34
|
];
|
|
37
35
|
}
|
|
@@ -20,7 +20,7 @@ import { isEncryptedAtRest } from "./config-helpers.js";
|
|
|
20
20
|
import { EXTENSION_SELECTOR_HINT_KEY, isSelectableInSettings, settingsSelectablePluginIds, } from "./extension-selector-plugins.js";
|
|
21
21
|
import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
|
|
22
22
|
import { isKebabSegment, toKebab } from "./qualified-name.js";
|
|
23
|
-
import { isFieldsEditSection, sectionFieldSpecs } from "./screen-helpers.js";
|
|
23
|
+
import { isFieldsEditSection, sectionFieldSpecs, } from "./screen-helpers.js";
|
|
24
24
|
import { isOpenToAllGranted } from "./types/handlers.js";
|
|
25
25
|
// Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
|
|
26
26
|
// (find-or-create FeatureSchema). MUSS gleich CONFIG_FEATURE aus dem config
|
|
@@ -349,9 +349,10 @@ class ProviderFormBuilder {
|
|
|
349
349
|
const section = pluginScreen.layout.sections.find(isFieldsEditSection);
|
|
350
350
|
if (section === undefined)
|
|
351
351
|
return undefined;
|
|
352
|
-
const
|
|
352
|
+
const widened = section;
|
|
353
|
+
const { groups: _groups, fields: _fields, ...sectionBase } = widened;
|
|
353
354
|
const gated = {
|
|
354
|
-
...
|
|
355
|
+
...sectionBase,
|
|
355
356
|
fields: gateFields(sectionFieldSpecs(section), visible),
|
|
356
357
|
};
|
|
357
358
|
this.sections.push(gated);
|
|
@@ -374,7 +375,6 @@ class ProviderFormBuilder {
|
|
|
374
375
|
this.requiredFields.push(...(secretsScreen.requiredFields ?? []));
|
|
375
376
|
const secretsTitleKey = `${feature}.settings.secrets`;
|
|
376
377
|
for (const section of secretsScreen.sections) {
|
|
377
|
-
// kumiko-lint-ignore section-fields-raw a secretsEdit section is a flat key list without groups
|
|
378
378
|
const fields = gateFields(section.fields, visible);
|
|
379
379
|
const ownSection = section.title === secretsTitleKey || pluginSection === undefined;
|
|
380
380
|
if (ownSection) {
|
|
@@ -8,7 +8,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
8
8
|
import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
|
|
9
9
|
import { assertUnreachable } from "../utils/index.js";
|
|
10
10
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
|
|
11
|
-
import {
|
|
11
|
+
import { assertReason } from "./escape-hatch-reason.js";
|
|
12
12
|
import { dropEmptyWriteOnlyValues } from "./field-access.js";
|
|
13
13
|
import { instructionFieldNames } from "./instruction-fields.js";
|
|
14
14
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
@@ -60,12 +60,7 @@ function isSystemAdminOnly(access) {
|
|
|
60
60
|
function resolveCrossTenantReason(name, options, kind) {
|
|
61
61
|
if (options.escapeHatch === undefined)
|
|
62
62
|
return undefined;
|
|
63
|
-
|
|
64
|
-
throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
|
|
65
|
-
}
|
|
66
|
-
const placeholderProblem = codemodPlaceholderReasonProblem(options.escapeHatch.reason);
|
|
67
|
-
if (placeholderProblem !== undefined)
|
|
68
|
-
throw new Error(`"${name}": ${placeholderProblem}`);
|
|
63
|
+
assertReason(`"${name}": escapeHatch`, options.escapeHatch.reason);
|
|
69
64
|
assertEscapeHatchAccessIsSystemAdminOnly(name, kind, options.access);
|
|
70
65
|
return options.escapeHatch.reason;
|
|
71
66
|
}
|
|
@@ -528,6 +523,9 @@ export function createEntityExecutor(entityName, entity) {
|
|
|
528
523
|
// For anything more involved (filters, joins, custom shaping), write the
|
|
529
524
|
// query-handler explicitly with ctx.queryProjection or a raw select.
|
|
530
525
|
export function defineProjectionQueryHandler(name, projectionQualifiedName, options) {
|
|
526
|
+
if (typeof options.unsafeAllTenants === "object") {
|
|
527
|
+
assertReason(`"${name}": unsafeAllTenants`, options.unsafeAllTenants.reason);
|
|
528
|
+
}
|
|
531
529
|
const escapeHatch = options.escapeHatch ??
|
|
532
530
|
(typeof options.unsafeAllTenants === "object"
|
|
533
531
|
? { reason: options.unsafeAllTenants.reason }
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { EscapeHatchDeclaration } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
-
export declare function declareEscapeHatch(
|
|
2
|
+
export declare function declareEscapeHatch(declaration: EscapeHatchDeclaration): void;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { assertReason } from "./escape-hatch-reason.js";
|
|
1
2
|
// Marks a standalone helper that escalates on a HandlerContext handed to it
|
|
2
3
|
// by its caller. The caller's registration carries the escapeHatch that
|
|
3
4
|
// authorizes it; this call makes the escalation visible at the site where it
|
|
4
|
-
// happens
|
|
5
|
-
export function declareEscapeHatch(
|
|
5
|
+
// happens and rejects an empty or placeholder reason.
|
|
6
|
+
export function declareEscapeHatch(declaration) {
|
|
7
|
+
assertReason("declareEscapeHatch", declaration.reason);
|
|
8
|
+
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export declare const CODEMOD_PLACEHOLDER_REASON_MARKER = "state the operator use case here";
|
|
2
2
|
export declare function codemodPlaceholderReasonProblem(reason: string): string | undefined;
|
|
3
|
+
export declare function reasonProblem(reason: string): string | undefined;
|
|
4
|
+
export declare function assertReason(callerLabel: string, reason: string): void;
|
|
@@ -6,3 +6,38 @@ export function codemodPlaceholderReasonProblem(reason) {
|
|
|
6
6
|
return undefined;
|
|
7
7
|
return `the reason still contains the migrate-cross-tenant codemod placeholder ("${CODEMOD_PLACEHOLDER_REASON_MARKER}") — replace the codemod placeholder with the real operator use case`;
|
|
8
8
|
}
|
|
9
|
+
// Compared after trim, lowercase and stripping trailing punctuation.
|
|
10
|
+
const PLACEHOLDER_REASONS = new Set([
|
|
11
|
+
"todo",
|
|
12
|
+
"tbd",
|
|
13
|
+
"fixme",
|
|
14
|
+
"legacy",
|
|
15
|
+
"temp",
|
|
16
|
+
"temporary",
|
|
17
|
+
"hack",
|
|
18
|
+
"wip",
|
|
19
|
+
"n/a",
|
|
20
|
+
"na",
|
|
21
|
+
"none",
|
|
22
|
+
"-",
|
|
23
|
+
"?",
|
|
24
|
+
"reason",
|
|
25
|
+
"xxx",
|
|
26
|
+
]);
|
|
27
|
+
const PLACEHOLDER_REASON_PREFIXES = ["todo:", "fixme:", "tbd:"];
|
|
28
|
+
export function reasonProblem(reason) {
|
|
29
|
+
const lowered = reason.trim().toLowerCase();
|
|
30
|
+
if (lowered.length === 0)
|
|
31
|
+
return "the reason must be a non-empty explanation";
|
|
32
|
+
const stripped = lowered.replace(/[.!:;,]+$/, "").trim();
|
|
33
|
+
if (PLACEHOLDER_REASON_PREFIXES.some((prefix) => lowered.startsWith(prefix)) ||
|
|
34
|
+
PLACEHOLDER_REASONS.has(stripped)) {
|
|
35
|
+
return `the reason "${reason.trim()}" is a placeholder — state why this access is needed`;
|
|
36
|
+
}
|
|
37
|
+
return codemodPlaceholderReasonProblem(reason);
|
|
38
|
+
}
|
|
39
|
+
export function assertReason(callerLabel, reason) {
|
|
40
|
+
const problem = reasonProblem(reason);
|
|
41
|
+
if (problem !== undefined)
|
|
42
|
+
throw new Error(`${callerLabel}: ${problem}`);
|
|
43
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// empty reason, but options stays `Record<string, unknown>`, so orchestrators
|
|
5
5
|
// re-narrow at read time instead of trusting the shape.
|
|
6
6
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
|
+
import { reasonProblem } from "../escape-hatch-reason.js";
|
|
7
8
|
export function extensionUsageEscapeHatchReason(usage) {
|
|
8
9
|
const escapeHatch = usage.options?.["escapeHatch"];
|
|
9
10
|
if (!isPlainObject(escapeHatch))
|
|
@@ -11,5 +12,5 @@ export function extensionUsageEscapeHatchReason(usage) {
|
|
|
11
12
|
const { reason, grants } = escapeHatch;
|
|
12
13
|
if (Array.isArray(grants) && !grants.includes("unsafeRaw"))
|
|
13
14
|
return undefined;
|
|
14
|
-
return typeof reason === "string" && reason
|
|
15
|
+
return typeof reason === "string" && reasonProblem(reason) === undefined ? reason : undefined;
|
|
15
16
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
3
|
import { isValidIanaTimeZone } from "../time/iana.js";
|
|
4
|
+
import { reasonProblem } from "./escape-hatch-reason.js";
|
|
4
5
|
import { createEventDef } from "./event-def.js";
|
|
5
6
|
import { resolveName } from "./handler-helpers.js";
|
|
6
7
|
import { splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
|
|
@@ -79,10 +80,11 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
79
80
|
const { name: definedJobName, handler: h, ...rest } = jobNameOrDefinition;
|
|
80
81
|
return [definedJobName, rest, h];
|
|
81
82
|
})();
|
|
82
|
-
|
|
83
|
-
jobOptions.escapeHatch.reason
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const jobReasonProblem = jobOptions.escapeHatch !== undefined
|
|
84
|
+
? reasonProblem(jobOptions.escapeHatch.reason)
|
|
85
|
+
: undefined;
|
|
86
|
+
if (jobReasonProblem !== undefined) {
|
|
87
|
+
throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobReasonProblem}`);
|
|
86
88
|
}
|
|
87
89
|
const jobGrantsProblem = jobOptions.escapeHatch && escapeHatchGrantsProblem(jobOptions.escapeHatch);
|
|
88
90
|
if (jobGrantsProblem) {
|