@cosmicdrift/kumiko-framework 0.299.0 → 0.304.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__/http-route-entry.integration.test.ts +114 -0
- package/src/api/auth-routes.ts +53 -24
- package/src/api/server.ts +71 -32
- package/src/changes.json +26 -0
- 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 +59 -7
- 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 +2 -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/types/index.ts +2 -0
- 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/testing/handler-context.ts +3 -1
- package/src/ui-types/index.ts +2 -0
|
@@ -578,3 +578,130 @@ describe("validateAccessDeclarations — self-bound personal-data fields", () =>
|
|
|
578
578
|
expect(() => validateAccessDeclarations(feature)).toThrow(/"displayName"/);
|
|
579
579
|
});
|
|
580
580
|
});
|
|
581
|
+
|
|
582
|
+
describe("validateAccessDeclarations — anonymous (roles-form) personal-data intake", () => {
|
|
583
|
+
test("an anonymous write handler accepting a personal-data field without personalData throws", () => {
|
|
584
|
+
const feature = defineFeature("notes", (r) => {
|
|
585
|
+
r.entity("note", noteEntity);
|
|
586
|
+
r.writeHandler(
|
|
587
|
+
"note:signup",
|
|
588
|
+
z.object({ email: z.string() }),
|
|
589
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
590
|
+
{ access: { roles: ["anonymous"] } },
|
|
591
|
+
);
|
|
592
|
+
});
|
|
593
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
594
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:signup"/);
|
|
595
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
|
|
596
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/personalData: "public-intake"/);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('the same handler WITH personalData: "public-intake" boots fine', () => {
|
|
600
|
+
const feature = defineFeature("notes", (r) => {
|
|
601
|
+
r.entity("note", noteEntity);
|
|
602
|
+
r.writeHandler(
|
|
603
|
+
"note:signup",
|
|
604
|
+
z.object({ email: z.string() }),
|
|
605
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
606
|
+
{ access: { roles: ["anonymous"], personalData: "public-intake" } },
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('an owner-bound personal-data field is not exempted for an anonymous handler — anonymous callers share one user.id, so from("user:id", ...) binds no one', () => {
|
|
613
|
+
const ownedByCaller: OwnershipMap = { Member: from("user:id", "ownerUserId") };
|
|
614
|
+
const entity = createEntity({
|
|
615
|
+
table: "fw2885_guard_anon_owned",
|
|
616
|
+
fields: {
|
|
617
|
+
name: createTextField({ personal: { of: "ownerUserId" }, find: "none" }),
|
|
618
|
+
ownerUserId: createTextField({ required: false, personal: "ref" }),
|
|
619
|
+
},
|
|
620
|
+
access: { write: ownedByCaller },
|
|
621
|
+
});
|
|
622
|
+
const schema = z.object({ name: z.string() });
|
|
623
|
+
const anonymousFeature = defineFeature("notes", (r) => {
|
|
624
|
+
r.entity("note", entity);
|
|
625
|
+
r.writeHandler("note:signup", schema, async () => ({ isSuccess: true as const, data: {} }), {
|
|
626
|
+
access: { roles: ["anonymous"] },
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
expect(anonymousFeature.handlerEntityMappings["note:signup"]).toBe("note");
|
|
630
|
+
expect(() => validateAccessDeclarations(anonymousFeature)).toThrow(/"name"/);
|
|
631
|
+
|
|
632
|
+
// Control: the same owner-bound entity/schema via openToAll IS exempted, so the throw above is override-specific.
|
|
633
|
+
const openToAllFeature = defineFeature("notes", (r) => {
|
|
634
|
+
r.entity("note", entity);
|
|
635
|
+
r.writeHandler("note:signup", schema, async () => ({ isSuccess: true as const, data: {} }), {
|
|
636
|
+
access: { openToAll: { reason: "members share contacts" } },
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
expect(() => validateAccessDeclarations(openToAllFeature)).not.toThrow();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test('personalData: "public-intake" on roles without "anonymous" throws', () => {
|
|
643
|
+
const feature = defineFeature("notes", (r) => {
|
|
644
|
+
r.entity("note", noteEntity);
|
|
645
|
+
r.writeHandler(
|
|
646
|
+
"note:create",
|
|
647
|
+
z.object({ title: z.string() }),
|
|
648
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
649
|
+
{ access: { roles: ["Admin"], personalData: "public-intake" } },
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
|
|
653
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/anonymous/);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('personalData: "tenant-members" on the roles form throws', () => {
|
|
657
|
+
const feature = defineFeature("notes", (r) => {
|
|
658
|
+
r.entity("note", noteEntity);
|
|
659
|
+
r.writeHandler(
|
|
660
|
+
"note:create",
|
|
661
|
+
z.object({ title: z.string() }),
|
|
662
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
663
|
+
{
|
|
664
|
+
// @cast-boundary test — simulates JSON/Designer input that doesn't match the static union
|
|
665
|
+
access: { roles: ["anonymous"], personalData: "tenant-members" } as unknown as AccessRule,
|
|
666
|
+
},
|
|
667
|
+
);
|
|
668
|
+
});
|
|
669
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"tenant-members"/);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("personalData on the roles form of a query handler throws", () => {
|
|
673
|
+
const feature = defineFeature("notes", (r) => {
|
|
674
|
+
r.entity("note", noteEntity);
|
|
675
|
+
r.queryHandler("note:list", z.object({}), async () => [], {
|
|
676
|
+
access: { roles: ["anonymous"], personalData: "public-intake" },
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:list"/);
|
|
680
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/access\.personalData/);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("a non-anonymous roles handler accepting a personal-data field does not throw", () => {
|
|
684
|
+
const feature = defineFeature("notes", (r) => {
|
|
685
|
+
r.entity("note", noteEntity);
|
|
686
|
+
r.writeHandler(
|
|
687
|
+
"note:create",
|
|
688
|
+
z.object({ email: z.string() }),
|
|
689
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
690
|
+
{ access: { roles: ["Admin"] } },
|
|
691
|
+
);
|
|
692
|
+
});
|
|
693
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test('personalData: "tenant-members" no longer type-checks on the roles form', () => {
|
|
697
|
+
// @ts-expect-error "tenant-members" is only valid on openToAll, not the roles form
|
|
698
|
+
const access: AccessRule = { roles: ["anonymous"], personalData: "tenant-members" };
|
|
699
|
+
expect(access).toBeDefined();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("an unrecognised personalData string does not type-check on the roles form", () => {
|
|
703
|
+
// @ts-expect-error only "public-intake" is a valid roles-form personalData value
|
|
704
|
+
const access: AccessRule = { roles: ["anonymous"], personalData: "whatever" };
|
|
705
|
+
expect(access).toBeDefined();
|
|
706
|
+
});
|
|
707
|
+
});
|
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
import { ANONYMOUS_ROLE } from "../system-user";
|
|
1
2
|
import type {
|
|
2
3
|
AccessRule,
|
|
3
4
|
FeatureDefinition,
|
|
@@ -73,12 +74,14 @@ function candidatePersonalFieldNames(
|
|
|
73
74
|
feature: FeatureDefinition,
|
|
74
75
|
handlerName: string,
|
|
75
76
|
handler: WriteHandlerDef,
|
|
77
|
+
honorOwnerBindingOverride?: boolean,
|
|
76
78
|
): ReadonlySet<string> {
|
|
77
79
|
const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
|
|
78
80
|
const entities = feature.entities ?? {};
|
|
79
81
|
if (mappedEntityName) {
|
|
80
82
|
const entity = entities[mappedEntityName];
|
|
81
|
-
const honorOwnerBinding =
|
|
83
|
+
const honorOwnerBinding =
|
|
84
|
+
honorOwnerBindingOverride ?? !canWriteAroundExecutor(feature, handler);
|
|
82
85
|
return entity ? personalFieldNames(entity, honorOwnerBinding) : new Set();
|
|
83
86
|
}
|
|
84
87
|
const names = new Set<string>();
|
|
@@ -103,12 +106,17 @@ function hasOpenToAll(access: AccessRule): boolean {
|
|
|
103
106
|
|
|
104
107
|
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
105
108
|
function declaredPersonalData(access: AccessRule): unknown {
|
|
106
|
-
if (!("openToAll" in access)) return
|
|
109
|
+
if (!("openToAll" in access)) return access.personalData;
|
|
107
110
|
const openToAll: unknown = access.openToAll;
|
|
108
111
|
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
109
112
|
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
function accessAllowsAnonymous(access: AccessRule): boolean {
|
|
116
|
+
if ("openToAll" in access) return false;
|
|
117
|
+
return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
|
|
118
|
+
}
|
|
119
|
+
|
|
112
120
|
function declaresTenantMembersPersonalData(access: AccessRule): boolean {
|
|
113
121
|
return declaredPersonalData(access) === "tenant-members";
|
|
114
122
|
}
|
|
@@ -154,9 +162,10 @@ function validatePersonalDataOnlyOnWrite(
|
|
|
154
162
|
): void {
|
|
155
163
|
// skip: write handlers may declare personalData, or it wasn't declared here
|
|
156
164
|
if (kind === "write" || declaredPersonalData(access) === undefined) return;
|
|
165
|
+
const property = "openToAll" in access ? "openToAll.personalData" : "access.personalData";
|
|
157
166
|
throw new Error(
|
|
158
167
|
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
159
|
-
|
|
168
|
+
`${property} — it only applies to write handlers, whose input is ` +
|
|
160
169
|
"checked for personal-data fields.",
|
|
161
170
|
);
|
|
162
171
|
}
|
|
@@ -167,11 +176,29 @@ function validatePersonalDataValue(
|
|
|
167
176
|
access: AccessRule,
|
|
168
177
|
): void {
|
|
169
178
|
const declared = declaredPersonalData(access);
|
|
170
|
-
// skip: nothing declared
|
|
171
|
-
if (declared === undefined
|
|
179
|
+
// skip: nothing declared
|
|
180
|
+
if (declared === undefined) return;
|
|
181
|
+
if ("openToAll" in access) {
|
|
182
|
+
// skip: the one supported value on openToAll
|
|
183
|
+
if (declared === "tenant-members") return;
|
|
184
|
+
throw new Error(
|
|
185
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
|
|
186
|
+
`openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (declared !== "public-intake") {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
|
|
192
|
+
`access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
// skip: roles include "anonymous", which personalData: "public-intake" requires
|
|
196
|
+
if (Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE)) return;
|
|
172
197
|
throw new Error(
|
|
173
|
-
`[Feature ${feature.name}] write handler "${handlerName}" declares
|
|
174
|
-
|
|
198
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
|
|
199
|
+
'access.personalData: "public-intake" but its roles do not include ' +
|
|
200
|
+
`"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
|
|
201
|
+
"that allow anonymous callers.",
|
|
175
202
|
);
|
|
176
203
|
}
|
|
177
204
|
|
|
@@ -200,12 +227,37 @@ function validateOpenToAllPersonalData(
|
|
|
200
227
|
);
|
|
201
228
|
}
|
|
202
229
|
|
|
230
|
+
// Anonymous callers all share one user.id, so honorOwnerBindingOverride is always false here.
|
|
231
|
+
function validateAnonymousPersonalData(
|
|
232
|
+
feature: FeatureDefinition,
|
|
233
|
+
handlerName: string,
|
|
234
|
+
handler: WriteHandlerDef,
|
|
235
|
+
): void {
|
|
236
|
+
const access = handler.access;
|
|
237
|
+
// skip: not reachable by an anonymous caller, or already declares public-intake
|
|
238
|
+
if (!accessAllowsAnonymous(access) || declaredPersonalData(access) === "public-intake") return;
|
|
239
|
+
const inputKeys = collectZodObjectKeys(handler.schema);
|
|
240
|
+
const personalNames = candidatePersonalFieldNames(feature, handlerName, handler, false);
|
|
241
|
+
const offending = [...inputKeys].filter((key) => personalNames.has(key));
|
|
242
|
+
// skip: no personal-data fields in the handler's input
|
|
243
|
+
if (offending.length === 0) return;
|
|
244
|
+
throw new Error(
|
|
245
|
+
`[Feature ${feature.name}] write handler "${handlerName}" allows anonymous callers ` +
|
|
246
|
+
`("${ANONYMOUS_ROLE}" in access.roles) and accepts personal-data field(s) ` +
|
|
247
|
+
`${offending.map((f) => `"${f}"`).join(", ")}. Declare ` +
|
|
248
|
+
'access: { roles: [..., "anonymous"], personalData: "public-intake" } — every anonymous ' +
|
|
249
|
+
'request shares one caller identity, so owner-binding via from("user:id", ...) does not ' +
|
|
250
|
+
"vouch for it; protection comes from the handler's required rateLimit (per ip).",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
203
254
|
export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
204
255
|
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
205
256
|
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
206
257
|
validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
|
|
207
258
|
validatePersonalDataValue(feature, handlerName, handler.access);
|
|
208
259
|
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
260
|
+
validateAnonymousPersonalData(feature, handlerName, handler);
|
|
209
261
|
}
|
|
210
262
|
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
211
263
|
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
|
@@ -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,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { composeEnvSchema, readKumikoMeta } from "../../env";
|
|
4
|
+
import { prometheusMetricsEnvSchema, resolveObservabilityWiring } from "../metrics-wiring";
|
|
5
|
+
|
|
6
|
+
describe("resolveObservabilityWiring", () => {
|
|
7
|
+
it("returns {} without a token", () => {
|
|
8
|
+
expect(resolveObservabilityWiring(undefined)).toEqual({});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns {} for an empty token", () => {
|
|
12
|
+
expect(resolveObservabilityWiring("")).toEqual({});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("wires a prometheus provider and metrics route when a token is set", () => {
|
|
16
|
+
const token = "a".repeat(32);
|
|
17
|
+
const wiring = resolveObservabilityWiring(token);
|
|
18
|
+
|
|
19
|
+
expect("metrics" in wiring).toBe(true);
|
|
20
|
+
if (!("metrics" in wiring)) throw new Error("expected wiring to include metrics");
|
|
21
|
+
|
|
22
|
+
expect(wiring.metrics).toEqual({ path: "/metrics", token });
|
|
23
|
+
expect(wiring.observability.name).toBe("prometheus");
|
|
24
|
+
expect(wiring.observability.meter.snapshot()).toEqual(new Map());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("prometheusMetricsEnvSchema", () => {
|
|
29
|
+
it("rejects tokens shorter than 32 characters", () => {
|
|
30
|
+
const result = prometheusMetricsEnvSchema.safeParse({
|
|
31
|
+
PROMETHEUS_METRICS_TOKEN: "a".repeat(31),
|
|
32
|
+
});
|
|
33
|
+
expect(result.success).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("accepts a 32-character token", () => {
|
|
37
|
+
const result = prometheusMetricsEnvSchema.safeParse({
|
|
38
|
+
PROMETHEUS_METRICS_TOKEN: "a".repeat(32),
|
|
39
|
+
});
|
|
40
|
+
expect(result.success).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("accepts a missing token", () => {
|
|
44
|
+
const result = prometheusMetricsEnvSchema.safeParse({});
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("exposes pulumi secret metadata for consumer env-schemas", () => {
|
|
49
|
+
const { schema } = composeEnvSchema({
|
|
50
|
+
features: [],
|
|
51
|
+
extend: z.object({ FOO: z.string() }).extend(prometheusMetricsEnvSchema.shape),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const field = schema.shape["PROMETHEUS_METRICS_TOKEN"];
|
|
55
|
+
if (!(field instanceof z.ZodType))
|
|
56
|
+
throw new Error("expected PROMETHEUS_METRICS_TOKEN in composed schema");
|
|
57
|
+
const meta = readKumikoMeta(field);
|
|
58
|
+
expect(meta.pulumi?.secret).toBe(true);
|
|
59
|
+
expect(meta.pulumi?.generator).toBe("openssl rand -base64 32");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -16,6 +16,11 @@ export {
|
|
|
16
16
|
createSafeMetricsHandle,
|
|
17
17
|
createUnboundMetricsHandle,
|
|
18
18
|
} from "./metrics-handle";
|
|
19
|
+
export {
|
|
20
|
+
type ObservabilityWiring,
|
|
21
|
+
prometheusMetricsEnvSchema,
|
|
22
|
+
resolveObservabilityWiring,
|
|
23
|
+
} from "./metrics-wiring";
|
|
19
24
|
export { createNoopProvider } from "./noop-provider";
|
|
20
25
|
export {
|
|
21
26
|
createPrometheusMeter,
|