@cosmicdrift/kumiko-framework 0.269.2 → 0.273.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/README.md +5 -1
- package/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
- package/src/__tests__/full-stack.integration.test.ts +2 -2
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/__tests__/transition-guard.integration.test.ts +7 -7
- package/src/api/__tests__/api.test.ts +2 -2
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +99 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +69 -18
- package/src/engine/__tests__/build-app-schema.test.ts +10 -6
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
- package/src/engine/__tests__/engine.test.ts +39 -25
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +47 -8
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/extends-registrar.test.ts +3 -3
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
- package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +6 -2
- package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
- package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
- package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
- package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/registry.test.ts +19 -13
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +176 -20
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/access-declarations.ts +3 -5
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/nav.ts +4 -3
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +12 -2
- package/src/engine/entity-handlers.ts +72 -20
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
- package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
- package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
- package/src/engine/feature-ast/extractors/handlers.ts +16 -2
- package/src/engine/feature-ast/extractors/hooks.ts +25 -4
- package/src/engine/feature-ast/patcher.ts +15 -3
- package/src/engine/feature-ast/patterns.ts +4 -0
- package/src/engine/feature-ast/render.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/shared-fields.ts +7 -3
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
- package/src/jobs/job-runner.ts +35 -5
- package/src/observability/__tests__/observability.integration.test.ts +7 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +172 -17
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +249 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -5
- package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +19 -5
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
} from "../db/eagerload";
|
|
9
9
|
import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-store-executor";
|
|
10
10
|
import { buildEntityTable, type EntityTable } from "../db/table-builder";
|
|
11
|
-
import {
|
|
11
|
+
import { acknowledgeConventionCrossTenant, type TenantDb } from "../db/tenant-db";
|
|
12
|
+
import { isSystemIdentity } from "../pipeline/system-identity-switch";
|
|
12
13
|
import { assertUnreachable } from "../utils";
|
|
13
14
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
|
|
14
15
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
AccessRule,
|
|
17
18
|
DeriveContext,
|
|
18
19
|
EntityDefinition,
|
|
20
|
+
EscapeHatchDeclaration,
|
|
19
21
|
HandlerContext,
|
|
20
22
|
QueryHandlerDef,
|
|
21
23
|
SessionUser,
|
|
@@ -107,6 +109,33 @@ type ListPayload = {
|
|
|
107
109
|
|
|
108
110
|
const idSchema = z.object({ id: z.uuid() });
|
|
109
111
|
|
|
112
|
+
// Keyed by handler function: the registrar rebuilds the def object but keeps the function reference.
|
|
113
|
+
const deprecatedCrossTenantHandlers = new WeakSet<object>();
|
|
114
|
+
|
|
115
|
+
export function isDeprecatedCrossTenantHandler(handler: object): boolean {
|
|
116
|
+
return deprecatedCrossTenantHandlers.has(handler);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
|
|
120
|
+
|
|
121
|
+
function resolveCrossTenantReason(name: string, options: EntityHandlerOptions): string | undefined {
|
|
122
|
+
if (options.escapeHatch !== undefined && options.crossTenant === true) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`"${name}": declare either escapeHatch or the deprecated crossTenant, not both.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (options.escapeHatch !== undefined) {
|
|
128
|
+
if (options.escapeHatch.reason.trim().length === 0) {
|
|
129
|
+
throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
|
|
130
|
+
}
|
|
131
|
+
return options.escapeHatch.reason;
|
|
132
|
+
}
|
|
133
|
+
if (options.crossTenant === true) {
|
|
134
|
+
return `deprecated crossTenant: true on entity convention handler ${name}`;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
110
139
|
// Upper bound on entity-list `limit`: unbounded/fractional values ran
|
|
111
140
|
// straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
|
|
112
141
|
// read.ts), letting a client demand a full-table materialisation or trip a
|
|
@@ -194,10 +223,10 @@ export function defineEntityWriteHandler(
|
|
|
194
223
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
195
224
|
// TenantDb for these handlers before the ctx.db cutover.
|
|
196
225
|
//
|
|
197
|
-
//
|
|
198
|
-
// write handler reads/writes across
|
|
199
|
-
// whole feature r.systemScope().
|
|
200
|
-
const
|
|
226
|
+
// crossTenantReason (escapeHatch or the deprecated crossTenant) mirrors the
|
|
227
|
+
// query-handler branch below: this ONE write handler reads/writes across
|
|
228
|
+
// every tenant without making the whole feature r.systemScope().
|
|
229
|
+
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
201
230
|
|
|
202
231
|
const dbFor = (ctx: HandlerContext): TenantDb => {
|
|
203
232
|
if (ctx.systemDb) {
|
|
@@ -205,7 +234,9 @@ export function defineEntityWriteHandler(
|
|
|
205
234
|
`entity convention handler for r.systemScope() feature (${name})`,
|
|
206
235
|
);
|
|
207
236
|
}
|
|
208
|
-
return
|
|
237
|
+
return crossTenantReason !== undefined
|
|
238
|
+
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
239
|
+
: ctx.db;
|
|
209
240
|
};
|
|
210
241
|
|
|
211
242
|
// The event stream is keyed by the acting user's tenantId (streamTenantFor in
|
|
@@ -219,7 +250,7 @@ export function defineEntityWriteHandler(
|
|
|
219
250
|
id: unknown,
|
|
220
251
|
db: TenantDb,
|
|
221
252
|
): Promise<SessionUser> => {
|
|
222
|
-
if (
|
|
253
|
+
if (crossTenantReason === undefined || entity.systemStream) return user;
|
|
223
254
|
const row = await db.fetchOne<Record<string, unknown>>(table, { id });
|
|
224
255
|
const rowTenantId = row?.["tenantId"];
|
|
225
256
|
if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId) return user;
|
|
@@ -234,7 +265,14 @@ export function defineEntityWriteHandler(
|
|
|
234
265
|
schema = buildInsertSchema(entity);
|
|
235
266
|
handler = async (event, ctx) => {
|
|
236
267
|
const { runPreSave } = ctx;
|
|
237
|
-
|
|
268
|
+
// A caller-chosen id (deterministic-id idempotent creates) is only
|
|
269
|
+
// honored for a system-identity caller (jobs/hooks via
|
|
270
|
+
// createSystemUser) — an HTTP-authenticated end user can never
|
|
271
|
+
// present that identity, so their payload.id is dropped exactly as
|
|
272
|
+
// before this field became part of the schema.
|
|
273
|
+
const { id: _callerId, ...payloadWithoutId } = event.payload as DbRow;
|
|
274
|
+
const payload = isSystemIdentity(event.user) ? (event.payload as DbRow) : payloadWithoutId;
|
|
275
|
+
return executor.create(payload, event.user, dbFor(ctx), {
|
|
238
276
|
preSave:
|
|
239
277
|
runPreSave &&
|
|
240
278
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -288,6 +326,9 @@ export function defineEntityWriteHandler(
|
|
|
288
326
|
assertUnreachable(verb, "write verb");
|
|
289
327
|
}
|
|
290
328
|
|
|
329
|
+
if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
|
|
330
|
+
|
|
331
|
+
// escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
|
|
291
332
|
return {
|
|
292
333
|
name,
|
|
293
334
|
schema,
|
|
@@ -340,21 +381,22 @@ export function defineEntityQueryHandler(
|
|
|
340
381
|
// useReferenceLookup stays as a fallback (for apps that write custom
|
|
341
382
|
// handlers by hand without this wrapper).
|
|
342
383
|
const hasRefFields = collectReferenceFields(entity).length > 0;
|
|
384
|
+
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
343
385
|
|
|
344
386
|
// Preference order:
|
|
345
387
|
// 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
|
|
346
388
|
// cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
|
|
347
389
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
348
390
|
// TenantDb for these handlers before that cutover.
|
|
349
|
-
// 2.
|
|
350
|
-
// (e.g. a SystemAdmin-only
|
|
351
|
-
// whole feature r.systemScope()
|
|
352
|
-
// from every OTHER handler the
|
|
353
|
-
// list()/detail() only add a
|
|
354
|
-
// (see event-store-executor.ts),
|
|
355
|
-
// TenantDb built from the same raw
|
|
356
|
-
// access-control (who may call this
|
|
357
|
-
// still gated by `options.access`.
|
|
391
|
+
// 2. crossTenantReason (escapeHatch or the deprecated crossTenant) — this
|
|
392
|
+
// ONE handler reads across every tenant (e.g. a SystemAdmin-only
|
|
393
|
+
// operator inspector) without making the whole feature r.systemScope()
|
|
394
|
+
// — that would drop tenant isolation from every OTHER handler the
|
|
395
|
+
// feature registers too. The executor's list()/detail() only add a
|
|
396
|
+
// tenant filter when db.mode === "tenant" (see event-store-executor.ts),
|
|
397
|
+
// so handing them a "system"-mode TenantDb built from the same raw
|
|
398
|
+
// connection is enough to skip it — access-control (who may call this
|
|
399
|
+
// handler at all) is unaffected, still gated by `options.access`.
|
|
358
400
|
// 3. plain ctx.db — the common case, tenant-filtered.
|
|
359
401
|
const dbFor = (ctx: HandlerContext): TenantDb => {
|
|
360
402
|
if (ctx.systemDb) {
|
|
@@ -362,7 +404,9 @@ export function defineEntityQueryHandler(
|
|
|
362
404
|
`entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
|
|
363
405
|
);
|
|
364
406
|
}
|
|
365
|
-
return
|
|
407
|
+
return crossTenantReason !== undefined
|
|
408
|
+
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
409
|
+
: ctx.db;
|
|
366
410
|
};
|
|
367
411
|
|
|
368
412
|
switch (verb) {
|
|
@@ -418,6 +462,9 @@ export function defineEntityQueryHandler(
|
|
|
418
462
|
assertUnreachable(verb, "query verb");
|
|
419
463
|
}
|
|
420
464
|
|
|
465
|
+
if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
|
|
466
|
+
|
|
467
|
+
// escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
|
|
421
468
|
return {
|
|
422
469
|
name,
|
|
423
470
|
schema,
|
|
@@ -534,7 +581,7 @@ export function createEntityExecutor(
|
|
|
534
581
|
//
|
|
535
582
|
// r.queryHandler(
|
|
536
583
|
// defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue", {
|
|
537
|
-
// access: { openToAll:
|
|
584
|
+
// access: { openToAll: { reason: "any signed-in user reviews aggregate revenue" } },
|
|
538
585
|
// }),
|
|
539
586
|
// );
|
|
540
587
|
//
|
|
@@ -543,7 +590,11 @@ export function createEntityExecutor(
|
|
|
543
590
|
export function defineProjectionQueryHandler(
|
|
544
591
|
name: string,
|
|
545
592
|
projectionQualifiedName: string,
|
|
546
|
-
options: {
|
|
593
|
+
options: {
|
|
594
|
+
access: AccessRule;
|
|
595
|
+
unsafeAllTenants?: boolean;
|
|
596
|
+
escapeHatch?: EscapeHatchDeclaration;
|
|
597
|
+
},
|
|
547
598
|
): QueryHandlerDef {
|
|
548
599
|
return {
|
|
549
600
|
name,
|
|
@@ -558,6 +609,7 @@ export function defineProjectionQueryHandler(
|
|
|
558
609
|
options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
|
|
559
610
|
), // @wrapper-known semantic-alias
|
|
560
611
|
access: options.access,
|
|
612
|
+
...(options.escapeHatch && { escapeHatch: options.escapeHatch }),
|
|
561
613
|
};
|
|
562
614
|
}
|
|
563
615
|
|
|
@@ -70,13 +70,14 @@ defineFeature("todoList", (r) => {
|
|
|
70
70
|
return { isSuccess: true, data: { id: "x" } };
|
|
71
71
|
},
|
|
72
72
|
access: { roles: ["user"] },
|
|
73
|
+
escapeHatch: { reason: "seeds default priority labels via db.global()" },
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
r.queryHandler({
|
|
76
77
|
name: "task:list",
|
|
77
78
|
schema: z.object({}),
|
|
78
79
|
handler: async (q, ctx) => [],
|
|
79
|
-
access: { openToAll:
|
|
80
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
r.streamHandler({
|
|
@@ -94,6 +95,7 @@ defineFeature("todoList", (r) => {
|
|
|
94
95
|
handler: async (event, ctx) => {
|
|
95
96
|
console.log("task saved");
|
|
96
97
|
},
|
|
98
|
+
escapeHatch: { reason: "reindexes the task search doc via SYSTEM identity" },
|
|
97
99
|
});
|
|
98
100
|
|
|
99
101
|
r.hook({
|
|
@@ -64,7 +64,7 @@ defineFeature("todoList", (r) => {
|
|
|
64
64
|
"task:list",
|
|
65
65
|
z.object({}),
|
|
66
66
|
async (q, ctx) => [],
|
|
67
|
-
{ access: { openToAll:
|
|
67
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
68
68
|
);
|
|
69
69
|
|
|
70
70
|
r.hook("postSave", "task", async (event, ctx) => {
|
|
@@ -168,7 +168,7 @@ describe("parseSourceFile against a complete inline-form feature", () => {
|
|
|
168
168
|
|
|
169
169
|
const queryList = result.patterns.find((p) => p.kind === "queryHandler");
|
|
170
170
|
expect(queryList).toMatchObject({
|
|
171
|
-
access: { openToAll:
|
|
171
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
@@ -1332,6 +1332,37 @@ defineFeature("f", (r) => {
|
|
|
1332
1332
|
expect(result.patterns[0]).toMatchObject({ kind: "hook", phase: "afterCommit" });
|
|
1333
1333
|
});
|
|
1334
1334
|
|
|
1335
|
+
test("captures the optional escapeHatch from the options object (positional form)", () => {
|
|
1336
|
+
const result = parseInline(`
|
|
1337
|
+
defineFeature("f", (r) => {
|
|
1338
|
+
r.hook("postSave", "task", (event, ctx) => {}, { escapeHatch: { reason: "reindexes via SYSTEM" } });
|
|
1339
|
+
});
|
|
1340
|
+
`);
|
|
1341
|
+
|
|
1342
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1343
|
+
kind: "hook",
|
|
1344
|
+
escapeHatch: { reason: "reindexes via SYSTEM" },
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
test("captures the optional escapeHatch from the object form", () => {
|
|
1349
|
+
const result = parseInline(`
|
|
1350
|
+
defineFeature("f", (r) => {
|
|
1351
|
+
r.hook({
|
|
1352
|
+
type: "postSave",
|
|
1353
|
+
target: "task",
|
|
1354
|
+
handler: (event, ctx) => {},
|
|
1355
|
+
escapeHatch: { reason: "reindexes via SYSTEM" },
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
`);
|
|
1359
|
+
|
|
1360
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1361
|
+
kind: "hook",
|
|
1362
|
+
escapeHatch: { reason: "reindexes via SYSTEM" },
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1335
1366
|
test("rejects an unknown hook type", () => {
|
|
1336
1367
|
const result = parseInline(`
|
|
1337
1368
|
defineFeature("f", (r) => {
|
|
@@ -1576,7 +1607,7 @@ defineFeature("f", (r) => {
|
|
|
1576
1607
|
name: "task:approve",
|
|
1577
1608
|
schema: z.object({ id: z.string() }),
|
|
1578
1609
|
handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
|
|
1579
|
-
access: { openToAll:
|
|
1610
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
1580
1611
|
});
|
|
1581
1612
|
});
|
|
1582
1613
|
`);
|
|
@@ -1584,7 +1615,7 @@ defineFeature("f", (r) => {
|
|
|
1584
1615
|
expect(result.patterns[0]).toMatchObject({
|
|
1585
1616
|
kind: "writeHandler",
|
|
1586
1617
|
handlerName: "task:approve",
|
|
1587
|
-
access: { openToAll:
|
|
1618
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
1588
1619
|
});
|
|
1589
1620
|
});
|
|
1590
1621
|
|
|
@@ -1678,6 +1709,42 @@ defineFeature("f", (r) => {
|
|
|
1678
1709
|
});
|
|
1679
1710
|
expect(result.errors).toEqual([]);
|
|
1680
1711
|
});
|
|
1712
|
+
|
|
1713
|
+
test("object form: escapeHatch is extracted", () => {
|
|
1714
|
+
const result = parseInline(`
|
|
1715
|
+
defineFeature("f", (r) => {
|
|
1716
|
+
r.writeHandler({
|
|
1717
|
+
name: "task:reindex",
|
|
1718
|
+
schema: z.object({}),
|
|
1719
|
+
handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
|
|
1720
|
+
access: { roles: ["Admin"] },
|
|
1721
|
+
escapeHatch: { reason: "bulk reindex writes via db.global()" },
|
|
1722
|
+
});
|
|
1723
|
+
});
|
|
1724
|
+
`);
|
|
1725
|
+
|
|
1726
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1727
|
+
kind: "writeHandler",
|
|
1728
|
+
handlerName: "task:reindex",
|
|
1729
|
+
escapeHatch: { reason: "bulk reindex writes via db.global()" },
|
|
1730
|
+
});
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test("4-argument form: escapeHatch is read from the options object", () => {
|
|
1734
|
+
const result = parseInline(`
|
|
1735
|
+
defineFeature("f", (r) => {
|
|
1736
|
+
r.writeHandler("task:reindex", z.object({}), async (event, ctx) => {
|
|
1737
|
+
return { isSuccess: true, data: {} };
|
|
1738
|
+
}, { access: { roles: ["Admin"] }, escapeHatch: { reason: "bulk reindex writes via db.global()" } });
|
|
1739
|
+
});
|
|
1740
|
+
`);
|
|
1741
|
+
|
|
1742
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1743
|
+
kind: "writeHandler",
|
|
1744
|
+
handlerName: "task:reindex",
|
|
1745
|
+
escapeHatch: { reason: "bulk reindex writes via db.global()" },
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1681
1748
|
});
|
|
1682
1749
|
|
|
1683
1750
|
describe("extractQueryHandler", () => {
|
|
@@ -1714,6 +1781,26 @@ defineFeature("f", (r) => {
|
|
|
1714
1781
|
agent: { risk: "low" },
|
|
1715
1782
|
});
|
|
1716
1783
|
});
|
|
1784
|
+
|
|
1785
|
+
test("object form: escapeHatch is extracted (query handlers may switch to SYSTEM via ctx.queryAs)", () => {
|
|
1786
|
+
const result = parseInline(`
|
|
1787
|
+
defineFeature("f", (r) => {
|
|
1788
|
+
r.queryHandler({
|
|
1789
|
+
name: "task:audit-list",
|
|
1790
|
+
schema: z.object({}),
|
|
1791
|
+
handler: async (q, ctx) => [],
|
|
1792
|
+
access: { roles: ["Admin"] },
|
|
1793
|
+
escapeHatch: { reason: "reads across tenants via ctx.queryAs(SYSTEM, ...)" },
|
|
1794
|
+
});
|
|
1795
|
+
});
|
|
1796
|
+
`);
|
|
1797
|
+
|
|
1798
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1799
|
+
kind: "queryHandler",
|
|
1800
|
+
handlerName: "task:audit-list",
|
|
1801
|
+
escapeHatch: { reason: "reads across tenants via ctx.queryAs(SYSTEM, ...)" },
|
|
1802
|
+
});
|
|
1803
|
+
});
|
|
1717
1804
|
});
|
|
1718
1805
|
|
|
1719
1806
|
describe("extractStreamHandler", () => {
|
|
@@ -1740,7 +1827,7 @@ defineFeature("f", (r) => {
|
|
|
1740
1827
|
name: "chat:complete",
|
|
1741
1828
|
schema: z.object({ prompt: z.string() }),
|
|
1742
1829
|
handler: async function* (input, ctx) { yield "token"; },
|
|
1743
|
-
access: { openToAll:
|
|
1830
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
1744
1831
|
});
|
|
1745
1832
|
});
|
|
1746
1833
|
`);
|
|
@@ -1748,7 +1835,7 @@ defineFeature("f", (r) => {
|
|
|
1748
1835
|
expect(result.patterns[0]).toMatchObject({
|
|
1749
1836
|
kind: "streamHandler",
|
|
1750
1837
|
handlerName: "chat:complete",
|
|
1751
|
-
access: { openToAll:
|
|
1838
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
1752
1839
|
});
|
|
1753
1840
|
});
|
|
1754
1841
|
|
|
@@ -1783,6 +1870,21 @@ defineFeature("f", (r) => {
|
|
|
1783
1870
|
expect(result.patterns[0]).not.toHaveProperty("description");
|
|
1784
1871
|
expect(result.patterns[0]).not.toHaveProperty("agent");
|
|
1785
1872
|
});
|
|
1873
|
+
|
|
1874
|
+
test("escapeHatch is NOT part of StreamHandlerPattern's runtime shape (StreamHandlerDef has no escapeHatch)", () => {
|
|
1875
|
+
const result = parseInline(`
|
|
1876
|
+
defineFeature("f", (r) => {
|
|
1877
|
+
r.streamHandler({
|
|
1878
|
+
name: "chat:complete",
|
|
1879
|
+
schema: z.object({ prompt: z.string() }),
|
|
1880
|
+
handler: async function* (input, ctx) { yield "token"; },
|
|
1881
|
+
escapeHatch: { reason: "Should be ignored for streamHandler." },
|
|
1882
|
+
});
|
|
1883
|
+
});
|
|
1884
|
+
`);
|
|
1885
|
+
|
|
1886
|
+
expect(result.patterns[0]).not.toHaveProperty("escapeHatch");
|
|
1887
|
+
});
|
|
1786
1888
|
});
|
|
1787
1889
|
|
|
1788
1890
|
describe("extractJob", () => {
|
|
@@ -131,14 +131,14 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
|
|
|
131
131
|
name: "task:list",
|
|
132
132
|
schemaSource: "z.object({})",
|
|
133
133
|
handlerSource: "async (q, ctx) => []",
|
|
134
|
-
access: { openToAll:
|
|
134
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
135
135
|
});
|
|
136
136
|
const result = parseSourceFile(sf);
|
|
137
137
|
const pattern = result.patterns[0];
|
|
138
138
|
expect(pattern).toMatchObject({
|
|
139
139
|
kind: "queryHandler",
|
|
140
140
|
handlerName: "task:list",
|
|
141
|
-
access: { openToAll:
|
|
141
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
144
|
|
|
@@ -441,7 +441,7 @@ defineFeature("tasks", (r) => {
|
|
|
441
441
|
name: "task:list",
|
|
442
442
|
schemaSource: "z.object({})",
|
|
443
443
|
handlerSource: "async (q, ctx) => []",
|
|
444
|
-
access: { openToAll:
|
|
444
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
445
445
|
});
|
|
446
446
|
p.addStreamHandler({
|
|
447
447
|
name: "task:stream",
|
|
@@ -20,9 +20,9 @@ describe("readOptionalAccessRule — openToAll.personalData", () => {
|
|
|
20
20
|
).toEqual({ openToAll: { reason: "signup" } });
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("the deprecated openToAll: true form
|
|
24
|
-
expect(
|
|
25
|
-
openToAll: true,
|
|
26
|
-
|
|
23
|
+
test("the deprecated openToAll: true form is not extracted — fail-closed, consistent with isOpenToAllGranted", () => {
|
|
24
|
+
expect(
|
|
25
|
+
readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" }),
|
|
26
|
+
).toBeUndefined();
|
|
27
27
|
});
|
|
28
28
|
});
|
|
@@ -209,7 +209,11 @@ defineFeature("workflow", (r) => {
|
|
|
209
209
|
"step:list",
|
|
210
210
|
z.object({}),
|
|
211
211
|
async (q, ctx) => [],
|
|
212
|
-
{
|
|
212
|
+
{
|
|
213
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
214
|
+
description: "Lists steps.",
|
|
215
|
+
agent: { risk: "low" },
|
|
216
|
+
},
|
|
213
217
|
);
|
|
214
218
|
|
|
215
219
|
r.streamHandler(
|
|
@@ -4,12 +4,13 @@ import type {
|
|
|
4
4
|
AccessRule,
|
|
5
5
|
AgentHandlerHints,
|
|
6
6
|
AgentRisk,
|
|
7
|
+
EscapeHatchDeclaration,
|
|
7
8
|
RateLimitOption,
|
|
8
9
|
} from "../../types/handlers";
|
|
9
10
|
import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
|
|
10
11
|
import type { SourceLocation } from "../source-location";
|
|
11
12
|
import { sourceLocationFromNode } from "../source-location";
|
|
12
|
-
import { readOptionalAccessRule, readOptionalRateLimit } from "./hooks";
|
|
13
|
+
import { readOptionalAccessRule, readOptionalEscapeHatch, readOptionalRateLimit } from "./hooks";
|
|
13
14
|
import {
|
|
14
15
|
type ExtractOutput,
|
|
15
16
|
fail,
|
|
@@ -32,6 +33,7 @@ export type ParsedHandlerCall = {
|
|
|
32
33
|
readonly agent?: AgentHandlerHints;
|
|
33
34
|
readonly rateLimit?: RateLimitOption;
|
|
34
35
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
36
|
+
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
const AGENT_RISK_VALUES: readonly AgentRisk[] = ["low", "mid", "high"];
|
|
@@ -85,18 +87,20 @@ function readDescriptionAndAgent(
|
|
|
85
87
|
*/
|
|
86
88
|
function readOptionsFields(
|
|
87
89
|
options: unknown,
|
|
88
|
-
): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent"> {
|
|
90
|
+
): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent" | "escapeHatch"> {
|
|
89
91
|
if (!isPlainObject(options)) return {};
|
|
90
92
|
const access = readOptionalAccessRule(options["access"]);
|
|
91
93
|
const rateLimit = readOptionalRateLimit(options["rateLimit"]);
|
|
92
94
|
const description =
|
|
93
95
|
typeof options["description"] === "string" ? options["description"] : undefined;
|
|
94
96
|
const agent = readOptionalAgentHints(options["agent"]);
|
|
97
|
+
const escapeHatch = readOptionalEscapeHatch(options["escapeHatch"]);
|
|
95
98
|
return {
|
|
96
99
|
...(access !== undefined && { access }),
|
|
97
100
|
...(description !== undefined && { description }),
|
|
98
101
|
...(agent !== undefined && { agent }),
|
|
99
102
|
...(rateLimit !== undefined && { rateLimit }),
|
|
103
|
+
...(escapeHatch !== undefined && { escapeHatch }),
|
|
100
104
|
};
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -188,6 +192,13 @@ export function parseHandlerCall(
|
|
|
188
192
|
: undefined;
|
|
189
193
|
const { description, agent } = readDescriptionAndAgent(obj);
|
|
190
194
|
const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
|
|
195
|
+
const escapeHatchInit = obj
|
|
196
|
+
.getProperty("escapeHatch")
|
|
197
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
198
|
+
?.getInitializer();
|
|
199
|
+
const escapeHatch = escapeHatchInit
|
|
200
|
+
? readOptionalEscapeHatch(readDataLiteralNode(escapeHatchInit))
|
|
201
|
+
: undefined;
|
|
191
202
|
return ok({
|
|
192
203
|
source: sourceLocationFromNode(call, sourceFile),
|
|
193
204
|
handlerName: nameLiteral.getLiteralValue(),
|
|
@@ -198,6 +209,7 @@ export function parseHandlerCall(
|
|
|
198
209
|
...(agent !== undefined && { agent }),
|
|
199
210
|
...(rateLimit !== undefined && { rateLimit }),
|
|
200
211
|
...(skip === true && { unsafeSkipTransitionGuard: true }),
|
|
212
|
+
...(escapeHatch !== undefined && { escapeHatch }),
|
|
201
213
|
});
|
|
202
214
|
}
|
|
203
215
|
|
|
@@ -271,6 +283,7 @@ export function extractWriteHandler(
|
|
|
271
283
|
...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
|
|
272
284
|
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
273
285
|
...(parsed.pattern.unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
|
|
286
|
+
...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
|
|
274
287
|
});
|
|
275
288
|
}
|
|
276
289
|
|
|
@@ -296,6 +309,7 @@ export function extractQueryHandler(
|
|
|
296
309
|
...readHandlerFields(parsed),
|
|
297
310
|
...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
|
|
298
311
|
...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
|
|
312
|
+
...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
|
|
299
313
|
});
|
|
300
314
|
}
|
|
301
315
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CallExpression, Node, SourceFile } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
3
|
import type { LifecycleHookType } from "../../constants";
|
|
4
|
-
import type { AccessRule, RateLimitOption } from "../../types/handlers";
|
|
4
|
+
import type { AccessRule, EscapeHatchDeclaration, RateLimitOption } from "../../types/handlers";
|
|
5
5
|
import type { HookPhase } from "../../types/hooks";
|
|
6
6
|
import type { AuthClaimsPattern, HookPattern } from "../patterns";
|
|
7
7
|
import { sourceLocationFromNode } from "../source-location";
|
|
@@ -37,14 +37,12 @@ export function readOptionalPhase(node: Node | undefined): HookPhase | undefined
|
|
|
37
37
|
return undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// `openToAll: true` (deprecated) is not recognised here — isOpenToAllGranted never grants it, so extracting it would round-trip a rule that looks configured but denies everyone.
|
|
40
41
|
export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
|
|
41
42
|
if (!isPlainObject(value)) return undefined;
|
|
42
43
|
if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
|
|
43
44
|
return { roles: value["roles"] as readonly string[] };
|
|
44
45
|
}
|
|
45
|
-
if (value["openToAll"] === true) {
|
|
46
|
-
return { openToAll: true };
|
|
47
|
-
}
|
|
48
46
|
const openToAll = value["openToAll"];
|
|
49
47
|
if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
|
|
50
48
|
const personalData =
|
|
@@ -64,6 +62,25 @@ export function readOptionalRateLimit(value: unknown): RateLimitOption | undefin
|
|
|
64
62
|
return value as unknown as RateLimitOption;
|
|
65
63
|
}
|
|
66
64
|
|
|
65
|
+
export function readOptionalEscapeHatch(value: unknown): EscapeHatchDeclaration | undefined {
|
|
66
|
+
if (!isPlainObject(value)) return undefined;
|
|
67
|
+
if (typeof value["reason"] !== "string") return undefined;
|
|
68
|
+
return { reason: value["reason"] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Extracts the `escapeHatch` sub-property node first, not via readDataLiteralNode on the whole object — a sibling property like `handler` (a closure) isn't representable as plain data, which would make the whole-object read return undefined.
|
|
72
|
+
export function readOptionalHookEscapeHatch(
|
|
73
|
+
node: Node | undefined,
|
|
74
|
+
): EscapeHatchDeclaration | undefined {
|
|
75
|
+
const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
76
|
+
if (!obj) return undefined;
|
|
77
|
+
const init = obj
|
|
78
|
+
.getProperty("escapeHatch")
|
|
79
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
80
|
+
?.getInitializer();
|
|
81
|
+
return init ? readOptionalEscapeHatch(readDataLiteralNode(init)) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
// r.hook's target: a NameOrRef, a list of them, or an entity-wide
|
|
68
85
|
// `{ allOf: entityRef }` (replaces the old r.entityHook(type, entity, fn)).
|
|
69
86
|
// Checked first so a malformed `{ allOf }` doesn't silently fall through
|
|
@@ -154,6 +171,7 @@ export function extractHook(
|
|
|
154
171
|
);
|
|
155
172
|
}
|
|
156
173
|
const phase = readOptionalPhase(obj);
|
|
174
|
+
const escapeHatch = readOptionalHookEscapeHatch(obj);
|
|
157
175
|
return ok({
|
|
158
176
|
kind: "hook",
|
|
159
177
|
source: sourceLocationFromNode(call, sourceFile),
|
|
@@ -161,6 +179,7 @@ export function extractHook(
|
|
|
161
179
|
target,
|
|
162
180
|
fnBody: sourceLocationFromNode(fn, sourceFile),
|
|
163
181
|
...(phase !== undefined && { phase }),
|
|
182
|
+
...(escapeHatch !== undefined && { escapeHatch }),
|
|
164
183
|
});
|
|
165
184
|
}
|
|
166
185
|
|
|
@@ -212,6 +231,7 @@ export function extractHook(
|
|
|
212
231
|
);
|
|
213
232
|
}
|
|
214
233
|
const phase = readOptionalPhase(args[3]);
|
|
234
|
+
const escapeHatch = readOptionalHookEscapeHatch(args[3]);
|
|
215
235
|
return ok({
|
|
216
236
|
kind: "hook",
|
|
217
237
|
source: sourceLocationFromNode(call, sourceFile),
|
|
@@ -219,6 +239,7 @@ export function extractHook(
|
|
|
219
239
|
target,
|
|
220
240
|
fnBody: sourceLocationFromNode(fn, sourceFile),
|
|
221
241
|
...(phase !== undefined && { phase }),
|
|
242
|
+
...(escapeHatch !== undefined && { escapeHatch }),
|
|
222
243
|
});
|
|
223
244
|
}
|
|
224
245
|
|