@cosmicdrift/kumiko-framework 0.266.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 (48) 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 +15 -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__/global-tenancy.test.ts +58 -0
  23. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  24. package/src/engine/boot-validator/index.ts +2 -0
  25. package/src/engine/feature-ui-extensions.ts +2 -2
  26. package/src/engine/index.ts +3 -0
  27. package/src/engine/registry-validate.ts +6 -6
  28. package/src/engine/types/index.ts +2 -0
  29. package/src/entrypoint/index.ts +2 -13
  30. package/src/errors/i18n/de.yaml +20 -0
  31. package/src/errors/i18n/en.yaml +19 -0
  32. package/src/errors/reasons.ts +8 -0
  33. package/src/jobs/job-runner.ts +14 -0
  34. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  35. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  36. package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
  37. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  38. package/src/pipeline/active-membership.ts +3 -1
  39. package/src/pipeline/dispatch-shared.ts +89 -10
  40. package/src/pipeline/dispatch-stream.ts +9 -5
  41. package/src/pipeline/dispatch-write.ts +7 -0
  42. package/src/pipeline/dispatcher-utils.ts +1 -0
  43. package/src/pipeline/dispatcher.ts +18 -0
  44. package/src/pipeline/index.ts +1 -1
  45. package/src/pipeline/member-reader.ts +108 -0
  46. package/src/pipeline/system-identity-switch.ts +73 -13
  47. package/src/stack/test-stack.ts +10 -1
  48. 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";
@@ -37,8 +39,10 @@ export function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedErr
37
39
  });
38
40
  }
39
41
 
40
- // Reverse-lookup so withHookIdentitySwitchGrant can re-gate the SAME ungated pair under a narrower grant.
42
+ // Reverse-lookup so withHookEscapeHatchGrant can re-gate the SAME ungated pair under a narrower grant.
41
43
  const ungatedByGated = new WeakMap<QueryAsFn | WriteAsFn, IdentitySwitch>();
44
+ // Same idea, for ctx.queryAsMember — a single function rather than a pair.
45
+ const ungatedMemberReaderByGated = new WeakMap<MemberReader, MemberReader>();
42
46
 
43
47
  export function createGatedIdentitySwitch(
44
48
  callerLabel: string,
@@ -63,15 +67,39 @@ export function createGatedIdentitySwitch(
63
67
  return gated;
64
68
  }
65
69
 
66
- function readIdentitySwitchFn<TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn>(
70
+ // ctx.queryAsMember's gate: the caller never names the target identity up
71
+ // front (it's resolved internally), so this is a flat allow/deny.
72
+ export function createGatedMemberReader(
73
+ callerLabel: string,
74
+ allowSystemIdentity: boolean,
75
+ ungated: MemberReader,
76
+ ): MemberReader {
77
+ const gated: MemberReader = async (userId, qn, payload) => {
78
+ if (!allowSystemIdentity) throw systemIdentitySwitchDenied(callerLabel);
79
+ return ungated(userId, qn, payload);
80
+ };
81
+ ungatedMemberReaderByGated.set(gated, ungated);
82
+ return gated;
83
+ }
84
+
85
+ function readIdentitySwitchFn<
86
+ TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader,
87
+ >(
67
88
  context: object,
68
- key: "queryAs" | "writeAs" | "resolveActiveMembership",
89
+ key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember",
69
90
  ): TFn | undefined {
70
91
  if (!(key in context)) return undefined;
71
92
  const value = (context as Record<string, unknown>)[key];
72
93
  return typeof value === "function" ? (value as TFn) : undefined; // @cast-boundary engine-bridge — checked via typeof above
73
94
  }
74
95
 
96
+ // context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
97
+ function readDbLikeValue(context: object, key: "db" | "dbOutsideTransaction"): object | undefined {
98
+ if (!(key in context)) return undefined;
99
+ const value = (context as Record<string, unknown>)[key];
100
+ return typeof value === "object" && value !== null ? value : undefined;
101
+ }
102
+
75
103
  function unavailableIdentitySwitchFn(callerLabel: string, kind: "queryAs" | "writeAs") {
76
104
  return async () => {
77
105
  throw new InternalError({
@@ -98,7 +126,8 @@ function fallbackUngatedIdentitySwitch(
98
126
  };
99
127
  }
100
128
 
101
- // Reuses the ORIGINAL ungated pair when known, so this grant doesn't compose with the caller's.
129
+ // Resolved per function: a shared ungated pair would revive a member-resolution
130
+ // ctx's deny-stubbed writeAs through its still-registered queryAs.
102
131
  function gatedIdentitySwitchFields(
103
132
  callerLabel: string,
104
133
  escapeHatch: EscapeHatchDeclaration | undefined,
@@ -106,10 +135,9 @@ function gatedIdentitySwitchFields(
106
135
  ctxWriteAs: WriteAsFn | undefined,
107
136
  ): Partial<IdentitySwitch> {
108
137
  if (!ctxQueryAs && !ctxWriteAs) return {};
109
- const ungated: IdentitySwitch =
110
- (ctxQueryAs && ungatedByGated.get(ctxQueryAs)) ??
111
- (ctxWriteAs && ungatedByGated.get(ctxWriteAs)) ??
112
- fallbackUngatedIdentitySwitch(callerLabel, ctxQueryAs, ctxWriteAs);
138
+ const ungatedQueryAs = ctxQueryAs && (ungatedByGated.get(ctxQueryAs)?.queryAs ?? ctxQueryAs);
139
+ const ungatedWriteAs = ctxWriteAs && (ungatedByGated.get(ctxWriteAs)?.writeAs ?? ctxWriteAs);
140
+ const ungated = fallbackUngatedIdentitySwitch(callerLabel, ungatedQueryAs, ungatedWriteAs);
113
141
  const gated = createGatedIdentitySwitch(callerLabel, escapeHatch !== undefined, ungated);
114
142
  return {
115
143
  ...(ctxQueryAs && { queryAs: gated.queryAs }),
@@ -117,7 +145,21 @@ function gatedIdentitySwitchFields(
117
145
  };
118
146
  }
119
147
 
120
- export function withHookIdentitySwitchGrant<TContext extends object>(
148
+ // Unlike ctx.resolveActiveMembership (deny-only), a hook's own escapeHatch
149
+ // can grant queryAsMember even when the enclosing handler has none.
150
+ function gatedMemberReaderField(
151
+ callerLabel: string,
152
+ escapeHatch: EscapeHatchDeclaration | undefined,
153
+ ctxQueryAsMember: MemberReader | undefined,
154
+ ): { queryAsMember?: MemberReader } {
155
+ if (!ctxQueryAsMember) return {};
156
+ const ungated = ungatedMemberReaderByGated.get(ctxQueryAsMember) ?? ctxQueryAsMember;
157
+ return {
158
+ queryAsMember: createGatedMemberReader(callerLabel, escapeHatch !== undefined, ungated),
159
+ };
160
+ }
161
+
162
+ export function withHookEscapeHatchGrant<TContext extends object>(
121
163
  context: TContext,
122
164
  callerLabel: string,
123
165
  escapeHatch: EscapeHatchDeclaration | undefined,
@@ -128,7 +170,19 @@ export function withHookIdentitySwitchGrant<TContext extends object>(
128
170
  context,
129
171
  "resolveActiveMembership",
130
172
  );
131
- if (!ctxQueryAs && !ctxWriteAs && !ctxResolveActiveMembership) return context;
173
+ const ctxQueryAsMember = readIdentitySwitchFn<MemberReader>(context, "queryAsMember");
174
+ const ctxDb = readDbLikeValue(context, "db");
175
+ const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
176
+ if (
177
+ !ctxQueryAs &&
178
+ !ctxWriteAs &&
179
+ !ctxResolveActiveMembership &&
180
+ !ctxQueryAsMember &&
181
+ !ctxDb &&
182
+ !ctxDbOutsideTransaction
183
+ ) {
184
+ return context;
185
+ }
132
186
 
133
187
  return {
134
188
  ...context,
@@ -137,11 +191,17 @@ export function withHookIdentitySwitchGrant<TContext extends object>(
137
191
  escapeHatch === undefined && {
138
192
  resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
139
193
  }),
194
+ ...gatedMemberReaderField(callerLabel, escapeHatch, ctxQueryAsMember),
195
+ // @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
196
+ ...(ctxDb && { db: withUnsafeRawGrant(ctxDb as TenantDb, escapeHatch) }),
197
+ ...(ctxDbOutsideTransaction && {
198
+ dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
199
+ }),
140
200
  };
141
201
  }
142
202
 
143
- // Re-gates a hook's own ctx.queryAs/ctx.writeAs instead of inheriting the handler's grant.
144
- export function bindHookIdentitySwitchGrant(
203
+ // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
204
+ export function bindHookEscapeHatchGrant(
145
205
  fn: LifecycleHookFn,
146
206
  label: string,
147
207
  escapeHatch: EscapeHatchDeclaration | undefined,
@@ -149,6 +209,6 @@ export function bindHookIdentitySwitchGrant(
149
209
  return ((payload: unknown, context: object) =>
150
210
  (fn as (payload: unknown, context: object) => unknown)(
151
211
  payload,
152
- withHookIdentitySwitchGrant(context, label, escapeHatch),
212
+ withHookEscapeHatchGrant(context, label, escapeHatch),
153
213
  )) as LifecycleHookFn; // @cast-boundary engine-bridge — LifecycleHookFn union, same (payload, context) shape at runtime
154
214
  }
@@ -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,