@cosmicdrift/kumiko-framework 0.263.0 → 0.264.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/changes.json +7 -0
- package/src/engine/__tests__/extends-registrar.test.ts +76 -5
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +105 -0
- package/src/engine/__tests__/hook-phases.test.ts +51 -11
- package/src/engine/__tests__/post-query-hook.test.ts +21 -6
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +26 -0
- package/src/engine/boot-validator/access-declarations.ts +7 -5
- package/src/engine/feature-entity-handlers.ts +3 -0
- package/src/engine/feature-ui-extensions.ts +38 -5
- package/src/engine/registry-validate.ts +37 -6
- package/src/errors/i18n/de.yaml +13 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/jobs/__tests__/job-write-as.integration.test.ts +47 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +8 -2
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +352 -0
- package/src/pipeline/__tests__/system-identity-switch.test.ts +195 -0
- package/src/pipeline/dispatch-shared.ts +28 -16
- package/src/pipeline/system-identity-switch.ts +123 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID } from "../../engine";
|
|
3
|
+
import type { SessionUser } from "../../engine/types";
|
|
4
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
5
|
+
import { AccessDeniedError, FrameworkReasons } from "../../errors";
|
|
6
|
+
import {
|
|
7
|
+
createGatedIdentitySwitch,
|
|
8
|
+
type IdentitySwitch,
|
|
9
|
+
isSystemIdentity,
|
|
10
|
+
isSystemIdentitySwitchAllowed,
|
|
11
|
+
withHookIdentitySwitchGrant,
|
|
12
|
+
} from "../system-identity-switch";
|
|
13
|
+
|
|
14
|
+
const TENANT = "00000000-0000-4000-8000-00000000ab01" as TenantId;
|
|
15
|
+
|
|
16
|
+
const normalUser: SessionUser = { id: crypto.randomUUID(), tenantId: TENANT, roles: ["User"] };
|
|
17
|
+
const systemUserById: SessionUser = { id: SYSTEM_USER_ID, tenantId: TENANT, roles: ["User"] };
|
|
18
|
+
const systemUserByRole: SessionUser = {
|
|
19
|
+
id: crypto.randomUUID(),
|
|
20
|
+
tenantId: TENANT,
|
|
21
|
+
roles: [SYSTEM_ROLE],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function makeUngated(): {
|
|
25
|
+
readonly ungated: IdentitySwitch;
|
|
26
|
+
readonly queryAsMock: ReturnType<typeof mock>;
|
|
27
|
+
readonly writeAsMock: ReturnType<typeof mock>;
|
|
28
|
+
} {
|
|
29
|
+
const queryAsMock = mock(async (_user: SessionUser, _qn: string, _payload: unknown) => ({
|
|
30
|
+
ok: true,
|
|
31
|
+
}));
|
|
32
|
+
const writeAsMock = mock(async (_user: SessionUser, _qn: string, _payload: unknown) => ({
|
|
33
|
+
isSuccess: true as const,
|
|
34
|
+
data: { ok: true },
|
|
35
|
+
}));
|
|
36
|
+
return {
|
|
37
|
+
ungated: { queryAs: queryAsMock, writeAs: writeAsMock },
|
|
38
|
+
queryAsMock,
|
|
39
|
+
writeAsMock,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("isSystemIdentity", () => {
|
|
44
|
+
test("SYSTEM_USER_ID alone counts as SYSTEM", () => {
|
|
45
|
+
expect(isSystemIdentity(systemUserById)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("SYSTEM_ROLE alone counts as SYSTEM", () => {
|
|
49
|
+
expect(isSystemIdentity(systemUserByRole)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a normal user is not SYSTEM", () => {
|
|
53
|
+
expect(isSystemIdentity(normalUser)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("createSystemUser(...) is SYSTEM", () => {
|
|
57
|
+
expect(isSystemIdentity(createSystemUser(TENANT))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isSystemIdentitySwitchAllowed", () => {
|
|
62
|
+
test("SYSTEM target requires allowSystemIdentity=true", () => {
|
|
63
|
+
expect(isSystemIdentitySwitchAllowed(systemUserById, false)).toBe(false);
|
|
64
|
+
expect(isSystemIdentitySwitchAllowed(systemUserById, true)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a non-SYSTEM target is always allowed", () => {
|
|
68
|
+
expect(isSystemIdentitySwitchAllowed(normalUser, false)).toBe(true);
|
|
69
|
+
expect(isSystemIdentitySwitchAllowed(normalUser, true)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("createGatedIdentitySwitch", () => {
|
|
74
|
+
test("queryAs to SYSTEM throws AccessDeniedError with the reason code, no asUser/tenantId/payload leak", async () => {
|
|
75
|
+
const { ungated } = makeUngated();
|
|
76
|
+
const gated = createGatedIdentitySwitch('handler "x"', false, ungated);
|
|
77
|
+
|
|
78
|
+
let caught: unknown;
|
|
79
|
+
try {
|
|
80
|
+
await gated.queryAs(systemUserById, "some:target", { secret: "payload-value" });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
caught = err;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(caught).toBeInstanceOf(AccessDeniedError);
|
|
86
|
+
const error = caught as AccessDeniedError;
|
|
87
|
+
expect(error.details).toEqual({ reason: FrameworkReasons.systemIdentitySwitchDenied });
|
|
88
|
+
expect(error.message).not.toContain(systemUserById.id);
|
|
89
|
+
expect(error.message).not.toContain(TENANT);
|
|
90
|
+
expect(error.message).not.toContain("payload-value");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("writeAs to SYSTEM throws the same way (never falls back to a WriteResult failure)", async () => {
|
|
94
|
+
const { ungated } = makeUngated();
|
|
95
|
+
const gated = createGatedIdentitySwitch('handler "x"', false, ungated);
|
|
96
|
+
|
|
97
|
+
await expect(gated.writeAs(systemUserById, "some:target", {})).rejects.toBeInstanceOf(
|
|
98
|
+
AccessDeniedError,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("allowSystemIdentity=true lets a SYSTEM switch pass through to the ungated pair", async () => {
|
|
103
|
+
const { ungated, queryAsMock, writeAsMock } = makeUngated();
|
|
104
|
+
const gated = createGatedIdentitySwitch('handler "x"', true, ungated);
|
|
105
|
+
|
|
106
|
+
await gated.queryAs(systemUserById, "q", { a: 1 });
|
|
107
|
+
await gated.writeAs(systemUserById, "w", { b: 2 });
|
|
108
|
+
|
|
109
|
+
expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", { a: 1 });
|
|
110
|
+
expect(writeAsMock).toHaveBeenCalledWith(systemUserById, "w", { b: 2 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a non-system asUser always passes through, regardless of allowSystemIdentity", async () => {
|
|
114
|
+
const { ungated, queryAsMock } = makeUngated();
|
|
115
|
+
const deniedByDefault = createGatedIdentitySwitch('handler "x"', false, ungated);
|
|
116
|
+
|
|
117
|
+
await deniedByDefault.queryAs(normalUser, "q", {});
|
|
118
|
+
|
|
119
|
+
expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("withHookIdentitySwitchGrant", () => {
|
|
124
|
+
test("hook without escapeHatch does NOT inherit the handler's SYSTEM grant", async () => {
|
|
125
|
+
const { ungated } = makeUngated();
|
|
126
|
+
// Simulates dispatch-shared.ts's buildHandlerContext for a handler that
|
|
127
|
+
// itself declared escapeHatch / is systemScope (allowSystemIdentity=true).
|
|
128
|
+
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated) };
|
|
129
|
+
|
|
130
|
+
const hookCtx = withHookIdentitySwitchGrant(
|
|
131
|
+
handlerCtx,
|
|
132
|
+
'postSave hook of feature "f"',
|
|
133
|
+
undefined,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
|
|
137
|
+
AccessDeniedError,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("hook WITH escapeHatch is allowed even when the handler's own grant was false", async () => {
|
|
142
|
+
const { ungated, queryAsMock } = makeUngated();
|
|
143
|
+
// Handler itself has NO escapeHatch (allowSystemIdentity=false).
|
|
144
|
+
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
|
|
145
|
+
|
|
146
|
+
const hookCtx = withHookIdentitySwitchGrant(handlerCtx, 'postSave hook of feature "f"', {
|
|
147
|
+
reason: "test hook needs SYSTEM",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await hookCtx.queryAs(systemUserById, "q", { a: 1 });
|
|
151
|
+
// Reaches the REAL ungated fn — not the handler's own (still-denying) gate.
|
|
152
|
+
expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", { a: 1 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a non-system asUser is always allowed through the hook's own gate", async () => {
|
|
156
|
+
const { ungated, queryAsMock } = makeUngated();
|
|
157
|
+
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
|
|
158
|
+
const hookCtx = withHookIdentitySwitchGrant(handlerCtx, "hook", undefined);
|
|
159
|
+
|
|
160
|
+
await hookCtx.queryAs(normalUser, "q", {});
|
|
161
|
+
expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("spreading the context ({...ctx}) still resolves back to the ungated pair", async () => {
|
|
165
|
+
const { ungated, queryAsMock } = makeUngated();
|
|
166
|
+
const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated), extra: 1 };
|
|
167
|
+
const spread = { ...handlerCtx };
|
|
168
|
+
|
|
169
|
+
const hookCtx = withHookIdentitySwitchGrant(spread, "hook", { reason: "needs system" });
|
|
170
|
+
await hookCtx.queryAs(systemUserById, "q", {});
|
|
171
|
+
|
|
172
|
+
expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", {});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a context without queryAs/writeAs is returned unchanged", () => {
|
|
176
|
+
const plainContext = { db: {} };
|
|
177
|
+
const result = withHookIdentitySwitchGrant(plainContext, "hook", { reason: "x" });
|
|
178
|
+
expect(result).toBe(plainContext);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("an ungated context not built by the dispatcher bridge fails closed (unit-test stub)", async () => {
|
|
182
|
+
const stubQueryAs = mock(async (_user: SessionUser, _qn: string, _payload: unknown) => "ok");
|
|
183
|
+
const stubContext = { queryAs: stubQueryAs };
|
|
184
|
+
|
|
185
|
+
const hookCtx = withHookIdentitySwitchGrant(stubContext, "hook", undefined);
|
|
186
|
+
await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
|
|
187
|
+
AccessDeniedError,
|
|
188
|
+
);
|
|
189
|
+
expect(stubQueryAs).not.toHaveBeenCalled();
|
|
190
|
+
|
|
191
|
+
const allowedHookCtx = withHookIdentitySwitchGrant(stubContext, "hook", { reason: "x" });
|
|
192
|
+
await allowedHookCtx.queryAs(systemUserById, "q", {});
|
|
193
|
+
expect(stubQueryAs).toHaveBeenCalledWith(systemUserById, "q", {});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from "./dispatcher-utils";
|
|
76
76
|
import type { IdempotencyGuard } from "./idempotency";
|
|
77
77
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
78
|
+
import { createGatedIdentitySwitch } from "./system-identity-switch";
|
|
78
79
|
import type { TenantTimezoneCache } from "./tenant-timezone-cache";
|
|
79
80
|
|
|
80
81
|
// Framework/pipeline stays bundled-features-free, so this can't import the
|
|
@@ -208,6 +209,11 @@ export async function buildHandlerContext(
|
|
|
208
209
|
const reqCtx = requestContext.get();
|
|
209
210
|
// db.global()'s write-gate — undefined for query/stream handlers, which can't declare escapeHatch.
|
|
210
211
|
const escapeHatch = registry.getWriteHandler(type)?.escapeHatch;
|
|
212
|
+
// Broader than `escapeHatch` above — a query handler can't reach db.global() but can still opt in here.
|
|
213
|
+
const allowSystemIdentity =
|
|
214
|
+
isSystem ||
|
|
215
|
+
(registry.getWriteHandler(type)?.escapeHatch ?? registry.getQueryHandler(type)?.escapeHatch) !==
|
|
216
|
+
undefined;
|
|
211
217
|
const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
|
|
212
218
|
createTenantDb(
|
|
213
219
|
source,
|
|
@@ -304,29 +310,27 @@ export async function buildHandlerContext(
|
|
|
304
310
|
? (targetFeatureName: string) => createSafeMetricsHandle(meter, targetFeatureName)
|
|
305
311
|
: () => createNoopMetricsHandle();
|
|
306
312
|
|
|
307
|
-
// Cross-feature bridge.
|
|
308
|
-
//
|
|
309
|
-
// - the current afterCommitHooks sink — deferred side-effects fire once
|
|
310
|
-
// when the outermost transaction commits
|
|
311
|
-
// `queryAs` / `writeAs` let a handler explicitly switch identity
|
|
312
|
-
// (e.g. system-privileged lookups that bypass field-access read filters).
|
|
313
|
+
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
314
|
+
// queryAs/writeAs switch identity; SYSTEM needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
313
315
|
const bridgeSink = afterCommitHooks ?? [];
|
|
314
316
|
const scheduleAfterCommit = (hook: AfterCommitHook): void => {
|
|
315
317
|
bridgeSink.push(hook);
|
|
316
318
|
};
|
|
319
|
+
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, allowSystemIdentity, {
|
|
320
|
+
queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
321
|
+
executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
|
|
322
|
+
writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
323
|
+
executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
|
|
324
|
+
});
|
|
317
325
|
const bridge = {
|
|
318
326
|
query: (targetType: string, payload: unknown) =>
|
|
319
327
|
executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
|
|
320
|
-
queryAs:
|
|
321
|
-
executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
|
|
328
|
+
queryAs: identitySwitch.queryAs,
|
|
322
329
|
write: async (targetType: string, payload: unknown) => {
|
|
323
330
|
const res = await executeWrite(ctx, targetType, payload, user, tx, bridgeSink);
|
|
324
331
|
return res;
|
|
325
332
|
},
|
|
326
|
-
writeAs:
|
|
327
|
-
const res = await executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink);
|
|
328
|
-
return res;
|
|
329
|
-
},
|
|
333
|
+
writeAs: identitySwitch.writeAs,
|
|
330
334
|
// Strict + unsafe share the same runtime — only the type-surface
|
|
331
335
|
// differs. The strict signature is what's exposed to typed callers;
|
|
332
336
|
// unsafe is the explicit escape-hatch for runtime-pluggable events.
|
|
@@ -949,8 +953,8 @@ export async function enforceRateLimit(
|
|
|
949
953
|
// Build the per-hook context every auth-claims invocation gets. Claims
|
|
950
954
|
// hooks run OUTSIDE any request transaction (login is itself the root
|
|
951
955
|
// operation, not a nested call) and read-only — so the TenantDb is
|
|
952
|
-
// scoped as "tenant" and no tx is threaded through.
|
|
953
|
-
//
|
|
956
|
+
// scoped as "tenant" and no tx is threaded through. Cross-tenant lookups
|
|
957
|
+
// go through queryAs(otherUser, ...); SYSTEM is always denied (no escapeHatch declaration site here).
|
|
954
958
|
function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthClaimsContext {
|
|
955
959
|
const { appContext: context } = ctx;
|
|
956
960
|
const dbSource = resolveDbSource(ctx, undefined);
|
|
@@ -967,10 +971,18 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
|
|
|
967
971
|
secrets: context.secrets,
|
|
968
972
|
})
|
|
969
973
|
: undefined;
|
|
970
|
-
|
|
971
|
-
db,
|
|
974
|
+
const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", false, {
|
|
972
975
|
queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
|
|
973
976
|
executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
|
|
977
|
+
writeAs: async () => {
|
|
978
|
+
throw new InternalError({
|
|
979
|
+
message: "r.authClaims hook context has no writeAs — auth-claims hooks are read-only.",
|
|
980
|
+
});
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
return {
|
|
984
|
+
db,
|
|
985
|
+
queryAs: identitySwitch.queryAs,
|
|
974
986
|
...(configAccessor && { config: configAccessor }),
|
|
975
987
|
};
|
|
976
988
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
2
|
+
import type {
|
|
3
|
+
EscapeHatchDeclaration,
|
|
4
|
+
LifecycleHookFn,
|
|
5
|
+
SessionUser,
|
|
6
|
+
WriteResult,
|
|
7
|
+
} from "../engine/types";
|
|
8
|
+
import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors";
|
|
9
|
+
|
|
10
|
+
export type QueryAsFn = (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
|
|
11
|
+
export type WriteAsFn = (user: SessionUser, qn: string, payload: unknown) => Promise<WriteResult>;
|
|
12
|
+
export type IdentitySwitch = { readonly queryAs: QueryAsFn; readonly writeAs: WriteAsFn };
|
|
13
|
+
|
|
14
|
+
export function isSystemIdentity(user: SessionUser): boolean {
|
|
15
|
+
return user.id === SYSTEM_USER_ID || user.roles.includes(SYSTEM_ROLE);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isSystemIdentitySwitchAllowed(
|
|
19
|
+
asUser: SessionUser,
|
|
20
|
+
allowSystemIdentity: boolean,
|
|
21
|
+
): boolean {
|
|
22
|
+
return !isSystemIdentity(asUser) || allowSystemIdentity;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedError {
|
|
26
|
+
return new AccessDeniedError({
|
|
27
|
+
message:
|
|
28
|
+
`${callerLabel} may not switch identity to SYSTEM — declare r.systemScope() or ` +
|
|
29
|
+
"escapeHatch: { reason } on it",
|
|
30
|
+
details: { reason: FrameworkReasons.systemIdentitySwitchDenied },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Reverse-lookup so withHookIdentitySwitchGrant can re-gate the SAME ungated pair under a narrower grant.
|
|
35
|
+
const ungatedByGated = new WeakMap<QueryAsFn | WriteAsFn, IdentitySwitch>();
|
|
36
|
+
|
|
37
|
+
export function createGatedIdentitySwitch(
|
|
38
|
+
callerLabel: string,
|
|
39
|
+
allowSystemIdentity: boolean,
|
|
40
|
+
ungated: IdentitySwitch,
|
|
41
|
+
): IdentitySwitch {
|
|
42
|
+
const queryAs: QueryAsFn = async (asUser, qn, payload) => {
|
|
43
|
+
if (!isSystemIdentitySwitchAllowed(asUser, allowSystemIdentity)) {
|
|
44
|
+
throw systemIdentitySwitchDenied(callerLabel);
|
|
45
|
+
}
|
|
46
|
+
return ungated.queryAs(asUser, qn, payload);
|
|
47
|
+
};
|
|
48
|
+
const writeAs: WriteAsFn = async (asUser, qn, payload) => {
|
|
49
|
+
if (!isSystemIdentitySwitchAllowed(asUser, allowSystemIdentity)) {
|
|
50
|
+
throw systemIdentitySwitchDenied(callerLabel);
|
|
51
|
+
}
|
|
52
|
+
return ungated.writeAs(asUser, qn, payload);
|
|
53
|
+
};
|
|
54
|
+
const gated: IdentitySwitch = { queryAs, writeAs };
|
|
55
|
+
ungatedByGated.set(queryAs, ungated);
|
|
56
|
+
ungatedByGated.set(writeAs, ungated);
|
|
57
|
+
return gated;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readIdentitySwitchFn<TFn extends QueryAsFn | WriteAsFn>(
|
|
61
|
+
context: object,
|
|
62
|
+
key: "queryAs" | "writeAs",
|
|
63
|
+
): TFn | undefined {
|
|
64
|
+
if (!(key in context)) return undefined;
|
|
65
|
+
const value = (context as Record<string, unknown>)[key];
|
|
66
|
+
return typeof value === "function" ? (value as TFn) : undefined; // @cast-boundary engine-bridge — checked via typeof above
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function unavailableIdentitySwitchFn(callerLabel: string, kind: "queryAs" | "writeAs") {
|
|
70
|
+
return async () => {
|
|
71
|
+
throw new InternalError({
|
|
72
|
+
message: `${callerLabel}: this context has no ${kind} to switch identity with.`,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fail-closed fallback for a ctx not built by the dispatcher bridge (unit-test stub, duplicated module instance).
|
|
78
|
+
function fallbackUngatedIdentitySwitch(
|
|
79
|
+
callerLabel: string,
|
|
80
|
+
ctxQueryAs: QueryAsFn | undefined,
|
|
81
|
+
ctxWriteAs: WriteAsFn | undefined,
|
|
82
|
+
): IdentitySwitch {
|
|
83
|
+
return {
|
|
84
|
+
queryAs: ctxQueryAs ?? unavailableIdentitySwitchFn(callerLabel, "queryAs"),
|
|
85
|
+
writeAs: ctxWriteAs ?? unavailableIdentitySwitchFn(callerLabel, "writeAs"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function withHookIdentitySwitchGrant<TContext extends object>(
|
|
90
|
+
context: TContext,
|
|
91
|
+
callerLabel: string,
|
|
92
|
+
escapeHatch: EscapeHatchDeclaration | undefined,
|
|
93
|
+
): TContext {
|
|
94
|
+
const ctxQueryAs = readIdentitySwitchFn<QueryAsFn>(context, "queryAs");
|
|
95
|
+
const ctxWriteAs = readIdentitySwitchFn<WriteAsFn>(context, "writeAs");
|
|
96
|
+
if (!ctxQueryAs && !ctxWriteAs) return context;
|
|
97
|
+
|
|
98
|
+
// Reuse the ORIGINAL ungated pair when known, so this grant doesn't compose with the caller's.
|
|
99
|
+
const ungated: IdentitySwitch =
|
|
100
|
+
(ctxQueryAs && ungatedByGated.get(ctxQueryAs)) ??
|
|
101
|
+
(ctxWriteAs && ungatedByGated.get(ctxWriteAs)) ??
|
|
102
|
+
fallbackUngatedIdentitySwitch(callerLabel, ctxQueryAs, ctxWriteAs);
|
|
103
|
+
|
|
104
|
+
const gated = createGatedIdentitySwitch(callerLabel, escapeHatch !== undefined, ungated);
|
|
105
|
+
return {
|
|
106
|
+
...context,
|
|
107
|
+
...(ctxQueryAs && { queryAs: gated.queryAs }),
|
|
108
|
+
...(ctxWriteAs && { writeAs: gated.writeAs }),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Re-gates a hook's own ctx.queryAs/ctx.writeAs instead of inheriting the handler's grant.
|
|
113
|
+
export function bindHookIdentitySwitchGrant(
|
|
114
|
+
fn: LifecycleHookFn,
|
|
115
|
+
label: string,
|
|
116
|
+
escapeHatch: EscapeHatchDeclaration | undefined,
|
|
117
|
+
): LifecycleHookFn {
|
|
118
|
+
return ((payload: unknown, context: object) =>
|
|
119
|
+
(fn as (payload: unknown, context: object) => unknown)(
|
|
120
|
+
payload,
|
|
121
|
+
withHookIdentitySwitchGrant(context, label, escapeHatch),
|
|
122
|
+
)) as LifecycleHookFn; // @cast-boundary engine-bridge — LifecycleHookFn union, same (payload, context) shape at runtime
|
|
123
|
+
}
|