@cosmicdrift/kumiko-framework 0.265.0 → 0.268.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 (54) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
  9. package/src/api/__tests__/jwt.test.ts +8 -0
  10. package/src/api/jwt.ts +8 -0
  11. package/src/changes.json +35 -0
  12. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  13. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  14. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  15. package/src/db/entity-table-meta.ts +7 -0
  16. package/src/db/event-store-executor-context.ts +19 -0
  17. package/src/db/event-store-executor-write.ts +16 -0
  18. package/src/db/index.ts +3 -1
  19. package/src/db/table-builder.ts +27 -0
  20. package/src/db/tenant-db.ts +83 -10
  21. package/src/engine/active-membership.ts +15 -1
  22. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
  23. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  24. package/src/engine/boot-validator/access-declarations.ts +101 -27
  25. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  26. package/src/engine/boot-validator/index.ts +2 -0
  27. package/src/engine/boot-validator/zod-shape.ts +92 -1
  28. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
  29. package/src/engine/feature-ast/extractors/hooks.ts +8 -4
  30. package/src/engine/feature-ui-extensions.ts +2 -2
  31. package/src/engine/index.ts +4 -0
  32. package/src/engine/registry-validate.ts +6 -6
  33. package/src/engine/types/index.ts +3 -0
  34. package/src/entrypoint/index.ts +2 -13
  35. package/src/errors/i18n/de.yaml +20 -0
  36. package/src/errors/i18n/en.yaml +19 -0
  37. package/src/errors/reasons.ts +8 -0
  38. package/src/jobs/job-runner.ts +14 -0
  39. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  40. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  41. package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
  42. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  43. package/src/pipeline/active-membership.ts +3 -1
  44. package/src/pipeline/dispatch-shared.ts +89 -10
  45. package/src/pipeline/dispatch-stream.ts +9 -5
  46. package/src/pipeline/dispatch-write.ts +7 -0
  47. package/src/pipeline/dispatcher-utils.ts +1 -0
  48. package/src/pipeline/dispatcher.ts +18 -0
  49. package/src/pipeline/index.ts +1 -1
  50. package/src/pipeline/member-reader.ts +108 -0
  51. package/src/pipeline/system-identity-switch.ts +73 -13
  52. package/src/stack/test-stack.ts +10 -1
  53. package/src/testing/handler-context.ts +3 -0
  54. package/src/ui-types/index.ts +1 -0
@@ -1,38 +1,88 @@
1
- import type { AccessRule, FeatureDefinition, QueryHandlerDef, WriteHandlerDef } from "../types";
1
+ import type {
2
+ AccessRule,
3
+ FeatureDefinition,
4
+ OwnershipMap,
5
+ OwnershipRule,
6
+ QueryHandlerDef,
7
+ WriteHandlerDef,
8
+ } from "../types";
2
9
  import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
3
- import { getZodObjectShape } from "./zod-shape";
10
+ import { collectZodObjectKeys } from "./zod-shape";
4
11
 
5
12
  type HandlerKind = "write" | "query" | "stream";
6
13
 
7
14
  // Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
8
15
  // minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
9
- // sense openToAll+publicIntake is guarding against.
16
+ // sense the openToAll personal-data check is guarding against.
10
17
  function isPersonalDataField(field: unknown): boolean {
11
18
  const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
12
19
  return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
13
20
  }
14
21
 
15
- function personalFieldNames(entity: EntityDefinition): ReadonlySet<string> {
22
+ function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
23
+ if (rule === "all" || rule.kind !== "from") return false;
24
+ return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
25
+ }
26
+
27
+ // The executor checks access.write against every created/updated row; one "all" role
28
+ // or an empty map (= public) lets a caller write rows owned by someone else.
29
+ function writeMapBindsRowsToCaller(
30
+ writeMap: OwnershipMap | undefined,
31
+ ownerColumn: string,
32
+ ): boolean {
33
+ const rules = Object.values(writeMap ?? {});
34
+ return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
35
+ }
36
+
37
+ const ROW_ID_COLUMN = "id";
38
+
39
+ // A self/record-owned field's subject is the row itself, so only from("user:id", "id")
40
+ // makes that row the caller — on any other entity "self" names a third party.
41
+ function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
42
+ if (annot.userOwned) return annot.userOwned.ownerField;
43
+ if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
44
+ return undefined;
45
+ }
46
+
47
+ function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
48
+ const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
49
+ return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
50
+ }
51
+
52
+ function personalFieldNames(
53
+ entity: EntityDefinition,
54
+ honorOwnerBinding: boolean,
55
+ ): ReadonlySet<string> {
16
56
  const names = new Set<string>();
17
57
  for (const [fieldName, field] of Object.entries(entity.fields)) {
18
- if (isPersonalDataField(field)) names.add(fieldName);
58
+ const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
59
+ if (isPersonalDataField(field) && !exempt) names.add(fieldName);
19
60
  }
20
61
  return names;
21
62
  }
22
63
 
64
+ // escapeHatch and r.systemScope() can write around the entity's write map
65
+ // (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
66
+ function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
67
+ return handler.escapeHatch !== undefined || feature.systemScope;
68
+ }
69
+
70
+ // Owner binding counts only for a handler mapped to one entity.
23
71
  function candidatePersonalFieldNames(
24
72
  feature: FeatureDefinition,
25
73
  handlerName: string,
74
+ handler: WriteHandlerDef,
26
75
  ): ReadonlySet<string> {
27
76
  const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
28
77
  const entities = feature.entities ?? {};
29
78
  if (mappedEntityName) {
30
79
  const entity = entities[mappedEntityName];
31
- return entity ? personalFieldNames(entity) : new Set();
80
+ const honorOwnerBinding = !canWriteAroundExecutor(feature, handler);
81
+ return entity ? personalFieldNames(entity, honorOwnerBinding) : new Set();
32
82
  }
33
83
  const names = new Set<string>();
34
84
  for (const entity of Object.values(entities)) {
35
- for (const name of personalFieldNames(entity)) names.add(name);
85
+ for (const name of personalFieldNames(entity, false)) names.add(name);
36
86
  }
37
87
  return names;
38
88
  }
@@ -52,8 +102,16 @@ function hasOpenToAll(access: AccessRule): boolean {
52
102
  return "openToAll" in access;
53
103
  }
54
104
 
55
- function hasPublicIntake(access: AccessRule): boolean {
56
- return "publicIntake" in access && access.publicIntake === true;
105
+ // Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
106
+ function declaredPersonalData(access: AccessRule): unknown {
107
+ if (!("openToAll" in access)) return undefined;
108
+ const openToAll: unknown = access.openToAll;
109
+ if (typeof openToAll !== "object" || openToAll === null) return undefined;
110
+ return "personalData" in openToAll ? openToAll.personalData : undefined;
111
+ }
112
+
113
+ function declaresTenantMembersPersonalData(access: AccessRule): boolean {
114
+ return declaredPersonalData(access) === "tenant-members";
57
115
  }
58
116
 
59
117
  function validateOpenToAllReason(
@@ -86,19 +144,32 @@ function validateEscapeHatchReason(
86
144
  );
87
145
  }
88
146
 
89
- function validatePublicIntakeOnlyOnWrite(
147
+ function validatePersonalDataOnlyOnWrite(
90
148
  feature: FeatureDefinition,
91
149
  kind: HandlerKind,
92
150
  handlerName: string,
93
151
  access: AccessRule,
94
152
  ): void {
95
- // skip: write handlers may declare publicIntake, or it wasn't declared here
96
- if (kind === "write" || !hasPublicIntake(access)) return;
153
+ // skip: write handlers may declare personalData, or it wasn't declared here
154
+ if (kind === "write" || declaredPersonalData(access) === undefined) return;
97
155
  throw new Error(
98
156
  `[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
99
- "{ publicIntake: true } — publicIntake is only meaningful on a write handler " +
100
- "(it silences the personal-data + openToAll boot check for the fields a write " +
101
- "handler's input accepts).",
157
+ "openToAll.personalData — it only applies to write handlers, whose input is " +
158
+ "checked for personal-data fields.",
159
+ );
160
+ }
161
+
162
+ function validatePersonalDataValue(
163
+ feature: FeatureDefinition,
164
+ handlerName: string,
165
+ access: AccessRule,
166
+ ): void {
167
+ const declared = declaredPersonalData(access);
168
+ // skip: nothing declared, or the one supported value
169
+ if (declared === undefined || declared === "tenant-members") return;
170
+ throw new Error(
171
+ `[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
172
+ `openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
102
173
  );
103
174
  }
104
175
 
@@ -108,20 +179,22 @@ function validateOpenToAllPersonalData(
108
179
  handler: WriteHandlerDef,
109
180
  ): void {
110
181
  const access = handler.access;
111
- // skip: no openToAll declared, or publicIntake already covers personal data
112
- if (!hasOpenToAll(access) || hasPublicIntake(access)) return;
113
- const shape = getZodObjectShape(handler.schema);
114
- // skip: handler schema isn't a zod object — nothing to inspect
115
- if (!shape) return;
116
- const personalNames = candidatePersonalFieldNames(feature, handlerName);
117
- const offending = Object.keys(shape).filter((key) => personalNames.has(key));
182
+ // skip: no openToAll declared, or the handler declares tenant members may write personal data
183
+ if (!hasOpenToAll(access) || declaresTenantMembersPersonalData(access)) return;
184
+ const inputKeys = collectZodObjectKeys(handler.schema);
185
+ const personalNames = candidatePersonalFieldNames(feature, handlerName, handler);
186
+ const offending = [...inputKeys].filter((key) => personalNames.has(key));
118
187
  // skip: no personal-data fields in the handler's input
119
188
  if (offending.length === 0) return;
120
189
  throw new Error(
121
190
  `[Feature ${feature.name}] write handler "${handlerName}" declares openToAll and ` +
122
- `accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")} without ` +
123
- "{ publicIntake: true }. Restrict access to roles, or declare " +
124
- "{ publicIntake: true } if any authenticated user may submit this personal data.",
191
+ `accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")} that are ` +
192
+ "not bound to the caller. Choose one: (1) bind rows to the caller — every role in the " +
193
+ 'entity\'s access.write is from("user:id", "<ownerField>") for a personal: { of: "<ownerField>" } ' +
194
+ 'field, or from("user:id", "id") for a personal: "self" field, with no escapeHatch on the ' +
195
+ "handler and no r.systemScope() on its feature; (2) restrict access to roles; (3) declare " +
196
+ '{ openToAll: { reason: "<why any signed-in tenant member may write this personal data>", ' +
197
+ 'personalData: "tenant-members" } }.',
125
198
  );
126
199
  }
127
200
 
@@ -129,15 +202,16 @@ export function validateAccessDeclarations(feature: FeatureDefinition): void {
129
202
  for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
130
203
  validateOpenToAllReason(feature, "write", handlerName, handler.access);
131
204
  validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
205
+ validatePersonalDataValue(feature, handlerName, handler.access);
132
206
  validateOpenToAllPersonalData(feature, handlerName, handler);
133
207
  }
134
208
  for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
135
209
  validateOpenToAllReason(feature, "query", handlerName, handler.access);
136
210
  validateEscapeHatchReason(feature, "query", handlerName, handler.escapeHatch);
137
- validatePublicIntakeOnlyOnWrite(feature, "query", handlerName, handler.access);
211
+ validatePersonalDataOnlyOnWrite(feature, "query", handlerName, handler.access);
138
212
  }
139
213
  for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
140
214
  validateOpenToAllReason(feature, "stream", handlerName, handler.access);
141
- validatePublicIntakeOnlyOnWrite(feature, "stream", handlerName, handler.access);
215
+ validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
142
216
  }
143
217
  }
@@ -0,0 +1,17 @@
1
+ import type { FeatureDefinition } from "../types";
2
+
3
+ // Boot-time twin of event-store-executor-context.ts's buildExecutorContext check —
4
+ // surfaces the systemStream invariant once across all features before any dispatch.
5
+ export function validateGlobalTenancyEntities(features: readonly FeatureDefinition[]): void {
6
+ for (const feature of features) {
7
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
8
+ if (entity.tenancy === "global" && entity.systemStream !== true) {
9
+ throw new Error(
10
+ `Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities must ` +
11
+ "also declare systemStream: true — a global aggregate's event stream must live on " +
12
+ "the system tenant, not the creating tenant's.",
13
+ );
14
+ }
15
+ }
16
+ }
17
+ }
@@ -36,6 +36,7 @@ import {
36
36
  } from "./entity-handler";
37
37
  import { validateEntityListScreens } from "./entity-list-screens";
38
38
  import { validateGdprStoragePersistence } from "./gdpr-storage";
39
+ import { validateGlobalTenancyEntities } from "./global-tenancy";
39
40
  import { validateI18nSurfaceKeys } from "./i18n-keys";
40
41
  import {
41
42
  collectKnownRoles,
@@ -238,6 +239,7 @@ export function validateBoot(
238
239
  validateRelatedListSectionQueries(features);
239
240
  validateExtensionPreSaveWiring(features);
240
241
  validateGdprStoragePersistence(features);
242
+ validateGlobalTenancyEntities(features);
241
243
  validateFeatureBootChecks(features);
242
244
 
243
245
  if (hasEncryptedFields) {
@@ -1,4 +1,23 @@
1
- import { ZodArray, ZodDefault, ZodNullable, ZodObject, ZodOptional, type ZodType } from "zod";
1
+ import {
2
+ ZodArray,
3
+ ZodCatch,
4
+ ZodDefault,
5
+ ZodIntersection,
6
+ ZodLazy,
7
+ ZodNonOptional,
8
+ ZodNullable,
9
+ ZodObject,
10
+ ZodOptional,
11
+ ZodPipe,
12
+ ZodPrefault,
13
+ ZodReadonly,
14
+ ZodRecord,
15
+ ZodTuple,
16
+ type ZodType,
17
+ ZodUnion,
18
+ ZodXor,
19
+ } from "zod";
20
+ import type { $ZodType } from "zod/v4/core";
2
21
 
3
22
  // Drills through wrapper types (.nullable(), .optional(), .default()) a
4
23
  // handler's schema may use around its actual object/array shape — e.g. a
@@ -49,3 +68,75 @@ export function getZodRowShape(schema: ZodType | undefined): Record<string, ZodT
49
68
  ? getZodObjectShape(rowsArray.element as ZodType)
50
69
  : undefined;
51
70
  }
71
+
72
+ type SingleInnerSchema =
73
+ | ZodOptional
74
+ | ZodNullable
75
+ | ZodDefault
76
+ | ZodPrefault
77
+ | ZodNonOptional
78
+ | ZodCatch
79
+ | ZodReadonly
80
+ | ZodLazy;
81
+
82
+ const SINGLE_INNER_SCHEMA_CLASSES = [
83
+ ZodOptional,
84
+ ZodNullable,
85
+ ZodDefault,
86
+ ZodPrefault,
87
+ ZodNonOptional,
88
+ ZodCatch,
89
+ ZodReadonly,
90
+ ZodLazy,
91
+ ] as const;
92
+
93
+ function isSingleInnerSchema(schema: $ZodType): schema is SingleInnerSchema {
94
+ return SINGLE_INNER_SCHEMA_CLASSES.some((schemaClass) => schema instanceof schemaClass);
95
+ }
96
+
97
+ function combinatorChildSchemas(schema: $ZodType): readonly $ZodType[] {
98
+ if (schema instanceof ZodUnion || schema instanceof ZodXor) return schema.options;
99
+ if (schema instanceof ZodIntersection) return [schema.def.left, schema.def.right];
100
+ // z.preprocess() carries the accepted shape on `out`, .transform() on `in`.
101
+ if (schema instanceof ZodPipe) return [schema.in, schema.out];
102
+ return [];
103
+ }
104
+
105
+ function childSchemas(schema: $ZodType): readonly $ZodType[] {
106
+ if (schema instanceof ZodObject) return Object.values(schema.shape);
107
+ if (schema instanceof ZodArray) return [schema.element];
108
+ if (schema instanceof ZodRecord) return [schema.valueType];
109
+ if (schema instanceof ZodTuple) {
110
+ const { items, rest } = schema.def;
111
+ return rest ? [...items, rest] : items;
112
+ }
113
+ if (isSingleInnerSchema(schema)) return [schema.unwrap()];
114
+ return combinatorChildSchemas(schema);
115
+ }
116
+
117
+ // A z.lazy() getter that builds a fresh schema per call never revisits a node.
118
+ const MAX_WALKED_SCHEMAS = 10_000;
119
+
120
+ // Object keys at any depth, so a field nested in an intersection, union or an
121
+ // update's `changes` object is found too.
122
+ export function collectZodObjectKeys(schema: $ZodType | undefined): ReadonlySet<string> {
123
+ const keys = new Set<string>();
124
+ if (schema === undefined) return keys;
125
+ const visited = new Set<$ZodType>();
126
+ const pending: $ZodType[] = [schema];
127
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
128
+ if (visited.has(next)) continue;
129
+ if (visited.size >= MAX_WALKED_SCHEMAS) {
130
+ throw new Error(
131
+ `collectZodObjectKeys: schema has more than ${MAX_WALKED_SCHEMAS} nodes — ` +
132
+ "a z.lazy() getter probably returns a new schema on every call; hoist it into a constant.",
133
+ );
134
+ }
135
+ visited.add(next);
136
+ if (next instanceof ZodObject) {
137
+ for (const key of Object.keys(next.shape)) keys.add(key);
138
+ }
139
+ pending.push(...childSchemas(next));
140
+ }
141
+ return keys;
142
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readOptionalAccessRule } from "../extractors/hooks";
3
+
4
+ describe("readOptionalAccessRule — openToAll.personalData", () => {
5
+ test("keeps personalData: tenant-members on the object form", () => {
6
+ expect(
7
+ readOptionalAccessRule({
8
+ openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
9
+ }),
10
+ ).toEqual({
11
+ openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
12
+ });
13
+ });
14
+
15
+ test("drops an unknown personalData value", () => {
16
+ expect(
17
+ readOptionalAccessRule({
18
+ openToAll: { reason: "signup", personalData: "public-intake" },
19
+ }),
20
+ ).toEqual({ openToAll: { reason: "signup" } });
21
+ });
22
+
23
+ test("the deprecated openToAll: true form carries no personalData", () => {
24
+ expect(readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" })).toEqual({
25
+ openToAll: true,
26
+ });
27
+ });
28
+ });
@@ -42,12 +42,16 @@ export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
42
42
  if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
43
43
  return { roles: value["roles"] as readonly string[] };
44
44
  }
45
- const publicIntake = value["publicIntake"] === true ? { publicIntake: true as const } : {};
46
45
  if (value["openToAll"] === true) {
47
- return { openToAll: true, ...publicIntake };
46
+ return { openToAll: true };
48
47
  }
49
- if (isPlainObject(value["openToAll"]) && typeof value["openToAll"]["reason"] === "string") {
50
- return { openToAll: { reason: value["openToAll"]["reason"] }, ...publicIntake };
48
+ const openToAll = value["openToAll"];
49
+ if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
50
+ const personalData =
51
+ openToAll["personalData"] === "tenant-members"
52
+ ? { personalData: "tenant-members" as const }
53
+ : {};
54
+ return { openToAll: { reason: openToAll["reason"], ...personalData } };
51
55
  }
52
56
  return undefined;
53
57
  }
@@ -1,5 +1,5 @@
1
1
  import type { EntityTableMeta } from "../db/entity-table-meta";
2
- import { bindHookIdentitySwitchGrant } from "../pipeline/system-identity-switch";
2
+ import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
3
  import { LifecycleHookTypes } from "./constants";
4
4
  import type { FeatureBuilderState } from "./feature-builder-state";
5
5
  import { resolveName } from "./handler-helpers";
@@ -143,7 +143,7 @@ export function buildUiExtensionsMethods<TName extends string>(
143
143
  const wrapped: LifecycleHookFn | ValidationHookFn =
144
144
  type === "validation"
145
145
  ? fn
146
- : bindHookIdentitySwitchGrant(fn as LifecycleHookFn, hookLabel, options?.escapeHatch); // @cast-boundary engine-bridge
146
+ : bindHookEscapeHatchGrant(fn as LifecycleHookFn, hookLabel, options?.escapeHatch); // @cast-boundary engine-bridge
147
147
 
148
148
  // Entity-wide target ("all write/query handlers of this entity") —
149
149
  // replaces the old r.entityHook(type, entity, fn).
@@ -4,6 +4,7 @@ export { hasAccess } from "./access";
4
4
  export {
5
5
  isPrincipalStatusPlugin,
6
6
  isTenantLifecycleStatusPlugin,
7
+ type PrincipalProfile,
7
8
  type PrincipalStatus,
8
9
  type PrincipalStatusPlugin,
9
10
  TENANT_TEARDOWN_STATUSES,
@@ -410,6 +411,7 @@ export type {
410
411
  ListSortSpec,
411
412
  LongTextFindability,
412
413
  ManyToManyRelation,
414
+ MemberReader,
413
415
  MetricNavigate,
414
416
  MetricSpec,
415
417
  MspErrorMode,
@@ -432,6 +434,7 @@ export type {
432
434
  OnDeleteStrategy,
433
435
  OpenToAllAccessRule,
434
436
  OpenToAllDeclaration,
437
+ OpenToAllPersonalData,
435
438
  ParentRefDef,
436
439
  PersonalAnnotations,
437
440
  PersonalAnnotationsLongText,
@@ -477,6 +480,7 @@ export type {
477
480
  SecretsEditSection,
478
481
  SelectFieldDef,
479
482
  SessionUser,
483
+ SessionUserOrigin,
480
484
  StreamHandlerDef,
481
485
  StreamHandlerFn,
482
486
  Subscribe,
@@ -1,5 +1,5 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
- import { bindHookIdentitySwitchGrant } from "../pipeline/system-identity-switch";
2
+ import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
3
  import { resolveName } from "./handler-helpers";
4
4
  import type {
5
5
  RegistryState,
@@ -126,10 +126,10 @@ export function applyExtensionUsages(state: RegistryState): void {
126
126
  // RegistrarExtensionRegistration and use that here.
127
127
  const extOwner = "*";
128
128
  // Extensions have no declaration site for escapeHatch, so SYSTEM is always denied
129
- // (system-identity-switch.ts) — bindHookIdentitySwitchGrant(..., undefined) below.
129
+ // (system-identity-switch.ts) — bindHookEscapeHatchGrant(..., undefined) below.
130
130
  if (ext.hooks) {
131
131
  if (ext.hooks.postSave) {
132
- const wrapped = bindHookIdentitySwitchGrant(
132
+ const wrapped = bindHookEscapeHatchGrant(
133
133
  ext.hooks.postSave,
134
134
  `postSave hook of extension "${usage.extensionName}"`,
135
135
  undefined,
@@ -143,7 +143,7 @@ export function applyExtensionUsages(state: RegistryState): void {
143
143
  state.entityPostSaveHooks.set(usage.entityName, existing);
144
144
  }
145
145
  if (ext.hooks.preDelete) {
146
- const wrapped = bindHookIdentitySwitchGrant(
146
+ const wrapped = bindHookEscapeHatchGrant(
147
147
  ext.hooks.preDelete,
148
148
  `preDelete hook of extension "${usage.extensionName}"`,
149
149
  undefined,
@@ -157,7 +157,7 @@ export function applyExtensionUsages(state: RegistryState): void {
157
157
  state.entityPreDeleteHooks.set(usage.entityName, existing);
158
158
  }
159
159
  if (ext.hooks.postDelete) {
160
- const wrapped = bindHookIdentitySwitchGrant(
160
+ const wrapped = bindHookEscapeHatchGrant(
161
161
  ext.hooks.postDelete,
162
162
  `postDelete hook of extension "${usage.extensionName}"`,
163
163
  undefined,
@@ -172,7 +172,7 @@ export function applyExtensionUsages(state: RegistryState): void {
172
172
  }
173
173
  // preSave on extensions: store as handler hook for all CRUD handlers of this entity
174
174
  if (ext.hooks.preSave) {
175
- const wrapped = bindHookIdentitySwitchGrant(
175
+ const wrapped = bindHookEscapeHatchGrant(
176
176
  ext.hooks.preSave,
177
177
  `preSave hook of extension "${usage.extensionName}"`,
178
178
  undefined,
@@ -171,6 +171,7 @@ export type {
171
171
  HandlerRef,
172
172
  JobContext,
173
173
  JobRunnerRef,
174
+ MemberReader,
174
175
  NameOrRef,
175
176
  NotifyFactory,
176
177
  NotifyFn,
@@ -178,6 +179,7 @@ export type {
178
179
  NotifyPriority,
179
180
  OpenToAllAccessRule,
180
181
  OpenToAllDeclaration,
182
+ OpenToAllPersonalData,
181
183
  QualifiedEventName,
182
184
  QueryEvent,
183
185
  QueryHandlerDef,
@@ -185,6 +187,7 @@ export type {
185
187
  RateLimitOption,
186
188
  RateLimitPer,
187
189
  SessionUser,
190
+ SessionUserOrigin,
188
191
  StreamHandlerDef,
189
192
  StreamHandlerFn,
190
193
  TryAppendEventFn,
@@ -38,7 +38,7 @@ import { buildServer, withFileProviderResolver } from "../api/server";
38
38
  import type { SseBroker } from "../api/sse-broker";
39
39
  import type { PgClient } from "../db/connection";
40
40
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
41
- import type { AppContext, DispatchWriteRef, JobRunIn, Registry, RunIn } from "../engine/types";
41
+ import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
42
42
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
43
43
  import { createJobRunner } from "../jobs/job-runner";
44
44
  import type { Lifecycle } from "../lifecycle";
@@ -47,6 +47,7 @@ import type { ObservabilityOptions, ObservabilityProvider } from "../observabili
47
47
  import { createNoopProvider } from "../observability";
48
48
  import type { EventDedup, EventDispatcher } from "../pipeline";
49
49
  import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher";
50
+ import { dispatcherToWriteRef } from "../pipeline/dispatcher";
50
51
  import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
51
52
 
52
53
  // Shared fields across all three modes. A caller that swaps between
@@ -206,18 +207,6 @@ function contextWithObservability(
206
207
  };
207
208
  }
208
209
 
209
- // Adapts the command-dispatcher's positional (type, payload, user) calls to
210
- // DispatchWriteRef's (user, qn, payload) shape — JobContext.write/queryAs
211
- // pass identity explicitly per call (boot-time singleton), while Dispatcher
212
- // takes it last (request-scoped closure caller). Same underlying pipeline,
213
- // different argument order.
214
- function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
215
- return {
216
- write: (user, qn, payload) => dispatcher.write(qn, payload, user),
217
- queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
218
- };
219
- }
220
-
221
210
  // buildApiServer shapes ServerOptions from API-mode caller-options.
222
211
  // AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
223
212
  // subtyping makes the all-in-one path a valid caller without an explicit
@@ -97,6 +97,26 @@ system_identity_switch_denied:
97
97
  (oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
98
98
  oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
99
99
 
100
+ member_resolution_denied:
101
+ endUser: |
102
+ Diese Aktion ist nicht erlaubt.
103
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
104
+ developer: |
105
+ `AccessDeniedError`: `ctx.queryAsMember(userId, ...)` konnte für `userId`
106
+ keine aktive Mitgliedschaft im Tenant des Aufrufers auflösen (kein
107
+ Mitglied, Principal gesperrt, oder der Tenant befindet sich im Teardown).
108
+ Bewusst generisch — `details` verrät nie, welche Prüfung fehlgeschlagen ist.
109
+
110
+ member_resolution_read_only:
111
+ endUser: |
112
+ Diese Aktion ist nicht erlaubt.
113
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
114
+ developer: |
115
+ `AccessDeniedError`: ein über `ctx.queryAsMember` erreichter Query-Handler
116
+ hat versucht zu schreiben (`ctx.write`/`writeAs`/`appendEvent`/
117
+ `jobRunner`/...). Aufgelöste Member-Principals sind nur lesend — es gibt
118
+ kein `writeAsMember`.
119
+
100
120
  agent.tool_not_allowed:
101
121
  endUser: |
102
122
  Dieses Tool ist im aktuellen Modus nicht verfügbar.
@@ -94,6 +94,25 @@ system_identity_switch_denied:
94
94
  (or on the `r.hook(...)` call, if the switch happens inside a hook), or
95
95
  move the logic into an `r.systemScope()` feature or a job.
96
96
 
97
+ member_resolution_denied:
98
+ endUser: |
99
+ This action isn't allowed.
100
+ Please contact an administrator if you believe this is a mistake.
101
+ developer: |
102
+ `AccessDeniedError`: `ctx.queryAsMember(userId, ...)` could not resolve an
103
+ active membership for `userId` in the caller's tenant (not a member,
104
+ principal blocked, or the tenant is in teardown). Deliberately generic —
105
+ `details` never says which check failed.
106
+
107
+ member_resolution_read_only:
108
+ endUser: |
109
+ This action isn't allowed.
110
+ Please contact an administrator if you believe this is a mistake.
111
+ developer: |
112
+ `AccessDeniedError`: a query handler reached via `ctx.queryAsMember` tried
113
+ to write (`ctx.write`/`writeAs`/`appendEvent`/`jobRunner`/...). Resolved
114
+ member principals are read-only — there is no `writeAsMember`.
115
+
97
116
  agent.tool_not_allowed:
98
117
  endUser: |
99
118
  This tool is not available in the current mode.
@@ -42,6 +42,14 @@ export const FrameworkReasons = {
42
42
  // AccessDeniedError: a handler/hook called ctx.queryAs/ctx.writeAs with a
43
43
  // SYSTEM identity without an r.systemScope() feature or a declared escapeHatch.
44
44
  systemIdentitySwitchDenied: "system_identity_switch_denied",
45
+
46
+ // AccessDeniedError: ctx.queryAsMember's userId is not an active member of
47
+ // ctx's tenant. Deliberately generic — a caller can't probe which check failed.
48
+ memberResolutionDenied: "member_resolution_denied",
49
+
50
+ // AccessDeniedError: a query handler invoked via ctx.queryAsMember tried to
51
+ // write — a resolved member principal is read-only by construction.
52
+ memberResolutionReadOnly: "member_resolution_read_only",
45
53
  } as const;
46
54
 
47
55
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
@@ -10,6 +10,7 @@ import {
10
10
  type DispatchWriteRef,
11
11
  type JobContext,
12
12
  type JobRunIn,
13
+ type MemberReader,
13
14
  type Registry,
14
15
  type SessionUser,
15
16
  SYSTEM_TENANT_ID,
@@ -552,6 +553,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
552
553
  tenantId,
553
554
  })
554
555
  : context.derivatives;
556
+ // Per-job-run reader, created lazily on queryAsMember's first call and
557
+ // cached for the rest of this run.
558
+ let memberReader: MemberReader | undefined;
555
559
  const jobContext: JobContext = {
556
560
  ...context,
557
561
  // Same union as configDb above — job runners are always constructed
@@ -597,6 +601,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
597
601
  }
598
602
  return dispatchWriteRef.queryAs(user, qn, payload);
599
603
  },
604
+ queryAsMember: (userId: string, qn: string, payload: unknown) => {
605
+ if (!dispatchWriteRef) {
606
+ throw new Error(
607
+ "JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first",
608
+ );
609
+ }
610
+ // Lazy per-run reader, mirroring the handler-context reader.
611
+ memberReader ??= dispatchWriteRef.createMemberReader(tenantId);
612
+ return memberReader(userId, qn, payload);
613
+ },
600
614
  };
601
615
 
602
616
  await options.onJobStart?.(jobName, jobId, meta);