@cosmicdrift/kumiko-framework 0.266.0 → 0.269.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 (56) 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/__tests__/stream-escape-hatch.integration.test.ts +203 -0
  11. package/src/api/jwt.ts +8 -0
  12. package/src/changes.json +28 -0
  13. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  14. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  15. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  16. package/src/db/entity-table-meta.ts +7 -0
  17. package/src/db/event-store-executor-context.ts +19 -0
  18. package/src/db/event-store-executor-write.ts +16 -0
  19. package/src/db/index.ts +3 -1
  20. package/src/db/table-builder.ts +27 -0
  21. package/src/db/tenant-db.ts +83 -10
  22. package/src/engine/__tests__/extends-registrar.test.ts +1 -0
  23. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +4 -2
  24. package/src/engine/active-membership.ts +15 -1
  25. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +37 -0
  26. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  27. package/src/engine/boot-validator/access-declarations.ts +6 -1
  28. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  29. package/src/engine/boot-validator/index.ts +2 -0
  30. package/src/engine/feature-entity-handlers.ts +7 -1
  31. package/src/engine/feature-ui-extensions.ts +2 -2
  32. package/src/engine/index.ts +3 -0
  33. package/src/engine/registry-validate.ts +6 -6
  34. package/src/engine/steps/call-feature.ts +1 -1
  35. package/src/engine/types/index.ts +2 -0
  36. package/src/entrypoint/index.ts +2 -13
  37. package/src/errors/i18n/de.yaml +36 -0
  38. package/src/errors/i18n/en.yaml +34 -0
  39. package/src/errors/reasons.ts +13 -0
  40. package/src/jobs/job-runner.ts +14 -0
  41. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  42. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  43. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +201 -8
  44. package/src/pipeline/__tests__/system-identity-switch.test.ts +244 -33
  45. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  46. package/src/pipeline/active-membership.ts +3 -1
  47. package/src/pipeline/dispatch-shared.ts +107 -20
  48. package/src/pipeline/dispatch-stream.ts +9 -5
  49. package/src/pipeline/dispatch-write.ts +7 -0
  50. package/src/pipeline/dispatcher-utils.ts +1 -0
  51. package/src/pipeline/dispatcher.ts +18 -0
  52. package/src/pipeline/index.ts +1 -1
  53. package/src/pipeline/member-reader.ts +108 -0
  54. package/src/pipeline/system-identity-switch.ts +142 -25
  55. package/src/stack/test-stack.ts +10 -1
  56. package/src/testing/handler-context.ts +3 -0
@@ -1,8 +1,10 @@
1
+ import { type TenantDb, withUnsafeRawGrant } from "../db/tenant-db";
1
2
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
2
3
  import type {
3
4
  ActiveMembershipResult,
4
5
  EscapeHatchDeclaration,
5
6
  LifecycleHookFn,
7
+ MemberReader,
6
8
  SessionUser,
7
9
  WriteResult,
8
10
  } from "../engine/types";
@@ -21,11 +23,38 @@ export function isSystemIdentity(user: SessionUser): boolean {
21
23
  return user.id === SYSTEM_USER_ID || user.roles.includes(SYSTEM_ROLE);
22
24
  }
23
25
 
24
- export function isSystemIdentitySwitchAllowed(
26
+ function hasSameClaims(caller: SessionUser, asUser: SessionUser): boolean {
27
+ if (caller.claims === asUser.claims) return true;
28
+ const callerClaims = caller.claims ?? {};
29
+ const asUserClaims = asUser.claims ?? {};
30
+ const keys = Object.keys(callerClaims);
31
+ if (keys.length !== Object.keys(asUserClaims).length) return false;
32
+ return keys.every(
33
+ (key) =>
34
+ Object.hasOwn(asUserClaims, key) &&
35
+ JSON.stringify(callerClaims[key]) === JSON.stringify(asUserClaims[key]),
36
+ );
37
+ }
38
+
39
+ // claims drive ownership row filters and origin makes a ctx read-only, so both must match exactly.
40
+ export function isSelfDelegation(caller: SessionUser, asUser: SessionUser): boolean {
41
+ return (
42
+ asUser.id === caller.id &&
43
+ asUser.tenantId === caller.tenantId &&
44
+ asUser.origin === caller.origin &&
45
+ asUser.roles.every((role) => caller.roles.includes(role)) &&
46
+ hasSameClaims(caller, asUser)
47
+ );
48
+ }
49
+
50
+ export function isIdentitySwitchAllowed(
51
+ caller: SessionUser | undefined,
25
52
  asUser: SessionUser,
26
- allowSystemIdentity: boolean,
53
+ hasGrant: boolean,
27
54
  ): boolean {
28
- return !isSystemIdentity(asUser) || allowSystemIdentity;
55
+ if (hasGrant) return true;
56
+ if (isSystemIdentity(asUser) || caller === undefined) return false;
57
+ return isSelfDelegation(caller, asUser);
29
58
  }
30
59
 
31
60
  export function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedError {
@@ -37,41 +66,84 @@ export function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedErr
37
66
  });
38
67
  }
39
68
 
40
- // Reverse-lookup so withHookIdentitySwitchGrant can re-gate the SAME ungated pair under a narrower grant.
41
- const ungatedByGated = new WeakMap<QueryAsFn | WriteAsFn, IdentitySwitch>();
69
+ export function identitySwitchDenied(callerLabel: string, asUser: SessionUser): AccessDeniedError {
70
+ if (isSystemIdentity(asUser)) return systemIdentitySwitchDenied(callerLabel);
71
+ return new AccessDeniedError({
72
+ message:
73
+ `${callerLabel} may only switch identity to its own caller (same user, tenant, claims and ` +
74
+ "a subset of its roles) — declare r.systemScope() or escapeHatch: { reason } on it",
75
+ details: { reason: FrameworkReasons.identitySwitchDenied },
76
+ });
77
+ }
78
+
79
+ type GatedIdentitySwitchSource = {
80
+ readonly ungated: IdentitySwitch;
81
+ readonly caller: SessionUser | undefined;
82
+ };
83
+
84
+ // Reverse-lookup so withHookEscapeHatchGrant can re-gate the SAME ungated pair (and caller) under a narrower grant.
85
+ const sourceByGated = new WeakMap<QueryAsFn | WriteAsFn, GatedIdentitySwitchSource>();
86
+ // Same idea, for ctx.queryAsMember — a single function rather than a pair.
87
+ const ungatedMemberReaderByGated = new WeakMap<MemberReader, MemberReader>();
42
88
 
43
89
  export function createGatedIdentitySwitch(
44
90
  callerLabel: string,
45
- allowSystemIdentity: boolean,
91
+ caller: SessionUser | undefined,
92
+ hasGrant: boolean,
46
93
  ungated: IdentitySwitch,
47
94
  ): IdentitySwitch {
48
95
  const queryAs: QueryAsFn = async (asUser, qn, payload) => {
49
- if (!isSystemIdentitySwitchAllowed(asUser, allowSystemIdentity)) {
50
- throw systemIdentitySwitchDenied(callerLabel);
96
+ if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
97
+ throw identitySwitchDenied(callerLabel, asUser);
51
98
  }
52
99
  return ungated.queryAs(asUser, qn, payload);
53
100
  };
54
101
  const writeAs: WriteAsFn = async (asUser, qn, payload) => {
55
- if (!isSystemIdentitySwitchAllowed(asUser, allowSystemIdentity)) {
56
- throw systemIdentitySwitchDenied(callerLabel);
102
+ if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
103
+ throw identitySwitchDenied(callerLabel, asUser);
57
104
  }
58
105
  return ungated.writeAs(asUser, qn, payload);
59
106
  };
60
107
  const gated: IdentitySwitch = { queryAs, writeAs };
61
- ungatedByGated.set(queryAs, ungated);
62
- ungatedByGated.set(writeAs, ungated);
108
+ const source: GatedIdentitySwitchSource = { ungated, caller };
109
+ sourceByGated.set(queryAs, source);
110
+ sourceByGated.set(writeAs, source);
111
+ return gated;
112
+ }
113
+
114
+ // ctx.queryAsMember's gate: the caller never names the target identity up
115
+ // front (it's resolved internally), so this is a flat allow/deny.
116
+ export function createGatedMemberReader(
117
+ callerLabel: string,
118
+ allowSystemIdentity: boolean,
119
+ ungated: MemberReader,
120
+ ): MemberReader {
121
+ const gated: MemberReader = async (userId, qn, payload) => {
122
+ if (!allowSystemIdentity) throw systemIdentitySwitchDenied(callerLabel);
123
+ return ungated(userId, qn, payload);
124
+ };
125
+ ungatedMemberReaderByGated.set(gated, ungated);
63
126
  return gated;
64
127
  }
65
128
 
66
- function readIdentitySwitchFn<TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn>(
129
+ function readIdentitySwitchFn<
130
+ TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader,
131
+ >(
67
132
  context: object,
68
- key: "queryAs" | "writeAs" | "resolveActiveMembership",
133
+ key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember",
69
134
  ): TFn | undefined {
70
135
  if (!(key in context)) return undefined;
71
136
  const value = (context as Record<string, unknown>)[key];
72
137
  return typeof value === "function" ? (value as TFn) : undefined; // @cast-boundary engine-bridge — checked via typeof above
73
138
  }
74
139
 
140
+ // context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
141
+ function readDbLikeValue(context: object, key: "db" | "dbOutsideTransaction"): object | undefined {
142
+ if (!(key in context)) return undefined;
143
+ const value = (context as Record<string, unknown>)[key];
144
+ return typeof value === "object" && value !== null ? value : undefined;
145
+ }
146
+
75
147
  function unavailableIdentitySwitchFn(callerLabel: string, kind: "queryAs" | "writeAs") {
76
148
  return async () => {
77
149
  throw new InternalError({
@@ -98,7 +170,18 @@ function fallbackUngatedIdentitySwitch(
98
170
  };
99
171
  }
100
172
 
101
- // Reuses the ORIGINAL ungated pair when known, so this grant doesn't compose with the caller's.
173
+ // Only a dispatcher-registered gate names the caller; ctx.user is never trusted, and two
174
+ // gates for different callers (a hand-mixed ctx) yield none, so only a grant passes.
175
+ function resolveDispatcherCaller(
176
+ querySource: GatedIdentitySwitchSource | undefined,
177
+ writeSource: GatedIdentitySwitchSource | undefined,
178
+ ): SessionUser | undefined {
179
+ if (querySource && writeSource && querySource.caller !== writeSource.caller) return undefined;
180
+ return (querySource ?? writeSource)?.caller;
181
+ }
182
+
183
+ // Resolved per function: a shared ungated pair would revive a member-resolution
184
+ // ctx's deny-stubbed writeAs through its still-registered queryAs.
102
185
  function gatedIdentitySwitchFields(
103
186
  callerLabel: string,
104
187
  escapeHatch: EscapeHatchDeclaration | undefined,
@@ -106,18 +189,34 @@ function gatedIdentitySwitchFields(
106
189
  ctxWriteAs: WriteAsFn | undefined,
107
190
  ): Partial<IdentitySwitch> {
108
191
  if (!ctxQueryAs && !ctxWriteAs) return {};
109
- const ungated: IdentitySwitch =
110
- (ctxQueryAs && ungatedByGated.get(ctxQueryAs)) ??
111
- (ctxWriteAs && ungatedByGated.get(ctxWriteAs)) ??
112
- fallbackUngatedIdentitySwitch(callerLabel, ctxQueryAs, ctxWriteAs);
113
- const gated = createGatedIdentitySwitch(callerLabel, escapeHatch !== undefined, ungated);
192
+ const querySource = ctxQueryAs && sourceByGated.get(ctxQueryAs);
193
+ const writeSource = ctxWriteAs && sourceByGated.get(ctxWriteAs);
194
+ const ungatedQueryAs = ctxQueryAs && (querySource?.ungated.queryAs ?? ctxQueryAs);
195
+ const ungatedWriteAs = ctxWriteAs && (writeSource?.ungated.writeAs ?? ctxWriteAs);
196
+ const ungated = fallbackUngatedIdentitySwitch(callerLabel, ungatedQueryAs, ungatedWriteAs);
197
+ const caller = resolveDispatcherCaller(querySource, writeSource);
198
+ const gated = createGatedIdentitySwitch(callerLabel, caller, escapeHatch !== undefined, ungated);
114
199
  return {
115
200
  ...(ctxQueryAs && { queryAs: gated.queryAs }),
116
201
  ...(ctxWriteAs && { writeAs: gated.writeAs }),
117
202
  };
118
203
  }
119
204
 
120
- export function withHookIdentitySwitchGrant<TContext extends object>(
205
+ // Unlike ctx.resolveActiveMembership (deny-only), a hook's own escapeHatch
206
+ // can grant queryAsMember even when the enclosing handler has none.
207
+ function gatedMemberReaderField(
208
+ callerLabel: string,
209
+ escapeHatch: EscapeHatchDeclaration | undefined,
210
+ ctxQueryAsMember: MemberReader | undefined,
211
+ ): { queryAsMember?: MemberReader } {
212
+ if (!ctxQueryAsMember) return {};
213
+ const ungated = ungatedMemberReaderByGated.get(ctxQueryAsMember) ?? ctxQueryAsMember;
214
+ return {
215
+ queryAsMember: createGatedMemberReader(callerLabel, escapeHatch !== undefined, ungated),
216
+ };
217
+ }
218
+
219
+ export function withHookEscapeHatchGrant<TContext extends object>(
121
220
  context: TContext,
122
221
  callerLabel: string,
123
222
  escapeHatch: EscapeHatchDeclaration | undefined,
@@ -128,7 +227,19 @@ export function withHookIdentitySwitchGrant<TContext extends object>(
128
227
  context,
129
228
  "resolveActiveMembership",
130
229
  );
131
- if (!ctxQueryAs && !ctxWriteAs && !ctxResolveActiveMembership) return context;
230
+ const ctxQueryAsMember = readIdentitySwitchFn<MemberReader>(context, "queryAsMember");
231
+ const ctxDb = readDbLikeValue(context, "db");
232
+ const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
233
+ if (
234
+ !ctxQueryAs &&
235
+ !ctxWriteAs &&
236
+ !ctxResolveActiveMembership &&
237
+ !ctxQueryAsMember &&
238
+ !ctxDb &&
239
+ !ctxDbOutsideTransaction
240
+ ) {
241
+ return context;
242
+ }
132
243
 
133
244
  return {
134
245
  ...context,
@@ -137,11 +248,17 @@ export function withHookIdentitySwitchGrant<TContext extends object>(
137
248
  escapeHatch === undefined && {
138
249
  resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
139
250
  }),
251
+ ...gatedMemberReaderField(callerLabel, escapeHatch, ctxQueryAsMember),
252
+ // @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
253
+ ...(ctxDb && { db: withUnsafeRawGrant(ctxDb as TenantDb, escapeHatch) }),
254
+ ...(ctxDbOutsideTransaction && {
255
+ dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
256
+ }),
140
257
  };
141
258
  }
142
259
 
143
- // Re-gates a hook's own ctx.queryAs/ctx.writeAs instead of inheriting the handler's grant.
144
- export function bindHookIdentitySwitchGrant(
260
+ // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
261
+ export function bindHookEscapeHatchGrant(
145
262
  fn: LifecycleHookFn,
146
263
  label: string,
147
264
  escapeHatch: EscapeHatchDeclaration | undefined,
@@ -149,6 +266,6 @@ export function bindHookIdentitySwitchGrant(
149
266
  return ((payload: unknown, context: object) =>
150
267
  (fn as (payload: unknown, context: object) => unknown)(
151
268
  payload,
152
- withHookIdentitySwitchGrant(context, label, escapeHatch),
269
+ withHookEscapeHatchGrant(context, label, escapeHatch),
153
270
  )) as LifecycleHookFn; // @cast-boundary engine-bridge — LifecycleHookFn union, same (payload, context) shape at runtime
154
271
  }
@@ -13,7 +13,12 @@ import { createJobRunner, type JobRunner, type JobRunnerOptions } from "../jobs"
13
13
  import type { Lifecycle } from "../lifecycle";
14
14
  import { createNoopProvider, type ObservabilityProvider } from "../observability";
15
15
  import type { Dispatcher, EventDispatcher } from "../pipeline";
16
- import { createEntityCache, createEventDedup, createIdempotencyGuard } from "../pipeline";
16
+ import {
17
+ createEntityCache,
18
+ createEventDedup,
19
+ createIdempotencyGuard,
20
+ dispatcherToWriteRef,
21
+ } from "../pipeline";
17
22
  import { createInMemorySearchAdapter } from "../search";
18
23
  import type { SearchAdapter } from "../search/types";
19
24
  import { createTestDb } from "./db";
@@ -446,6 +451,10 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
446
451
  })()),
447
452
  });
448
453
 
454
+ // Mirrors the prod entrypoints — without this, JobContext.write/writeAs/
455
+ // queryAs/queryAsMember throw on first use inside a test-stack job.
456
+ if (jobRunner) jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
457
+
449
458
  const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
450
459
 
451
460
  // Pre-register consumer state rows so tests can call runOnce() directly
@@ -9,6 +9,7 @@ import type {
9
9
  AppendEventArgs,
10
10
  FetchForWritingArgs,
11
11
  HandlerContext,
12
+ MemberReader,
12
13
  SessionUser,
13
14
  WriteResult,
14
15
  } from "../engine/types";
@@ -58,6 +59,7 @@ export function bridgeStub(opts?: {
58
59
  | "queryProjection"
59
60
  | "resolveAuthClaims"
60
61
  | "resolveActiveMembership"
62
+ | "queryAsMember"
61
63
  | "hasFeature"
62
64
  | "metrics"
63
65
  | "metricsFor"
@@ -122,6 +124,7 @@ export function bridgeStub(opts?: {
122
124
  resolveActiveMembership: notAvailable(
123
125
  "resolveActiveMembership",
124
126
  ) as unknown as HandlerContext["resolveActiveMembership"],
127
+ queryAsMember: notAvailable("queryAsMember") as unknown as MemberReader,
125
128
  // Stub defaults to always-enabled — matches the dispatcher's behaviour
126
129
  // when no effectiveFeatures resolver is wired (tests without toggles).
127
130
  hasFeature: async () => true,