@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.
Files changed (31) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/api.test.ts +3 -2
  3. package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
  4. package/src/api/auth-routes.ts +53 -24
  5. package/src/api/server.ts +71 -32
  6. package/src/changes.json +26 -0
  7. package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
  8. package/src/engine/__tests__/membership-roles.test.ts +13 -4
  9. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
  10. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
  11. package/src/engine/boot-validator/access-declarations.ts +59 -7
  12. package/src/engine/boot-validator/entity-handler.ts +20 -0
  13. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  14. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  15. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
  16. package/src/engine/feature-ast/extractors/hooks.ts +3 -1
  17. package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
  18. package/src/engine/feature-ast/patcher.ts +2 -2
  19. package/src/engine/feature-ast/patterns.ts +1 -1
  20. package/src/engine/feature-ast/render.ts +1 -1
  21. package/src/engine/feature-ui-extensions.ts +6 -0
  22. package/src/engine/index.ts +2 -0
  23. package/src/engine/membership-roles.ts +20 -4
  24. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  25. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  26. package/src/engine/types/index.ts +2 -0
  27. package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
  28. package/src/observability/index.ts +5 -0
  29. package/src/observability/metrics-wiring.ts +32 -0
  30. package/src/testing/handler-context.ts +3 -1
  31. 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 = !canWriteAroundExecutor(feature, handler);
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 undefined;
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
- "openToAll.personalData — it only applies to write handlers, whose input is " +
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, or the one supported value
171
- if (declared === undefined || declared === "tenant-members") return;
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 an unknown ` +
174
- `openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
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
- return { roles: value["roles"] as readonly string[] };
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
- const anonymous = readBooleanProperty(arg, "anonymous");
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
- ...(anonymous === true && { anonymous: true }),
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?: boolean;
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
- ...(anonymous === true && { anonymous: true }),
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?: boolean;
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
- if (p.anonymous === true) lines.push(" anonymous: true,");
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(
@@ -476,6 +476,8 @@ export type {
476
476
  RelationDefinition,
477
477
  ResolvedPiiFlags,
478
478
  RetentionDef,
479
+ RoleAccessPersonalData,
480
+ RoleAccessRule,
479
481
  RowAction,
480
482
  RowActionNavigate,
481
483
  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 without touching globalRoles
10
- // (where SystemAdmin legitimately lives).
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 is never filtered.
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(new Set([...globalRoles, ...stripForbiddenMembershipRoles(membershipRoles)]));
55
+ return Array.from(
56
+ new Set([
57
+ ...stripNonMintableGlobalRoles(globalRoles),
58
+ ...stripForbiddenMembershipRoles(membershipRoles),
59
+ ]),
60
+ );
45
61
  }
@@ -315,6 +315,7 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
315
315
  source: PLACEHOLDER_LOC,
316
316
  method: "GET",
317
317
  path: "/x",
318
+ anonymous: true,
318
319
  handlerBody: PLACEHOLDER_BODY_LOC,
319
320
  };
320
321
  case "projection":
@@ -453,6 +453,7 @@ export const httpRouteSchema: PatternFormSchema = {
453
453
  path: "anonymous",
454
454
  label: { en: "Anonymous (no auth)", de: "Anonym (keine Auth)" },
455
455
  input: "boolean",
456
+ required: true,
456
457
  },
457
458
  {
458
459
  path: "handlerBody",
@@ -194,6 +194,8 @@ export type {
194
194
  RateLimitDisabled,
195
195
  RateLimitOption,
196
196
  RateLimitPer,
197
+ RoleAccessPersonalData,
198
+ RoleAccessRule,
197
199
  SessionUser,
198
200
  SessionUserOrigin,
199
201
  StreamHandlerDef,
@@ -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,