@cosmicdrift/kumiko-framework 0.269.1 → 0.270.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-db-raw/global-fetch.expected.ts +9 -0
- package/src/__tests__/fixtures/migrate-db-raw/global-fetch.input.ts +10 -0
- package/src/__tests__/fixtures/migrate-db-raw/global-select.expected.ts +6 -0
- package/src/__tests__/fixtures/migrate-db-raw/global-select.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-db-raw/import-pruning.expected.ts +12 -0
- package/src/__tests__/fixtures/migrate-db-raw/import-pruning.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-db-raw/manual-only.expected.ts +26 -0
- package/src/__tests__/fixtures/migrate-db-raw/manual-only.input.ts +26 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-const-shorthand.expected.ts +10 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-const-shorthand.input.ts +11 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-event.expected.ts +9 -0
- package/src/__tests__/fixtures/migrate-db-raw/own-tenant-event.input.ts +15 -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-db-raw-codemod.test.ts +132 -0
- package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
- package/src/__tests__/transition-guard.integration.test.ts +7 -7
- package/src/__tests__/upgrade-cli.test.ts +54 -0
- package/src/api/__tests__/api.test.ts +2 -2
- package/src/api/__tests__/body-limit.test.ts +1 -1
- 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/changes.json +10 -2
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +17 -17
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +18 -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 +18 -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-handlers.test.ts +19 -8
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
- 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__/registry.test.ts +19 -13
- package/src/engine/__tests__/screen.test.ts +26 -20
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
- package/src/engine/boot-validator/access-declarations.ts +3 -5
- package/src/engine/boot-validator/nav.ts +4 -3
- package/src/engine/build-config-feature-schema.ts +9 -2
- package/src/engine/entity-handlers.ts +1 -1
- 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/pattern-library/shared-fields.ts +7 -3
- 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/es-ops/__tests__/runner.integration.test.ts +2 -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__/jobs.integration.test.ts +1 -1
- 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__/lifecycle-pipeline.test.ts +10 -10
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/query-projection.integration.test.ts +3 -3
- package/src/scripts/codemod/migrate-db-raw.ts +397 -0
- package/src/scripts/codemod/migrate-open-to-all.ts +248 -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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { qualifyEntityName } from "../qualified-name";
|
|
6
6
|
import type { AccessRule, FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
7
|
+
import { isOpenToAllGranted } from "../types";
|
|
7
8
|
|
|
8
9
|
export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
|
|
9
10
|
const set = new Set<string>();
|
|
@@ -135,10 +136,10 @@ export function validateNavCycles(
|
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
// undefined
|
|
139
|
-
// — neither has a role-set an inversion check could compare against.
|
|
139
|
+
// undefined or granted openToAll means visible to everyone (no role-set to compare); malformed/denied openToAll returns `[]`, same as an explicit `roles: []`.
|
|
140
140
|
function navViewerRoles(access: AccessRule | undefined): readonly string[] | undefined {
|
|
141
|
-
if (access === undefined
|
|
141
|
+
if (access === undefined) return undefined;
|
|
142
|
+
if ("openToAll" in access) return isOpenToAllGranted(access) ? undefined : [];
|
|
142
143
|
return access.roles;
|
|
143
144
|
}
|
|
144
145
|
|
|
@@ -28,6 +28,7 @@ import type { ConfigKeyDefinition, TranslationKeys } from "./types/config";
|
|
|
28
28
|
import type { Registry, SecretKeyDefinition } from "./types/feature";
|
|
29
29
|
import type { FieldDefinition } from "./types/fields";
|
|
30
30
|
import type { AccessRule } from "./types/handlers";
|
|
31
|
+
import { isOpenToAllGranted } from "./types/handlers";
|
|
31
32
|
import type { NavDefinition, NavIconKey } from "./types/nav";
|
|
32
33
|
import type {
|
|
33
34
|
ConfigEditScreenDefinition,
|
|
@@ -497,13 +498,19 @@ function minMaskOrder(keys: readonly MaskedKey[]): number {
|
|
|
497
498
|
// opt-int, und zeigt user-scope (write `all`) jedem. `all` lässt sich in
|
|
498
499
|
// AccessRule nur als openToAll ausdrücken; der Write bleibt server-seitig
|
|
499
500
|
// per Key gegated.
|
|
501
|
+
const ALL_ROLE_OPEN_TO_ALL_REASON =
|
|
502
|
+
'config key declares the "all" role, so every signed-in user of the tenant may read/write it';
|
|
503
|
+
|
|
500
504
|
// Union der Navs-Access-Regeln: ein openToAll-Nav öffnet das ganze Gate, sonst
|
|
501
505
|
// die Vereinigung der Rollen. undefined-access-Navs tragen nichts bei.
|
|
502
506
|
function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRule {
|
|
503
507
|
const roles: string[] = [];
|
|
504
508
|
for (const rule of rules) {
|
|
505
509
|
if (rule === undefined) continue;
|
|
506
|
-
if ("openToAll" in rule)
|
|
510
|
+
if ("openToAll" in rule) {
|
|
511
|
+
if (isOpenToAllGranted(rule)) return rule;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
507
514
|
roles.push(...rule.roles);
|
|
508
515
|
}
|
|
509
516
|
return rolesToAccess(roles);
|
|
@@ -512,6 +519,6 @@ function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRul
|
|
|
512
519
|
// `all` lässt sich in AccessRule nur als openToAll ausdrücken; der Write bleibt
|
|
513
520
|
// server-seitig per Key gegated.
|
|
514
521
|
function rolesToAccess(roles: readonly string[]): AccessRule {
|
|
515
|
-
if (roles.includes("all")) return { openToAll:
|
|
522
|
+
if (roles.includes("all")) return { openToAll: { reason: ALL_ROLE_OPEN_TO_ALL_REASON } };
|
|
516
523
|
return { roles: [...new Set(roles)] };
|
|
517
524
|
}
|
|
@@ -534,7 +534,7 @@ export function createEntityExecutor(
|
|
|
534
534
|
//
|
|
535
535
|
// r.queryHandler(
|
|
536
536
|
// defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue", {
|
|
537
|
-
// access: { openToAll:
|
|
537
|
+
// access: { openToAll: { reason: "any signed-in user reviews aggregate revenue" } },
|
|
538
538
|
// }),
|
|
539
539
|
// );
|
|
540
540
|
//
|
|
@@ -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
|
|
|
@@ -31,7 +31,12 @@ import type {
|
|
|
31
31
|
} from "../types/config";
|
|
32
32
|
import type { MetricOptions, SecretOptions } from "../types/feature";
|
|
33
33
|
import type { EntityDefinition } from "../types/fields";
|
|
34
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
AccessRule,
|
|
36
|
+
ClaimKeyType,
|
|
37
|
+
EscapeHatchDeclaration,
|
|
38
|
+
RateLimitOption,
|
|
39
|
+
} from "../types/handlers";
|
|
35
40
|
import type { HookPhase } from "../types/hooks";
|
|
36
41
|
import type { HttpRouteMethod } from "../types/http-route";
|
|
37
42
|
import type { NavDefinition } from "../types/nav";
|
|
@@ -94,6 +99,7 @@ export type AddWriteHandlerArgs = {
|
|
|
94
99
|
readonly access?: AccessRule;
|
|
95
100
|
readonly rateLimit?: RateLimitOption;
|
|
96
101
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
102
|
+
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
97
103
|
};
|
|
98
104
|
|
|
99
105
|
export type AddQueryHandlerArgs = {
|
|
@@ -102,6 +108,7 @@ export type AddQueryHandlerArgs = {
|
|
|
102
108
|
readonly handlerSource: string;
|
|
103
109
|
readonly access?: AccessRule;
|
|
104
110
|
readonly rateLimit?: RateLimitOption;
|
|
111
|
+
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
105
112
|
};
|
|
106
113
|
|
|
107
114
|
export type AddStreamHandlerArgs = {
|
|
@@ -121,6 +128,7 @@ export type AddHookArgs = {
|
|
|
121
128
|
/** Source text of the closure, e.g. `"async (event, ctx) => { ... }"`. */
|
|
122
129
|
readonly handlerSource: string;
|
|
123
130
|
readonly phase?: HookPhase;
|
|
131
|
+
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
124
132
|
};
|
|
125
133
|
|
|
126
134
|
export type AddJobArgs = {
|
|
@@ -380,6 +388,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
380
388
|
access,
|
|
381
389
|
rateLimit,
|
|
382
390
|
unsafeSkipTransitionGuard,
|
|
391
|
+
escapeHatch,
|
|
383
392
|
}) {
|
|
384
393
|
add({
|
|
385
394
|
kind: "writeHandler",
|
|
@@ -390,10 +399,11 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
390
399
|
...(access !== undefined && { access }),
|
|
391
400
|
...(rateLimit !== undefined && { rateLimit }),
|
|
392
401
|
...(unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
|
|
402
|
+
...(escapeHatch !== undefined && { escapeHatch }),
|
|
393
403
|
});
|
|
394
404
|
},
|
|
395
405
|
|
|
396
|
-
addQueryHandler({ name, schemaSource, handlerSource, access, rateLimit }) {
|
|
406
|
+
addQueryHandler({ name, schemaSource, handlerSource, access, rateLimit, escapeHatch }) {
|
|
397
407
|
add({
|
|
398
408
|
kind: "queryHandler",
|
|
399
409
|
source: SYNTHETIC_LOC,
|
|
@@ -402,6 +412,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
402
412
|
handlerBody: rawLoc(handlerSource),
|
|
403
413
|
...(access !== undefined && { access }),
|
|
404
414
|
...(rateLimit !== undefined && { rateLimit }),
|
|
415
|
+
...(escapeHatch !== undefined && { escapeHatch }),
|
|
405
416
|
});
|
|
406
417
|
},
|
|
407
418
|
|
|
@@ -417,7 +428,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
417
428
|
});
|
|
418
429
|
},
|
|
419
430
|
|
|
420
|
-
addHook({ type, target, handlerSource, phase }) {
|
|
431
|
+
addHook({ type, target, handlerSource, phase, escapeHatch }) {
|
|
421
432
|
add({
|
|
422
433
|
kind: "hook",
|
|
423
434
|
source: SYNTHETIC_LOC,
|
|
@@ -425,6 +436,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
425
436
|
target,
|
|
426
437
|
fnBody: rawLoc(handlerSource),
|
|
427
438
|
...(phase !== undefined && { phase }),
|
|
439
|
+
...(escapeHatch !== undefined && { escapeHatch }),
|
|
428
440
|
});
|
|
429
441
|
},
|
|
430
442
|
|
|
@@ -70,6 +70,7 @@ import type {
|
|
|
70
70
|
AccessRule,
|
|
71
71
|
AgentHandlerHints,
|
|
72
72
|
ClaimKeyType,
|
|
73
|
+
EscapeHatchDeclaration,
|
|
73
74
|
RateLimitOption,
|
|
74
75
|
} from "../types/handlers";
|
|
75
76
|
import type { HookPhase } from "../types/hooks";
|
|
@@ -388,6 +389,7 @@ export type WriteHandlerPattern = {
|
|
|
388
389
|
readonly agent?: AgentHandlerHints;
|
|
389
390
|
readonly rateLimit?: RateLimitOption;
|
|
390
391
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
392
|
+
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
391
393
|
};
|
|
392
394
|
|
|
393
395
|
// `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
|
|
@@ -410,6 +412,7 @@ export type QueryHandlerPattern = {
|
|
|
410
412
|
readonly description?: string;
|
|
411
413
|
readonly agent?: AgentHandlerHints;
|
|
412
414
|
readonly rateLimit?: RateLimitOption;
|
|
415
|
+
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
413
416
|
};
|
|
414
417
|
|
|
415
418
|
// `r.streamHandler(...)` — registers a streaming read handler: name, Zod
|
|
@@ -444,6 +447,7 @@ export type HookPattern = {
|
|
|
444
447
|
readonly target: string | readonly string[] | { readonly allOf: string };
|
|
445
448
|
readonly fnBody: SourceLocation;
|
|
446
449
|
readonly phase?: HookPhase;
|
|
450
|
+
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
447
451
|
};
|
|
448
452
|
|
|
449
453
|
// `r.job(name, options, handler)` — registers a background job, qualified
|
|
@@ -408,6 +408,7 @@ function renderWriteHandler(p: WriteHandlerPattern): string {
|
|
|
408
408
|
if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
|
|
409
409
|
if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
|
|
410
410
|
if (p.unsafeSkipTransitionGuard === true) lines.push(" unsafeSkipTransitionGuard: true,");
|
|
411
|
+
if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
|
|
411
412
|
lines.push("});");
|
|
412
413
|
return lines.join("\n");
|
|
413
414
|
}
|
|
@@ -422,6 +423,7 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
|
|
|
422
423
|
if (p.description !== undefined) lines.push(` description: ${JSON.stringify(p.description)},`);
|
|
423
424
|
if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
|
|
424
425
|
if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
|
|
426
|
+
if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
|
|
425
427
|
lines.push("});");
|
|
426
428
|
return lines.join("\n");
|
|
427
429
|
}
|
|
@@ -450,6 +452,7 @@ function renderHook(p: HookPattern): string {
|
|
|
450
452
|
lines.push(` target: ${renderHookTarget(p.target)},`);
|
|
451
453
|
lines.push(` handler: ${reindentBody(p.fnBody.raw, PATTERN_INDENT)},`);
|
|
452
454
|
if (p.phase !== undefined) lines.push(` phase: ${JSON.stringify(p.phase)},`);
|
|
455
|
+
if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
|
|
453
456
|
lines.push("});");
|
|
454
457
|
return lines.join("\n");
|
|
455
458
|
}
|
|
@@ -61,9 +61,13 @@ export const accessRuleField: FormFieldSpec = {
|
|
|
61
61
|
label: { en: "Open to all (auth still required)", de: "Für alle (Auth nötig)" },
|
|
62
62
|
fields: [
|
|
63
63
|
{
|
|
64
|
-
path: "access.openToAll",
|
|
65
|
-
label: { en: "
|
|
66
|
-
|
|
64
|
+
path: "access.openToAll.reason",
|
|
65
|
+
label: { en: "Reason", de: "Begründung" },
|
|
66
|
+
hint: {
|
|
67
|
+
en: "Who may call this, and why that is safe without a role — required, `true` alone no longer grants access.",
|
|
68
|
+
},
|
|
69
|
+
input: "text",
|
|
70
|
+
required: true,
|
|
67
71
|
},
|
|
68
72
|
],
|
|
69
73
|
},
|
|
@@ -41,7 +41,7 @@ const writeProbeFeature = defineFeature("writeProbe", (r) => {
|
|
|
41
41
|
});
|
|
42
42
|
return { isSuccess: true as const, data: { note: event.payload.note } };
|
|
43
43
|
},
|
|
44
|
-
{ access: { openToAll:
|
|
44
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
45
45
|
);
|
|
46
46
|
r.job("write-via-job", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
47
47
|
try {
|