@cosmicdrift/kumiko-framework 0.299.0 → 0.305.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/package.json +4 -4
- package/src/api/__tests__/api.test.ts +3 -2
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
- package/src/api/__tests__/server-error-logging.test.ts +33 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/auth-routes.ts +53 -24
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +79 -34
- package/src/changes.json +68 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/tenant-db.ts +50 -3
- package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
- package/src/engine/__tests__/membership-roles.test.ts +13 -4
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
- package/src/engine/boot-validator/access-declarations.ts +58 -67
- package/src/engine/boot-validator/entity-handler.ts +20 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
- package/src/engine/feature-ast/extractors/hooks.ts +3 -1
- package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/patterns.ts +1 -1
- package/src/engine/feature-ast/render.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +6 -0
- package/src/engine/index.ts +4 -0
- package/src/engine/membership-roles.ts +20 -4
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/pattern-library/mixed-schemas.ts +1 -0
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/index.ts +4 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +35 -0
- package/src/jobs/job-runner.ts +19 -5
- package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
- package/src/observability/index.ts +5 -0
- package/src/observability/metrics-wiring.ts +32 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +3 -0
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- package/src/testing/handler-context.ts +3 -1
- package/src/ui-types/index.ts +2 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../define-feature";
|
|
4
|
+
import { validateHandlerAccess } from "../entity-handler";
|
|
5
|
+
|
|
6
|
+
describe('validateHandlerAccess — roles: ["all"] is rejected', () => {
|
|
7
|
+
test("a write handler with roles: ['all'] throws, naming the handler", () => {
|
|
8
|
+
const feature = defineFeature("no-all-write", (r) => {
|
|
9
|
+
r.writeHandler(
|
|
10
|
+
"note:create",
|
|
11
|
+
z.object({ title: z.string() }),
|
|
12
|
+
async () => ({
|
|
13
|
+
isSuccess: true as const,
|
|
14
|
+
data: {},
|
|
15
|
+
}),
|
|
16
|
+
{
|
|
17
|
+
access: { roles: ["all"] },
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/no-all-write:write:note:create/);
|
|
23
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/no session ever carries the role "all"/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a query handler with roles: ['all'] throws, naming the handler", () => {
|
|
27
|
+
const feature = defineFeature("no-all-query", (r) => {
|
|
28
|
+
r.queryHandler(
|
|
29
|
+
"note:list",
|
|
30
|
+
z.object({}),
|
|
31
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
32
|
+
{
|
|
33
|
+
access: { roles: ["all"] },
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/no-all-query:query:note:list/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("openToAll access is unaffected", () => {
|
|
42
|
+
const feature = defineFeature("open-to-all-ok", (r) => {
|
|
43
|
+
r.writeHandler(
|
|
44
|
+
"note:create",
|
|
45
|
+
z.object({ title: z.string() }),
|
|
46
|
+
async () => ({
|
|
47
|
+
isSuccess: true as const,
|
|
48
|
+
data: {},
|
|
49
|
+
}),
|
|
50
|
+
{
|
|
51
|
+
access: { openToAll: { reason: "any signed-in user" } },
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(() => validateHandlerAccess(feature)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("roles: ['anonymous'] with a rateLimit boots fine", () => {
|
|
60
|
+
const feature = defineFeature("anon-ok", (r) => {
|
|
61
|
+
r.writeHandler(
|
|
62
|
+
"note:create",
|
|
63
|
+
z.object({ title: z.string() }),
|
|
64
|
+
async () => ({
|
|
65
|
+
isSuccess: true as const,
|
|
66
|
+
data: {},
|
|
67
|
+
}),
|
|
68
|
+
{
|
|
69
|
+
access: { roles: ["anonymous"] },
|
|
70
|
+
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(() => validateHandlerAccess(feature)).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -1,67 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessAllowsAnonymous,
|
|
3
|
+
declaredPersonalData,
|
|
4
|
+
personalFieldNames,
|
|
5
|
+
} from "../personal-data-fields";
|
|
6
|
+
import { ANONYMOUS_ROLE } from "../system-user";
|
|
1
7
|
import type {
|
|
2
8
|
AccessRule,
|
|
3
9
|
FeatureDefinition,
|
|
4
|
-
OwnershipMap,
|
|
5
|
-
OwnershipRule,
|
|
6
10
|
QueryHandlerDef,
|
|
7
11
|
StreamHandlerDef,
|
|
8
12
|
WriteHandlerDef,
|
|
9
13
|
} from "../types";
|
|
10
|
-
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
11
14
|
import { collectZodObjectKeys } from "./zod-shape";
|
|
12
15
|
|
|
13
16
|
type HandlerKind = "write" | "query" | "stream";
|
|
14
17
|
|
|
15
|
-
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
16
|
-
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
17
|
-
// sense the openToAll personal-data check is guarding against.
|
|
18
|
-
function isPersonalDataField(field: unknown): boolean {
|
|
19
|
-
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
20
|
-
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
|
|
24
|
-
if (rule === "all" || rule.kind !== "from") return false;
|
|
25
|
-
return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// The executor checks access.write against every created/updated row; one "all" role
|
|
29
|
-
// or an empty map (= public) lets a caller write rows owned by someone else.
|
|
30
|
-
function writeMapBindsRowsToCaller(
|
|
31
|
-
writeMap: OwnershipMap | undefined,
|
|
32
|
-
ownerColumn: string,
|
|
33
|
-
): boolean {
|
|
34
|
-
const rules = Object.values(writeMap ?? {});
|
|
35
|
-
return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const ROW_ID_COLUMN = "id";
|
|
39
|
-
|
|
40
|
-
// A self/record-owned field's subject is the row itself, so only from("user:id", "id")
|
|
41
|
-
// makes that row the caller — on any other entity "self" names a third party.
|
|
42
|
-
function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
|
|
43
|
-
if (annot.userOwned) return annot.userOwned.ownerField;
|
|
44
|
-
if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
|
|
45
|
-
return undefined;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
|
|
49
|
-
const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
|
|
50
|
-
return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function personalFieldNames(
|
|
54
|
-
entity: EntityDefinition,
|
|
55
|
-
honorOwnerBinding: boolean,
|
|
56
|
-
): ReadonlySet<string> {
|
|
57
|
-
const names = new Set<string>();
|
|
58
|
-
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
59
|
-
const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
|
|
60
|
-
if (isPersonalDataField(field) && !exempt) names.add(fieldName);
|
|
61
|
-
}
|
|
62
|
-
return names;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
18
|
// escapeHatch and r.systemScope() can write around the entity's write map
|
|
66
19
|
// (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
|
|
67
20
|
function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
|
|
@@ -73,12 +26,14 @@ function candidatePersonalFieldNames(
|
|
|
73
26
|
feature: FeatureDefinition,
|
|
74
27
|
handlerName: string,
|
|
75
28
|
handler: WriteHandlerDef,
|
|
29
|
+
honorOwnerBindingOverride?: boolean,
|
|
76
30
|
): ReadonlySet<string> {
|
|
77
31
|
const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
|
|
78
32
|
const entities = feature.entities ?? {};
|
|
79
33
|
if (mappedEntityName) {
|
|
80
34
|
const entity = entities[mappedEntityName];
|
|
81
|
-
const honorOwnerBinding =
|
|
35
|
+
const honorOwnerBinding =
|
|
36
|
+
honorOwnerBindingOverride ?? !canWriteAroundExecutor(feature, handler);
|
|
82
37
|
return entity ? personalFieldNames(entity, honorOwnerBinding) : new Set();
|
|
83
38
|
}
|
|
84
39
|
const names = new Set<string>();
|
|
@@ -101,14 +56,6 @@ function hasOpenToAll(access: AccessRule): boolean {
|
|
|
101
56
|
return "openToAll" in access;
|
|
102
57
|
}
|
|
103
58
|
|
|
104
|
-
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
105
|
-
function declaredPersonalData(access: AccessRule): unknown {
|
|
106
|
-
if (!("openToAll" in access)) return undefined;
|
|
107
|
-
const openToAll: unknown = access.openToAll;
|
|
108
|
-
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
109
|
-
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
59
|
function declaresTenantMembersPersonalData(access: AccessRule): boolean {
|
|
113
60
|
return declaredPersonalData(access) === "tenant-members";
|
|
114
61
|
}
|
|
@@ -154,9 +101,10 @@ function validatePersonalDataOnlyOnWrite(
|
|
|
154
101
|
): void {
|
|
155
102
|
// skip: write handlers may declare personalData, or it wasn't declared here
|
|
156
103
|
if (kind === "write" || declaredPersonalData(access) === undefined) return;
|
|
104
|
+
const property = "openToAll" in access ? "openToAll.personalData" : "access.personalData";
|
|
157
105
|
throw new Error(
|
|
158
106
|
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
159
|
-
|
|
107
|
+
`${property} — it only applies to write handlers, whose input is ` +
|
|
160
108
|
"checked for personal-data fields.",
|
|
161
109
|
);
|
|
162
110
|
}
|
|
@@ -167,11 +115,29 @@ function validatePersonalDataValue(
|
|
|
167
115
|
access: AccessRule,
|
|
168
116
|
): void {
|
|
169
117
|
const declared = declaredPersonalData(access);
|
|
170
|
-
// skip: nothing declared
|
|
171
|
-
if (declared === undefined
|
|
118
|
+
// skip: nothing declared
|
|
119
|
+
if (declared === undefined) return;
|
|
120
|
+
if ("openToAll" in access) {
|
|
121
|
+
// skip: the one supported value on openToAll
|
|
122
|
+
if (declared === "tenant-members") return;
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
|
|
125
|
+
`openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (declared !== "public-intake") {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
|
|
131
|
+
`access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// skip: roles include "anonymous", which personalData: "public-intake" requires
|
|
135
|
+
if (Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE)) return;
|
|
172
136
|
throw new Error(
|
|
173
|
-
`[Feature ${feature.name}] write handler "${handlerName}" declares
|
|
174
|
-
|
|
137
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
|
|
138
|
+
'access.personalData: "public-intake" but its roles do not include ' +
|
|
139
|
+
`"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
|
|
140
|
+
"that allow anonymous callers.",
|
|
175
141
|
);
|
|
176
142
|
}
|
|
177
143
|
|
|
@@ -200,12 +166,37 @@ function validateOpenToAllPersonalData(
|
|
|
200
166
|
);
|
|
201
167
|
}
|
|
202
168
|
|
|
169
|
+
// Anonymous callers all share one user.id, so honorOwnerBindingOverride is always false here.
|
|
170
|
+
function validateAnonymousPersonalData(
|
|
171
|
+
feature: FeatureDefinition,
|
|
172
|
+
handlerName: string,
|
|
173
|
+
handler: WriteHandlerDef,
|
|
174
|
+
): void {
|
|
175
|
+
const access = handler.access;
|
|
176
|
+
// skip: not reachable by an anonymous caller, or already declares public-intake
|
|
177
|
+
if (!accessAllowsAnonymous(access) || declaredPersonalData(access) === "public-intake") return;
|
|
178
|
+
const inputKeys = collectZodObjectKeys(handler.schema);
|
|
179
|
+
const personalNames = candidatePersonalFieldNames(feature, handlerName, handler, false);
|
|
180
|
+
const offending = [...inputKeys].filter((key) => personalNames.has(key));
|
|
181
|
+
// skip: no personal-data fields in the handler's input
|
|
182
|
+
if (offending.length === 0) return;
|
|
183
|
+
throw new Error(
|
|
184
|
+
`[Feature ${feature.name}] write handler "${handlerName}" allows anonymous callers ` +
|
|
185
|
+
`("${ANONYMOUS_ROLE}" in access.roles) and accepts personal-data field(s) ` +
|
|
186
|
+
`${offending.map((f) => `"${f}"`).join(", ")}. Declare ` +
|
|
187
|
+
'access: { roles: [..., "anonymous"], personalData: "public-intake" } — every anonymous ' +
|
|
188
|
+
'request shares one caller identity, so owner-binding via from("user:id", ...) does not ' +
|
|
189
|
+
"vouch for it; protection comes from the handler's required rateLimit (per ip).",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
203
193
|
export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
204
194
|
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
205
195
|
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
206
196
|
validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
|
|
207
197
|
validatePersonalDataValue(feature, handlerName, handler.access);
|
|
208
198
|
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
199
|
+
validateAnonymousPersonalData(feature, handlerName, handler);
|
|
209
200
|
}
|
|
210
201
|
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
211
202
|
validateOpenToAllReason(feature, "query", handlerName, handler.access);
|
|
@@ -148,6 +148,7 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
|
148
148
|
`Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`,
|
|
149
149
|
);
|
|
150
150
|
}
|
|
151
|
+
validateNoAllRoleInHandlerAccess(feature.name, kind, name, handler.access);
|
|
151
152
|
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
152
153
|
validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
|
|
153
154
|
}
|
|
@@ -171,6 +172,25 @@ export function validateRateLimitDisabledReason(
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
// No session ever carries the role "all" — `roles: ["all"]` is unreachable dead config, not a wildcard.
|
|
176
|
+
export function validateNoAllRoleInHandlerAccess(
|
|
177
|
+
featureName: string,
|
|
178
|
+
kind: "write" | "query" | "stream",
|
|
179
|
+
handlerName: string,
|
|
180
|
+
access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
|
|
181
|
+
): void {
|
|
182
|
+
// skip: openToAll has no roles list to check
|
|
183
|
+
if (!("roles" in access)) return;
|
|
184
|
+
// skip: no "all" in the roles list, nothing unreachable to report
|
|
185
|
+
if (!access.roles.includes("all")) return;
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${kind} handler "${featureName}:${kind}:${handlerName}" declares access: { roles: ["all"] } — ` +
|
|
188
|
+
`no session ever carries the role "all", so this handler is unreachable by any caller. ` +
|
|
189
|
+
`Use access: { roles: ["anonymous"] } plus rateLimit: { per: "ip", ... } (or "ip+handler") ` +
|
|
190
|
+
`for unauthenticated callers, or access: { openToAll: { reason: "..." } } for any signed-in user.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
174
194
|
export function validateAnonymousRateLimit(
|
|
175
195
|
featureName: string,
|
|
176
196
|
kind: "write" | "query" | "stream",
|
|
@@ -239,6 +239,7 @@ describe("patch coverage for the remaining pattern-kinds", () => {
|
|
|
239
239
|
method: "GET",
|
|
240
240
|
path: "/health",
|
|
241
241
|
handlerSource: "async (c) => c.json({ ok: true })",
|
|
242
|
+
anonymous: true,
|
|
242
243
|
});
|
|
243
244
|
expect(parseSourceFile(sf).patterns).toHaveLength(3);
|
|
244
245
|
removePattern(sf, { kind: "httpRoute", method: "GET", path: "/health" });
|
|
@@ -328,6 +328,7 @@ describe("FeaturePatcher — coverage for the remaining typed adds", () => {
|
|
|
328
328
|
method: "GET",
|
|
329
329
|
path: "/health",
|
|
330
330
|
handlerSource: "async (c) => c.json({ ok: true })",
|
|
331
|
+
anonymous: true,
|
|
331
332
|
});
|
|
332
333
|
const result = parseSourceFile(sf);
|
|
333
334
|
expect(result.errors).toEqual([]);
|
|
@@ -26,3 +26,17 @@ describe("readOptionalAccessRule — openToAll.personalData", () => {
|
|
|
26
26
|
).toBeUndefined();
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
describe("readOptionalAccessRule — roles.personalData", () => {
|
|
31
|
+
test("keeps personalData: public-intake on the roles form", () => {
|
|
32
|
+
expect(readOptionalAccessRule({ roles: ["anonymous"], personalData: "public-intake" })).toEqual(
|
|
33
|
+
{ roles: ["anonymous"], personalData: "public-intake" },
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("drops an unknown personalData value on the roles form", () => {
|
|
38
|
+
expect(
|
|
39
|
+
readOptionalAccessRule({ roles: ["anonymous"], personalData: "tenant-members" }),
|
|
40
|
+
).toEqual({ roles: ["anonymous"] });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -41,7 +41,9 @@ export function readOptionalPhase(node: Node | undefined): HookPhase | undefined
|
|
|
41
41
|
export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
|
|
42
42
|
if (!isPlainObject(value)) return undefined;
|
|
43
43
|
if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
|
|
44
|
-
|
|
44
|
+
const personalData =
|
|
45
|
+
value["personalData"] === "public-intake" ? { personalData: "public-intake" as const } : {};
|
|
46
|
+
return { roles: value["roles"] as readonly string[], ...personalData };
|
|
45
47
|
}
|
|
46
48
|
const openToAll = value["openToAll"];
|
|
47
49
|
if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
|
|
@@ -210,13 +210,16 @@ export function extractHttpRoute(
|
|
|
210
210
|
"handler must be an inline arrow function or function expression",
|
|
211
211
|
);
|
|
212
212
|
}
|
|
213
|
-
|
|
213
|
+
// Missing/non-boolean `anonymous` reads as false (the safe side — mounted
|
|
214
|
+
// behind the session auth chain, not public) so a source file that predates
|
|
215
|
+
// the required field still parses instead of erroring here.
|
|
216
|
+
const anonymous = readBooleanProperty(arg, "anonymous") === true;
|
|
214
217
|
return ok({
|
|
215
218
|
kind: "httpRoute",
|
|
216
219
|
source: sourceLocationFromNode(call, sourceFile),
|
|
217
220
|
method: methodValue,
|
|
218
221
|
path: pathLiteral.getLiteralValue(),
|
|
219
222
|
handlerBody: sourceLocationFromNode(fn, sourceFile),
|
|
220
|
-
|
|
223
|
+
anonymous,
|
|
221
224
|
});
|
|
222
225
|
}
|
|
@@ -149,7 +149,7 @@ export type AddHttpRouteArgs = {
|
|
|
149
149
|
readonly method: HttpRouteMethod;
|
|
150
150
|
readonly path: string;
|
|
151
151
|
readonly handlerSource: string;
|
|
152
|
-
readonly anonymous
|
|
152
|
+
readonly anonymous: boolean;
|
|
153
153
|
};
|
|
154
154
|
|
|
155
155
|
export type AddDefineEventArgs = {
|
|
@@ -480,7 +480,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
480
480
|
method,
|
|
481
481
|
path,
|
|
482
482
|
handlerBody: rawLoc(handlerSource),
|
|
483
|
-
|
|
483
|
+
anonymous,
|
|
484
484
|
});
|
|
485
485
|
},
|
|
486
486
|
|
|
@@ -508,7 +508,7 @@ export type HttpRoutePattern = {
|
|
|
508
508
|
readonly source: SourceLocation;
|
|
509
509
|
readonly method: HttpRouteMethod;
|
|
510
510
|
readonly path: string;
|
|
511
|
-
readonly anonymous
|
|
511
|
+
readonly anonymous: boolean;
|
|
512
512
|
readonly handlerBody: SourceLocation;
|
|
513
513
|
};
|
|
514
514
|
|
|
@@ -558,7 +558,7 @@ function renderHttpRoute(p: HttpRoutePattern): string {
|
|
|
558
558
|
const lines: string[] = ["r.httpRoute({"];
|
|
559
559
|
lines.push(` method: ${JSON.stringify(p.method)},`);
|
|
560
560
|
lines.push(` path: ${JSON.stringify(p.path)},`);
|
|
561
|
-
|
|
561
|
+
lines.push(` anonymous: ${p.anonymous},`);
|
|
562
562
|
lines.push(` handler: ${p.handlerBody.raw},`);
|
|
563
563
|
lines.push("});");
|
|
564
564
|
return lines.join("\n");
|
|
@@ -498,6 +498,12 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
498
498
|
`Pick a different path or use r.queryHandler / r.writeHandler.`,
|
|
499
499
|
);
|
|
500
500
|
}
|
|
501
|
+
if (typeof definition.anonymous !== "boolean") {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`[Feature ${name}] httpRoute "${definition.method} ${definition.path}" must declare ` +
|
|
504
|
+
`anonymous: true | false — true mounts it public, false behind the session auth chain.`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
501
507
|
const key = `${definition.method} ${definition.path}`;
|
|
502
508
|
if (state.httpRoutes[key]) {
|
|
503
509
|
throw new Error(
|
package/src/engine/index.ts
CHANGED
|
@@ -410,6 +410,8 @@ export type {
|
|
|
410
410
|
HookMap,
|
|
411
411
|
ImageFieldDef,
|
|
412
412
|
ImagesFieldDef,
|
|
413
|
+
JobBackoff,
|
|
414
|
+
JobBackoffStrategy,
|
|
413
415
|
JobContext,
|
|
414
416
|
JobDefinition,
|
|
415
417
|
JobHandlerFn,
|
|
@@ -476,6 +478,8 @@ export type {
|
|
|
476
478
|
RelationDefinition,
|
|
477
479
|
ResolvedPiiFlags,
|
|
478
480
|
RetentionDef,
|
|
481
|
+
RoleAccessPersonalData,
|
|
482
|
+
RoleAccessRule,
|
|
479
483
|
RowAction,
|
|
480
484
|
RowActionNavigate,
|
|
481
485
|
RowActionNavigateBase,
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// survive a projection rebuild: replaying a stored membership event goes
|
|
7
7
|
// through the apply path, not the handler. stripForbiddenMembershipRoles is
|
|
8
8
|
// the read-time backstop — applied at every JWT mint that derives roles from
|
|
9
|
-
// membership, it neutralises a resurrected role
|
|
10
|
-
// (where SystemAdmin
|
|
9
|
+
// membership, it neutralises a resurrected role. buildSessionRoles applies a
|
|
10
|
+
// matching strip (anonymous/all only) to globalRoles, where SystemAdmin
|
|
11
|
+
// legitimately lives.
|
|
11
12
|
|
|
12
13
|
import { access } from "./config-helpers";
|
|
13
14
|
|
|
@@ -26,11 +27,21 @@ export function findForbiddenMembershipRole(roles: readonly string[]): string |
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// Filters reserved roles out of the membership portion only. Callers merge the
|
|
29
|
-
// result with globalRoles, which
|
|
30
|
+
// result with globalRoles, which buildSessionRoles filters separately below.
|
|
30
31
|
export function stripForbiddenMembershipRoles(roles: readonly string[]): readonly string[] {
|
|
31
32
|
return roles.filter((role) => !isForbiddenMembershipRole(role));
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
// "anonymous"/"all" are never legitimate global roles; system/SystemAdmin stay.
|
|
36
|
+
const NON_MINTABLE_GLOBAL_ROLES: ReadonlySet<string> = new Set<string>([
|
|
37
|
+
...access.all,
|
|
38
|
+
...access.anonymous,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function stripNonMintableGlobalRoles(roles: readonly string[]): readonly string[] {
|
|
42
|
+
return roles.filter((role) => !NON_MINTABLE_GLOBAL_ROLES.has(role));
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
// Single mint path for session roles: merges globalRoles with the stripped
|
|
35
46
|
// membership portion and dedupes. Every place that builds a session's roles
|
|
36
47
|
// from a membership should go through this instead of repeating
|
|
@@ -41,5 +52,10 @@ export function buildSessionRoles(
|
|
|
41
52
|
globalRoles: readonly string[],
|
|
42
53
|
membershipRoles: readonly string[],
|
|
43
54
|
): readonly string[] {
|
|
44
|
-
return Array.from(
|
|
55
|
+
return Array.from(
|
|
56
|
+
new Set([
|
|
57
|
+
...stripNonMintableGlobalRoles(globalRoles),
|
|
58
|
+
...stripForbiddenMembershipRoles(membershipRoles),
|
|
59
|
+
]),
|
|
60
|
+
);
|
|
45
61
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ANONYMOUS_ROLE } from "./system-user";
|
|
2
|
+
import type { AccessRule, OwnershipMap, OwnershipRule } from "./types";
|
|
3
|
+
import type { EntityDefinition, ResolvedPiiFlags } from "./types/fields";
|
|
4
|
+
|
|
5
|
+
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
6
|
+
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
7
|
+
// sense the openToAll / public-intake personal-data checks are guarding against.
|
|
8
|
+
function isPersonalDataField(field: unknown): boolean {
|
|
9
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
10
|
+
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
|
|
14
|
+
if (rule === "all" || rule.kind !== "from") return false;
|
|
15
|
+
return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The executor checks access.write against every created/updated row; one "all" role
|
|
19
|
+
// or an empty map (= public) lets a caller write rows owned by someone else.
|
|
20
|
+
function writeMapBindsRowsToCaller(
|
|
21
|
+
writeMap: OwnershipMap | undefined,
|
|
22
|
+
ownerColumn: string,
|
|
23
|
+
): boolean {
|
|
24
|
+
const rules = Object.values(writeMap ?? {});
|
|
25
|
+
return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ROW_ID_COLUMN = "id";
|
|
29
|
+
|
|
30
|
+
// A self/record-owned field's subject is the row itself, so only from("user:id", "id")
|
|
31
|
+
// makes that row the caller — on any other entity "self" names a third party.
|
|
32
|
+
function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
|
|
33
|
+
if (annot.userOwned) return annot.userOwned.ownerField;
|
|
34
|
+
if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
|
|
39
|
+
const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
|
|
40
|
+
return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function personalFieldNames(
|
|
44
|
+
entity: EntityDefinition,
|
|
45
|
+
honorOwnerBinding: boolean,
|
|
46
|
+
): ReadonlySet<string> {
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
49
|
+
const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
|
|
50
|
+
if (isPersonalDataField(field) && !exempt) names.add(fieldName);
|
|
51
|
+
}
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
56
|
+
export function declaredPersonalData(access: AccessRule): unknown {
|
|
57
|
+
if (!("openToAll" in access)) return access.personalData;
|
|
58
|
+
const openToAll: unknown = access.openToAll;
|
|
59
|
+
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
60
|
+
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function accessAllowsAnonymous(access: AccessRule): boolean {
|
|
64
|
+
if ("openToAll" in access) return false;
|
|
65
|
+
return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
|
|
66
|
+
}
|
|
@@ -729,6 +729,21 @@ export function validateBootGates(state: RegistryState): void {
|
|
|
729
729
|
}
|
|
730
730
|
}
|
|
731
731
|
|
|
732
|
+
export function validateJobBackoff(state: RegistryState): void {
|
|
733
|
+
// Object-form backoff carries an explicit delayMs base — catch a bad value
|
|
734
|
+
// at boot instead of letting BullMQ silently compute NaN/undefined delays.
|
|
735
|
+
for (const [jobName, jobDef] of state.jobMap) {
|
|
736
|
+
if (typeof jobDef.backoff !== "object") continue;
|
|
737
|
+
const { delayMs } = jobDef.backoff;
|
|
738
|
+
if (delayMs === undefined) continue;
|
|
739
|
+
if (!Number.isInteger(delayMs) || delayMs <= 0) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`Job "${jobName}" backoff.delayMs must be a positive integer (got ${delayMs})`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
732
747
|
export function validateExtensionUsageTargets(state: RegistryState): void {
|
|
733
748
|
// Validate: extension usages must reference existing extensions
|
|
734
749
|
for (const usage of state.extensionUsages) {
|
package/src/engine/registry.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
validateExtensionSelectors,
|
|
35
35
|
validateExtensionUsageTargets,
|
|
36
36
|
validateFieldAccessHandlersAreEntityMapped,
|
|
37
|
+
validateJobBackoff,
|
|
37
38
|
validateJobTriggers,
|
|
38
39
|
validateLifecycleHookTargets,
|
|
39
40
|
validateProjectionApplyKeys,
|
|
@@ -83,6 +84,7 @@ export function createRegistry(rawFeatures: readonly FeatureDefinition[]): Regis
|
|
|
83
84
|
validateEntityHookTargets(state, features);
|
|
84
85
|
validateJobTriggers(state);
|
|
85
86
|
validateBootGates(state);
|
|
87
|
+
validateJobBackoff(state);
|
|
86
88
|
validateExtensionUsageTargets(state);
|
|
87
89
|
computeHasRateLimitedHandler(state);
|
|
88
90
|
publishEventPiiCatalog(state);
|
|
@@ -32,6 +32,8 @@ export type {
|
|
|
32
32
|
CreateTenantSeedOptions,
|
|
33
33
|
CreateUserSeedOptions,
|
|
34
34
|
ExtensionSelectorDef,
|
|
35
|
+
JobBackoff,
|
|
36
|
+
JobBackoffStrategy,
|
|
35
37
|
JobDefinition,
|
|
36
38
|
JobHandlerFn,
|
|
37
39
|
JobRunIn,
|
|
@@ -194,6 +196,8 @@ export type {
|
|
|
194
196
|
RateLimitDisabled,
|
|
195
197
|
RateLimitOption,
|
|
196
198
|
RateLimitPer,
|
|
199
|
+
RoleAccessPersonalData,
|
|
200
|
+
RoleAccessRule,
|
|
197
201
|
SessionUser,
|
|
198
202
|
SessionUserOrigin,
|
|
199
203
|
StreamHandlerDef,
|