@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.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +35 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +101 -27
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/zod-shape.ts +92 -1
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
- package/src/engine/feature-ast/extractors/hooks.ts +8 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +4 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/index.ts +3 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +20 -0
- package/src/errors/i18n/en.yaml +19 -0
- package/src/errors/reasons.ts +8 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +89 -10
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +73 -13
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
- package/src/ui-types/index.ts +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ctx.queryAsMember — reads a query handler as a stored tenant member. The
|
|
2
|
+
// resolved SessionUser never leaves this module.
|
|
3
|
+
|
|
4
|
+
import type { DbConnection, DbTx } from "../db/connection";
|
|
5
|
+
import { buildSessionRoles } from "../engine/membership-roles";
|
|
6
|
+
import type { MemberReader, SessionUser } from "../engine/types";
|
|
7
|
+
import { SYSTEM_TENANT_ID } from "../engine/types";
|
|
8
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
9
|
+
import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors";
|
|
10
|
+
import {
|
|
11
|
+
type ActiveMembershipPolicy,
|
|
12
|
+
resolveActiveMembershipFn,
|
|
13
|
+
resolvePrincipalPlugin,
|
|
14
|
+
} from "./active-membership";
|
|
15
|
+
import { executeQuery } from "./dispatch-query";
|
|
16
|
+
import { type DispatchContext, resolveAuthClaimsFn, resolveDbSource } from "./dispatch-shared";
|
|
17
|
+
import { isSystemIdentity } from "./system-identity-switch";
|
|
18
|
+
|
|
19
|
+
// Stricter than interactive sign-in: an unknown principal or a tenant
|
|
20
|
+
// mid-teardown must not resolve — there is no user-facing flow to recover.
|
|
21
|
+
export const BACKGROUND_READ_POLICY: ActiveMembershipPolicy = {
|
|
22
|
+
allowPendingDestruction: false,
|
|
23
|
+
allowUnknownPrincipal: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Generic on purpose — details never carry which check failed, so a caller
|
|
27
|
+
// can't probe another user's membership state via the error shape.
|
|
28
|
+
function memberResolutionDenied(): AccessDeniedError {
|
|
29
|
+
return new AccessDeniedError({
|
|
30
|
+
message: "not permitted to read as this member",
|
|
31
|
+
details: { reason: FrameworkReasons.memberResolutionDenied },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Membership/principal/lifecycle resolve on the root connection, outside the
|
|
36
|
+
// caller's tx — only the target query runs inside it (via the `tx` argument).
|
|
37
|
+
async function resolveMember(
|
|
38
|
+
ctx: DispatchContext,
|
|
39
|
+
tenantId: TenantId,
|
|
40
|
+
userId: string,
|
|
41
|
+
): Promise<SessionUser> {
|
|
42
|
+
const membership = await resolveActiveMembershipFn(ctx, userId, tenantId, BACKGROUND_READ_POLICY);
|
|
43
|
+
if (membership.kind === "rejected") throw memberResolutionDenied();
|
|
44
|
+
|
|
45
|
+
const principalPlugin = resolvePrincipalPlugin(ctx.registry);
|
|
46
|
+
const dbSource = resolveDbSource(ctx, undefined);
|
|
47
|
+
if (!dbSource) {
|
|
48
|
+
throw new InternalError({
|
|
49
|
+
message: "ctx.queryAsMember requires a database connection — none is configured.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// resolveDbSource(ctx, undefined) never returns a tx, only AppContext.db.
|
|
53
|
+
const db = dbSource as DbConnection; // @cast-boundary db-operator
|
|
54
|
+
|
|
55
|
+
const profile = await principalPlugin.resolveProfile(userId, { db });
|
|
56
|
+
if (!profile) throw memberResolutionDenied();
|
|
57
|
+
|
|
58
|
+
// buildSessionRoles runs stripForbiddenMembershipRoles on the membership half (rebuild-resurrected roles).
|
|
59
|
+
const roles = buildSessionRoles(profile.globalRoles, membership.membership.roles);
|
|
60
|
+
const base: SessionUser = {
|
|
61
|
+
id: userId,
|
|
62
|
+
tenantId,
|
|
63
|
+
roles,
|
|
64
|
+
...(profile.timezone !== undefined && { timezone: profile.timezone }),
|
|
65
|
+
...(profile.locale !== undefined && { locale: profile.locale }),
|
|
66
|
+
origin: "member-resolution",
|
|
67
|
+
};
|
|
68
|
+
// Defense in depth: buildSessionRoles strips forbidden roles only from the
|
|
69
|
+
// membership half, not globalRoles — a resolved principal must never be SYSTEM.
|
|
70
|
+
if (isSystemIdentity(base)) throw memberResolutionDenied();
|
|
71
|
+
|
|
72
|
+
const claims = await resolveAuthClaimsFn(ctx, base);
|
|
73
|
+
return Object.keys(claims).length > 0 ? { ...base, claims } : base;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One reader per handler invocation (shared with its hooks) or job run; a
|
|
77
|
+
// nested dispatch builds its own reader with its own cache.
|
|
78
|
+
export function createMemberReaderFn(
|
|
79
|
+
ctx: DispatchContext,
|
|
80
|
+
tenantId: TenantId,
|
|
81
|
+
tx?: DbTx,
|
|
82
|
+
): MemberReader {
|
|
83
|
+
if (tenantId === SYSTEM_TENANT_ID) {
|
|
84
|
+
throw new InternalError({
|
|
85
|
+
message: "queryAsMember needs a tenant-scoped context — got SYSTEM_TENANT_ID.",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Only successful resolutions are cached — a rejected/failed resolution
|
|
90
|
+
// deletes its own entry so a transient error can't poison the rest of the run.
|
|
91
|
+
const cache = new Map<string, Promise<SessionUser>>();
|
|
92
|
+
|
|
93
|
+
function resolve(userId: string): Promise<SessionUser> {
|
|
94
|
+
const cached = cache.get(userId);
|
|
95
|
+
if (cached) return cached;
|
|
96
|
+
const pending = resolveMember(ctx, tenantId, userId);
|
|
97
|
+
cache.set(userId, pending);
|
|
98
|
+
pending.catch(() => {
|
|
99
|
+
cache.delete(userId);
|
|
100
|
+
});
|
|
101
|
+
return pending;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return async (userId, qn, payload) => {
|
|
105
|
+
const user = await resolve(userId);
|
|
106
|
+
return executeQuery(ctx, qn, payload, user, tx);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
212
|
+
withHookEscapeHatchGrant(context, label, escapeHatch),
|
|
153
213
|
)) as LifecycleHookFn; // @cast-boundary engine-bridge — LifecycleHookFn union, same (payload, context) shape at runtime
|
|
154
214
|
}
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/ui-types/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ export type {
|
|
|
56
56
|
AccessRule,
|
|
57
57
|
OpenToAllAccessRule,
|
|
58
58
|
OpenToAllDeclaration,
|
|
59
|
+
OpenToAllPersonalData,
|
|
59
60
|
} from "../engine/types/handlers";
|
|
60
61
|
export { isOpenToAllGranted } from "../engine/types/handlers";
|
|
61
62
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav";
|