@cosmicdrift/kumiko-framework 0.270.0 → 0.273.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/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +91 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +71 -19
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -3,6 +3,8 @@ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
|
3
3
|
import type {
|
|
4
4
|
ActiveMembershipResult,
|
|
5
5
|
EscapeHatchDeclaration,
|
|
6
|
+
EscapeHatchReporter,
|
|
7
|
+
HandlerContext,
|
|
6
8
|
LifecycleHookFn,
|
|
7
9
|
MemberReader,
|
|
8
10
|
SessionUser,
|
|
@@ -17,7 +19,12 @@ export type ResolveActiveMembershipFn = (
|
|
|
17
19
|
userId: string,
|
|
18
20
|
tenantId: TenantId,
|
|
19
21
|
) => Promise<ActiveMembershipResult>;
|
|
22
|
+
export type ProjectionReader = HandlerContext["queryProjection"];
|
|
20
23
|
export type IdentitySwitch = { readonly queryAs: QueryAsFn; readonly writeAs: WriteAsFn };
|
|
24
|
+
export type IdentitySwitchAudit = {
|
|
25
|
+
readonly reason: string | undefined;
|
|
26
|
+
readonly report: EscapeHatchReporter;
|
|
27
|
+
};
|
|
21
28
|
|
|
22
29
|
export function isSystemIdentity(user: SessionUser): boolean {
|
|
23
30
|
return user.id === SYSTEM_USER_ID || user.roles.includes(SYSTEM_ROLE);
|
|
@@ -76,36 +83,69 @@ export function identitySwitchDenied(callerLabel: string, asUser: SessionUser):
|
|
|
76
83
|
});
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
export function unsafeAllTenantsDenied(callerLabel: string): AccessDeniedError {
|
|
87
|
+
return new AccessDeniedError({
|
|
88
|
+
message:
|
|
89
|
+
`${callerLabel} may not call ctx.queryProjection(..., { unsafeAllTenants: true }) — declare ` +
|
|
90
|
+
"r.systemScope() or escapeHatch: { reason } on it",
|
|
91
|
+
details: { reason: FrameworkReasons.unsafeAllTenantsDenied },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
type GatedIdentitySwitchSource = {
|
|
80
96
|
readonly ungated: IdentitySwitch;
|
|
81
97
|
readonly caller: SessionUser | undefined;
|
|
98
|
+
readonly report?: EscapeHatchReporter;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type MemberReaderAudit = IdentitySwitchAudit & { readonly tenantId: TenantId };
|
|
102
|
+
|
|
103
|
+
type GatedMemberReaderSource = {
|
|
104
|
+
readonly ungated: MemberReader;
|
|
105
|
+
readonly audit?: MemberReaderAudit;
|
|
82
106
|
};
|
|
83
107
|
|
|
84
108
|
// Reverse-lookup so withHookEscapeHatchGrant can re-gate the SAME ungated pair (and caller) under a narrower grant.
|
|
85
109
|
const sourceByGated = new WeakMap<QueryAsFn | WriteAsFn, GatedIdentitySwitchSource>();
|
|
86
110
|
// Same idea, for ctx.queryAsMember — a single function rather than a pair.
|
|
87
|
-
const ungatedMemberReaderByGated = new WeakMap<MemberReader,
|
|
111
|
+
const ungatedMemberReaderByGated = new WeakMap<MemberReader, GatedMemberReaderSource>();
|
|
112
|
+
|
|
113
|
+
function reportGrantedSwitch(
|
|
114
|
+
caller: SessionUser | undefined,
|
|
115
|
+
asUser: SessionUser,
|
|
116
|
+
hasGrant: boolean,
|
|
117
|
+
audit: IdentitySwitchAudit | undefined,
|
|
118
|
+
): void {
|
|
119
|
+
// skip: no audit wired, switch wasn't grant-gated, or grant carries no reason to report
|
|
120
|
+
if (!audit || !hasGrant || audit.reason === undefined) return;
|
|
121
|
+
// skip: caller switching to themselves isn't a privilege escalation worth auditing
|
|
122
|
+
if (caller !== undefined && isSelfDelegation(caller, asUser)) return;
|
|
123
|
+
audit.report("identity-switch", audit.reason, { id: asUser.id, tenantId: asUser.tenantId });
|
|
124
|
+
}
|
|
88
125
|
|
|
89
126
|
export function createGatedIdentitySwitch(
|
|
90
127
|
callerLabel: string,
|
|
91
128
|
caller: SessionUser | undefined,
|
|
92
129
|
hasGrant: boolean,
|
|
93
130
|
ungated: IdentitySwitch,
|
|
131
|
+
audit?: IdentitySwitchAudit,
|
|
94
132
|
): IdentitySwitch {
|
|
95
133
|
const queryAs: QueryAsFn = async (asUser, qn, payload) => {
|
|
96
134
|
if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
|
|
97
135
|
throw identitySwitchDenied(callerLabel, asUser);
|
|
98
136
|
}
|
|
137
|
+
reportGrantedSwitch(caller, asUser, hasGrant, audit);
|
|
99
138
|
return ungated.queryAs(asUser, qn, payload);
|
|
100
139
|
};
|
|
101
140
|
const writeAs: WriteAsFn = async (asUser, qn, payload) => {
|
|
102
141
|
if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
|
|
103
142
|
throw identitySwitchDenied(callerLabel, asUser);
|
|
104
143
|
}
|
|
144
|
+
reportGrantedSwitch(caller, asUser, hasGrant, audit);
|
|
105
145
|
return ungated.writeAs(asUser, qn, payload);
|
|
106
146
|
};
|
|
107
147
|
const gated: IdentitySwitch = { queryAs, writeAs };
|
|
108
|
-
const source: GatedIdentitySwitchSource = { ungated, caller };
|
|
148
|
+
const source: GatedIdentitySwitchSource = { ungated, caller, report: audit?.report };
|
|
109
149
|
sourceByGated.set(queryAs, source);
|
|
110
150
|
sourceByGated.set(writeAs, source);
|
|
111
151
|
return gated;
|
|
@@ -117,20 +157,56 @@ export function createGatedMemberReader(
|
|
|
117
157
|
callerLabel: string,
|
|
118
158
|
allowSystemIdentity: boolean,
|
|
119
159
|
ungated: MemberReader,
|
|
160
|
+
audit?: MemberReaderAudit,
|
|
120
161
|
): MemberReader {
|
|
121
162
|
const gated: MemberReader = async (userId, qn, payload) => {
|
|
122
163
|
if (!allowSystemIdentity) throw systemIdentitySwitchDenied(callerLabel);
|
|
164
|
+
if (audit?.reason !== undefined) {
|
|
165
|
+
audit.report("identity-switch", audit.reason, { id: userId, tenantId: audit.tenantId });
|
|
166
|
+
}
|
|
123
167
|
return ungated(userId, qn, payload);
|
|
124
168
|
};
|
|
125
|
-
ungatedMemberReaderByGated.set(gated, ungated);
|
|
169
|
+
ungatedMemberReaderByGated.set(gated, { ungated, audit });
|
|
170
|
+
return gated;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type GatedProjectionReaderSource = {
|
|
174
|
+
readonly ungated: ProjectionReader;
|
|
175
|
+
readonly report?: EscapeHatchReporter;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Same idea, for ctx.queryProjection — a single function rather than a pair.
|
|
179
|
+
const ungatedProjectionReaderByGated = new WeakMap<ProjectionReader, GatedProjectionReaderSource>();
|
|
180
|
+
|
|
181
|
+
// ctx.queryProjection's gate: only { unsafeAllTenants: true } is grant-checked —
|
|
182
|
+
// a plain call always passes through, tenant-scoped by the projection's own table.
|
|
183
|
+
export function createGatedProjectionReader(
|
|
184
|
+
callerLabel: string,
|
|
185
|
+
hasGrant: boolean,
|
|
186
|
+
ungated: ProjectionReader,
|
|
187
|
+
audit?: IdentitySwitchAudit,
|
|
188
|
+
): ProjectionReader {
|
|
189
|
+
const gated = async <T = Record<string, unknown>>(
|
|
190
|
+
qualifiedName: string,
|
|
191
|
+
options?: { readonly unsafeAllTenants?: boolean },
|
|
192
|
+
): Promise<readonly T[]> => {
|
|
193
|
+
if (options?.unsafeAllTenants === true) {
|
|
194
|
+
if (!hasGrant) throw unsafeAllTenantsDenied(callerLabel);
|
|
195
|
+
if (audit?.reason !== undefined) {
|
|
196
|
+
audit.report("unsafe-all-tenants", audit.reason);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return ungated<T>(qualifiedName, options);
|
|
200
|
+
};
|
|
201
|
+
ungatedProjectionReaderByGated.set(gated, { ungated, report: audit?.report });
|
|
126
202
|
return gated;
|
|
127
203
|
}
|
|
128
204
|
|
|
129
205
|
function readIdentitySwitchFn<
|
|
130
|
-
TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader,
|
|
206
|
+
TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader | ProjectionReader,
|
|
131
207
|
>(
|
|
132
208
|
context: object,
|
|
133
|
-
key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember",
|
|
209
|
+
key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember" | "queryProjection",
|
|
134
210
|
): TFn | undefined {
|
|
135
211
|
if (!(key in context)) return undefined;
|
|
136
212
|
const value = (context as Record<string, unknown>)[key];
|
|
@@ -195,7 +271,15 @@ function gatedIdentitySwitchFields(
|
|
|
195
271
|
const ungatedWriteAs = ctxWriteAs && (writeSource?.ungated.writeAs ?? ctxWriteAs);
|
|
196
272
|
const ungated = fallbackUngatedIdentitySwitch(callerLabel, ungatedQueryAs, ungatedWriteAs);
|
|
197
273
|
const caller = resolveDispatcherCaller(querySource, writeSource);
|
|
198
|
-
const
|
|
274
|
+
const report = (querySource ?? writeSource)?.report;
|
|
275
|
+
const audit = report ? { reason: escapeHatch?.reason, report } : undefined;
|
|
276
|
+
const gated = createGatedIdentitySwitch(
|
|
277
|
+
callerLabel,
|
|
278
|
+
caller,
|
|
279
|
+
escapeHatch !== undefined,
|
|
280
|
+
ungated,
|
|
281
|
+
audit,
|
|
282
|
+
);
|
|
199
283
|
return {
|
|
200
284
|
...(ctxQueryAs && { queryAs: gated.queryAs }),
|
|
201
285
|
...(ctxWriteAs && { writeAs: gated.writeAs }),
|
|
@@ -210,9 +294,34 @@ function gatedMemberReaderField(
|
|
|
210
294
|
ctxQueryAsMember: MemberReader | undefined,
|
|
211
295
|
): { queryAsMember?: MemberReader } {
|
|
212
296
|
if (!ctxQueryAsMember) return {};
|
|
213
|
-
const
|
|
297
|
+
const source = ungatedMemberReaderByGated.get(ctxQueryAsMember);
|
|
298
|
+
const ungated = source?.ungated ?? ctxQueryAsMember;
|
|
299
|
+
const audit = source?.audit
|
|
300
|
+
? { reason: escapeHatch?.reason, report: source.audit.report, tenantId: source.audit.tenantId }
|
|
301
|
+
: undefined;
|
|
302
|
+
return {
|
|
303
|
+
queryAsMember: createGatedMemberReader(callerLabel, escapeHatch !== undefined, ungated, audit),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Re-gates ctx.queryProjection under the hook's own escapeHatch, same
|
|
308
|
+
// re-gating rule as the other fields below — see withHookEscapeHatchGrant.
|
|
309
|
+
function gatedProjectionReaderField(
|
|
310
|
+
callerLabel: string,
|
|
311
|
+
escapeHatch: EscapeHatchDeclaration | undefined,
|
|
312
|
+
ctxQueryProjection: ProjectionReader | undefined,
|
|
313
|
+
): { queryProjection?: ProjectionReader } {
|
|
314
|
+
if (!ctxQueryProjection) return {};
|
|
315
|
+
const source = ungatedProjectionReaderByGated.get(ctxQueryProjection);
|
|
316
|
+
const ungated = source?.ungated ?? ctxQueryProjection;
|
|
317
|
+
const audit = source?.report ? { reason: escapeHatch?.reason, report: source.report } : undefined;
|
|
214
318
|
return {
|
|
215
|
-
|
|
319
|
+
queryProjection: createGatedProjectionReader(
|
|
320
|
+
callerLabel,
|
|
321
|
+
escapeHatch !== undefined,
|
|
322
|
+
ungated,
|
|
323
|
+
audit,
|
|
324
|
+
),
|
|
216
325
|
};
|
|
217
326
|
}
|
|
218
327
|
|
|
@@ -228,6 +337,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
228
337
|
"resolveActiveMembership",
|
|
229
338
|
);
|
|
230
339
|
const ctxQueryAsMember = readIdentitySwitchFn<MemberReader>(context, "queryAsMember");
|
|
340
|
+
const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
|
|
231
341
|
const ctxDb = readDbLikeValue(context, "db");
|
|
232
342
|
const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
|
|
233
343
|
if (
|
|
@@ -235,6 +345,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
235
345
|
!ctxWriteAs &&
|
|
236
346
|
!ctxResolveActiveMembership &&
|
|
237
347
|
!ctxQueryAsMember &&
|
|
348
|
+
!ctxQueryProjection &&
|
|
238
349
|
!ctxDb &&
|
|
239
350
|
!ctxDbOutsideTransaction
|
|
240
351
|
) {
|
|
@@ -249,6 +360,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
249
360
|
resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
|
|
250
361
|
}),
|
|
251
362
|
...gatedMemberReaderField(callerLabel, escapeHatch, ctxQueryAsMember),
|
|
363
|
+
...gatedProjectionReaderField(callerLabel, escapeHatch, ctxQueryProjection),
|
|
252
364
|
// @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
|
|
253
365
|
...(ctxDb && { db: withUnsafeRawGrant(ctxDb as TenantDb, escapeHatch) }),
|
|
254
366
|
...(ctxDbOutsideTransaction && {
|
|
@@ -257,7 +369,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
257
369
|
};
|
|
258
370
|
}
|
|
259
371
|
|
|
260
|
-
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
|
|
372
|
+
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
|
|
261
373
|
export function bindHookEscapeHatchGrant(
|
|
262
374
|
fn: LifecycleHookFn,
|
|
263
375
|
label: string,
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// migrate-cross-tenant — rewrites `crossTenant: true` on entity-convention
|
|
3
|
+
// handlers to `escapeHatch: { reason }` where the handler name and verb can
|
|
4
|
+
// be derived from the call, and reports every other site for review (fw#2915).
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// bun scripts/codemod/migrate-cross-tenant.ts [--dry-run] <path...>
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
type CallExpression,
|
|
13
|
+
Node,
|
|
14
|
+
type ObjectLiteralExpression,
|
|
15
|
+
Project,
|
|
16
|
+
type PropertyAssignment,
|
|
17
|
+
SyntaxKind,
|
|
18
|
+
} from "ts-morph";
|
|
19
|
+
|
|
20
|
+
export interface Rewrite {
|
|
21
|
+
readonly file: string;
|
|
22
|
+
readonly line: number;
|
|
23
|
+
readonly before: string;
|
|
24
|
+
readonly after: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ManualSite {
|
|
28
|
+
readonly file: string;
|
|
29
|
+
readonly line: number;
|
|
30
|
+
readonly why: string;
|
|
31
|
+
readonly handlerName?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MigrateCrossTenantResult {
|
|
35
|
+
readonly output: string;
|
|
36
|
+
readonly rewrites: Rewrite[];
|
|
37
|
+
readonly manual: ManualSite[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const VERB_IN_NAME_CALLEES: Record<string, string> = {
|
|
41
|
+
defineEntityCreateHandler: "create",
|
|
42
|
+
defineEntityUpdateHandler: "update",
|
|
43
|
+
defineEntityDeleteHandler: "delete",
|
|
44
|
+
defineEntityRestoreHandler: "restore",
|
|
45
|
+
defineEntityListHandler: "list",
|
|
46
|
+
defineEntityDetailHandler: "detail",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const VERB_IN_ARG_CALLEES = new Set(["defineEntityWriteHandler", "defineEntityQueryHandler"]);
|
|
50
|
+
|
|
51
|
+
const READ_VERBS = new Set(["list", "detail"]);
|
|
52
|
+
|
|
53
|
+
function isCrossTenantAssignment(node: Node): node is PropertyAssignment {
|
|
54
|
+
return Node.isPropertyAssignment(node) && node.getName() === "crossTenant";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getCalleeName(call: CallExpression): string | undefined {
|
|
58
|
+
const expr = call.getExpression();
|
|
59
|
+
if (Node.isIdentifier(expr)) return expr.getText();
|
|
60
|
+
if (Node.isPropertyAccessExpression(expr)) return expr.getName();
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasEscapeHatchProperty(obj: ObjectLiteralExpression): boolean {
|
|
65
|
+
return obj.getProperty("escapeHatch") !== undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AutoRewriteTarget {
|
|
69
|
+
readonly handlerName: string;
|
|
70
|
+
readonly entityName: string;
|
|
71
|
+
readonly verb: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function deriveAutoRewriteTarget(
|
|
75
|
+
objectLiteral: ObjectLiteralExpression,
|
|
76
|
+
): AutoRewriteTarget | undefined {
|
|
77
|
+
const call = objectLiteral.getParentIfKind(SyntaxKind.CallExpression);
|
|
78
|
+
if (!call) return undefined;
|
|
79
|
+
if (!call.getArguments().includes(objectLiteral)) return undefined;
|
|
80
|
+
|
|
81
|
+
const calleeName = getCalleeName(call);
|
|
82
|
+
if (!calleeName) return undefined;
|
|
83
|
+
|
|
84
|
+
const firstArg = call.getArguments()[0];
|
|
85
|
+
if (!firstArg || !Node.isStringLiteral(firstArg)) return undefined;
|
|
86
|
+
const firstArgValue = firstArg.getLiteralValue();
|
|
87
|
+
|
|
88
|
+
const verbFromName = VERB_IN_NAME_CALLEES[calleeName];
|
|
89
|
+
if (verbFromName) {
|
|
90
|
+
return {
|
|
91
|
+
handlerName: `${firstArgValue}:${verbFromName}`,
|
|
92
|
+
entityName: firstArgValue,
|
|
93
|
+
verb: verbFromName,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (VERB_IN_ARG_CALLEES.has(calleeName)) {
|
|
98
|
+
const sep = firstArgValue.indexOf(":");
|
|
99
|
+
if (sep === -1) return undefined;
|
|
100
|
+
const entityName = firstArgValue.slice(0, sep);
|
|
101
|
+
const verb = firstArgValue.slice(sep + 1);
|
|
102
|
+
if (!entityName || !verb) return undefined;
|
|
103
|
+
return { handlerName: firstArgValue, entityName, verb };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function deriveEntityCrudContext(objectLiteral: ObjectLiteralExpression): string | undefined {
|
|
110
|
+
const verbGroupAssignment = objectLiteral.getParent();
|
|
111
|
+
if (!Node.isPropertyAssignment(verbGroupAssignment)) return undefined;
|
|
112
|
+
const verbGroup = verbGroupAssignment.getName();
|
|
113
|
+
if (verbGroup !== "write" && verbGroup !== "read") return undefined;
|
|
114
|
+
|
|
115
|
+
const optionsObject = verbGroupAssignment.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
116
|
+
const call = optionsObject?.getParentIfKind(SyntaxKind.CallExpression);
|
|
117
|
+
if (!call || !optionsObject || !call.getArguments().includes(optionsObject)) return undefined;
|
|
118
|
+
|
|
119
|
+
const calleeName = getCalleeName(call);
|
|
120
|
+
if (calleeName !== "registerEntityCrud" && calleeName !== "crud") return undefined;
|
|
121
|
+
|
|
122
|
+
const args = call.getArguments();
|
|
123
|
+
const entityArg = calleeName === "crud" ? args[0] : args[1];
|
|
124
|
+
const entityName =
|
|
125
|
+
entityArg && Node.isStringLiteral(entityArg) ? entityArg.getLiteralValue() : undefined;
|
|
126
|
+
return `under ${calleeName}(${entityName ? `"${entityName}", ` : ""}...) → ${verbGroup}: multiple verbs share this crossTenant — set escapeHatch per verb manually, one reason each`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function classifyManualReason(objectLiteral: ObjectLiteralExpression): string {
|
|
130
|
+
const entityCrudWhy = deriveEntityCrudContext(objectLiteral);
|
|
131
|
+
if (entityCrudWhy) return entityCrudWhy;
|
|
132
|
+
|
|
133
|
+
const call = objectLiteral.getParentIfKind(SyntaxKind.CallExpression);
|
|
134
|
+
if (!call?.getArguments().includes(objectLiteral)) {
|
|
135
|
+
return "crossTenant is not set directly in a handler call (e.g. a spread/shared object) — give each consuming handler its own escapeHatch reason";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const calleeName = getCalleeName(call);
|
|
139
|
+
if (!calleeName || (!VERB_IN_NAME_CALLEES[calleeName] && !VERB_IN_ARG_CALLEES.has(calleeName))) {
|
|
140
|
+
return `call \`${calleeName ?? "<unknown>"}\` is not a recognized entity-convention handler factory — add escapeHatch manually`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return "handler name/verb could not be derived from the call's first argument — add escapeHatch manually";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function deriveBestEffortHandlerName(assignment: PropertyAssignment): string | undefined {
|
|
147
|
+
const call = assignment.getFirstAncestorByKind(SyntaxKind.CallExpression);
|
|
148
|
+
const firstArg = call?.getArguments()[0];
|
|
149
|
+
return firstArg && Node.isStringLiteral(firstArg) ? firstArg.getLiteralValue() : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildEscapeHatchReason(target: AutoRewriteTarget): string {
|
|
153
|
+
const verbKind = READ_VERBS.has(target.verb) ? "reads" : "writes";
|
|
154
|
+
return `${target.handlerName} ${verbKind} ${target.entityName} rows across every tenant (migrated from crossTenant: true; state the operator use case here)`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function migrateCrossTenantSource(
|
|
158
|
+
source: string,
|
|
159
|
+
fileName: string,
|
|
160
|
+
): MigrateCrossTenantResult {
|
|
161
|
+
const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
|
|
162
|
+
const sourceFile = project.createSourceFile(fileName, source);
|
|
163
|
+
|
|
164
|
+
const targets = sourceFile
|
|
165
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
166
|
+
.filter(isCrossTenantAssignment);
|
|
167
|
+
|
|
168
|
+
const rewrites: Rewrite[] = [];
|
|
169
|
+
const manual: ManualSite[] = [];
|
|
170
|
+
|
|
171
|
+
for (const assignment of targets) {
|
|
172
|
+
if (assignment.wasForgotten()) continue;
|
|
173
|
+
const line = assignment.getStartLineNumber();
|
|
174
|
+
const init = assignment.getInitializer();
|
|
175
|
+
|
|
176
|
+
if (init?.getKind() === SyntaxKind.FalseKeyword) continue;
|
|
177
|
+
|
|
178
|
+
if (init?.getKind() !== SyntaxKind.TrueKeyword) {
|
|
179
|
+
manual.push({
|
|
180
|
+
file: fileName,
|
|
181
|
+
line,
|
|
182
|
+
why: `crossTenant is not a literal boolean (\`${init?.getText() ?? "<missing>"}\`) — replace manually with escapeHatch: { reason }`,
|
|
183
|
+
...(deriveBestEffortHandlerName(assignment) !== undefined && {
|
|
184
|
+
handlerName: deriveBestEffortHandlerName(assignment),
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const objectLiteral = assignment.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
191
|
+
if (!objectLiteral) continue;
|
|
192
|
+
|
|
193
|
+
if (hasEscapeHatchProperty(objectLiteral)) {
|
|
194
|
+
manual.push({
|
|
195
|
+
file: fileName,
|
|
196
|
+
line,
|
|
197
|
+
why: "this object already declares escapeHatch — merge the two manually and remove crossTenant",
|
|
198
|
+
...(deriveBestEffortHandlerName(assignment) !== undefined && {
|
|
199
|
+
handlerName: deriveBestEffortHandlerName(assignment),
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const target = deriveAutoRewriteTarget(objectLiteral);
|
|
206
|
+
if (!target) {
|
|
207
|
+
manual.push({
|
|
208
|
+
file: fileName,
|
|
209
|
+
line,
|
|
210
|
+
why: classifyManualReason(objectLiteral),
|
|
211
|
+
...(deriveBestEffortHandlerName(assignment) !== undefined && {
|
|
212
|
+
handlerName: deriveBestEffortHandlerName(assignment),
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const before = assignment.getText();
|
|
219
|
+
const after = `escapeHatch: { reason: ${JSON.stringify(buildEscapeHatchReason(target))} }`;
|
|
220
|
+
assignment.replaceWithText(after);
|
|
221
|
+
rewrites.push({ file: fileName, line, before, after });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { output: sourceFile.getFullText(), rewrites, manual };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
class CliError extends Error {}
|
|
228
|
+
|
|
229
|
+
interface CliOptions {
|
|
230
|
+
readonly dryRun: boolean;
|
|
231
|
+
readonly paths: string[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseArgs(argv: string[]): CliOptions {
|
|
235
|
+
let dryRun = false;
|
|
236
|
+
const paths: string[] = [];
|
|
237
|
+
|
|
238
|
+
for (const arg of argv) {
|
|
239
|
+
if (arg === "--dry-run") {
|
|
240
|
+
dryRun = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (arg.startsWith("--")) throw new CliError(`unknown flag: ${arg}`);
|
|
244
|
+
paths.push(arg);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (paths.length === 0) throw new CliError("at least one path is required");
|
|
248
|
+
return { dryRun, paths };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isMigratableFile(name: string): boolean {
|
|
252
|
+
return /\.(ts|tsx)$/.test(name);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function walkDir(dir: string, out: string[]): void {
|
|
256
|
+
for (const name of readdirSync(dir)) {
|
|
257
|
+
if (name === "node_modules") continue;
|
|
258
|
+
const full = join(dir, name);
|
|
259
|
+
const stat = statSync(full);
|
|
260
|
+
if (stat.isDirectory()) {
|
|
261
|
+
walkDir(full, out);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (isMigratableFile(name)) out.push(full);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function collectFiles(paths: string[]): string[] {
|
|
269
|
+
const files: string[] = [];
|
|
270
|
+
for (const p of paths) {
|
|
271
|
+
const abs = resolve(p);
|
|
272
|
+
const stat = statSync(abs);
|
|
273
|
+
if (stat.isDirectory()) walkDir(abs, files);
|
|
274
|
+
else if (isMigratableFile(abs)) files.push(abs);
|
|
275
|
+
}
|
|
276
|
+
return files;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function main(): Promise<void> {
|
|
280
|
+
let options: CliOptions;
|
|
281
|
+
try {
|
|
282
|
+
options = parseArgs(process.argv.slice(2));
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err instanceof CliError) {
|
|
285
|
+
console.error(`[migrate-cross-tenant] ${err.message}`);
|
|
286
|
+
process.exit(2);
|
|
287
|
+
}
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const files = collectFiles(options.paths);
|
|
292
|
+
const allManual: ManualSite[] = [];
|
|
293
|
+
let filesChanged = 0;
|
|
294
|
+
let totalRewrites = 0;
|
|
295
|
+
|
|
296
|
+
for (const file of files) {
|
|
297
|
+
const before = readFileSync(file, "utf8");
|
|
298
|
+
if (!before.includes("crossTenant")) continue;
|
|
299
|
+
const result = migrateCrossTenantSource(before, file);
|
|
300
|
+
allManual.push(...result.manual);
|
|
301
|
+
|
|
302
|
+
if (result.rewrites.length === 0) continue;
|
|
303
|
+
filesChanged += 1;
|
|
304
|
+
totalRewrites += result.rewrites.length;
|
|
305
|
+
|
|
306
|
+
if (options.dryRun) {
|
|
307
|
+
console.log(`\n=== ${file} (${result.rewrites.length} rewrite(s)) ===`);
|
|
308
|
+
for (const r of result.rewrites) {
|
|
309
|
+
console.log(`${file}:${r.line}`);
|
|
310
|
+
console.log(`- ${r.before}`);
|
|
311
|
+
console.log(`+ ${r.after}`);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
writeFileSync(file, result.output);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
console.log(
|
|
319
|
+
`\n[migrate-cross-tenant] ${options.dryRun ? "would change" : "changed"} ${filesChanged} file(s); ` +
|
|
320
|
+
`rewrites: ${totalRewrites}`,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
if (allManual.length > 0) {
|
|
324
|
+
console.log(`\n[migrate-cross-tenant] manual review needed (${allManual.length}):`);
|
|
325
|
+
for (const m of allManual) {
|
|
326
|
+
console.log(`${m.file}:${m.line} ${m.handlerName ?? "<unknown>"} ${m.why}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
process.exit(0);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (import.meta.main) {
|
|
334
|
+
await main();
|
|
335
|
+
}
|
|
@@ -109,6 +109,15 @@ interface PendingRewrite {
|
|
|
109
109
|
readonly line: number;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// The caller checks `args.length` immediately before every use — the index
|
|
113
|
+
// is always in bounds; this replaces a non-null assertion with an explicit
|
|
114
|
+
// invariant instead of silencing the compiler.
|
|
115
|
+
function nthArg(args: Node[], index: number): Node {
|
|
116
|
+
const arg = args[index];
|
|
117
|
+
if (!arg) throw new Error(`planRewrite: expected an argument at index ${index}`);
|
|
118
|
+
return arg;
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
function planRewrite(
|
|
113
122
|
callExpr: Node,
|
|
114
123
|
globalTables: ReadonlySet<string>,
|
|
@@ -118,14 +127,14 @@ function planRewrite(
|
|
|
118
127
|
if (!Node.isIdentifier(calleeExpr)) return undefined;
|
|
119
128
|
const fnName = calleeExpr.getText();
|
|
120
129
|
const args = callExpr.getArguments();
|
|
121
|
-
if (args.length === 0 || !isRawOnCtxDb(args
|
|
130
|
+
if (args.length === 0 || !isRawOnCtxDb(nthArg(args, 0))) return undefined;
|
|
122
131
|
|
|
123
132
|
const typeArgs = callExpr.getTypeArguments();
|
|
124
133
|
const typeArgsText =
|
|
125
134
|
typeArgs.length > 0 ? `<${typeArgs.map((t) => t.getText()).join(", ")}>` : "";
|
|
126
135
|
|
|
127
136
|
if (GLOBAL_TABLE_FN_NAMES.has(fnName) && args.length >= 2) {
|
|
128
|
-
const tableArg = args
|
|
137
|
+
const tableArg = nthArg(args, 1);
|
|
129
138
|
if (Node.isIdentifier(tableArg) && globalTables.has(tableArg.getText())) {
|
|
130
139
|
const rest = args.slice(2).map((a) => a.getText());
|
|
131
140
|
const after = `ctx.db.global(${tableArg.getText()}).${fnName}${typeArgsText}(${rest.join(", ")})`;
|
|
@@ -140,9 +149,9 @@ function planRewrite(
|
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
if (OWN_TENANT_FN_NAMES.has(fnName) && args.length >= 3) {
|
|
143
|
-
const objArg = args
|
|
152
|
+
const objArg = nthArg(args, 2);
|
|
144
153
|
if (objectHasValidOwnTenantId(objArg)) {
|
|
145
|
-
const rest = [args
|
|
154
|
+
const rest = [nthArg(args, 1), objArg, ...args.slice(3)].map((a) => a.getText());
|
|
146
155
|
const after = `ctx.db.${fnName}${typeArgsText}(${rest.join(", ")})`;
|
|
147
156
|
return {
|
|
148
157
|
callExpr,
|
|
@@ -174,7 +183,10 @@ function isTenantDbRawAccess(pae: Node): boolean {
|
|
|
174
183
|
function enclosingName(node: Node): string {
|
|
175
184
|
let current: Node | undefined = node.getParent();
|
|
176
185
|
while (current) {
|
|
177
|
-
if (Node.isFunctionDeclaration(current)
|
|
186
|
+
if (Node.isFunctionDeclaration(current)) {
|
|
187
|
+
const name = current.getName();
|
|
188
|
+
if (name) return name;
|
|
189
|
+
}
|
|
178
190
|
if (Node.isMethodDeclaration(current)) return current.getName();
|
|
179
191
|
if (Node.isPropertyAssignment(current)) return current.getName();
|
|
180
192
|
if (Node.isVariableDeclaration(current)) return current.getName();
|
|
@@ -283,7 +295,8 @@ function parseArgs(argv: string[]): CliOptions {
|
|
|
283
295
|
const paths: string[] = [];
|
|
284
296
|
|
|
285
297
|
for (let i = 0; i < argv.length; i++) {
|
|
286
|
-
const arg = argv[i]
|
|
298
|
+
const arg = argv[i];
|
|
299
|
+
if (!arg) break;
|
|
287
300
|
if (arg === "--dry-run") {
|
|
288
301
|
dryRun = true;
|
|
289
302
|
continue;
|
|
@@ -140,7 +140,8 @@ function parseArgs(argv: string[]): CliOptions {
|
|
|
140
140
|
const paths: string[] = [];
|
|
141
141
|
|
|
142
142
|
for (let i = 0; i < argv.length; i++) {
|
|
143
|
-
const arg = argv[i]
|
|
143
|
+
const arg = argv[i];
|
|
144
|
+
if (!arg) break;
|
|
144
145
|
if (arg === "--dry-run") {
|
|
145
146
|
dryRun = true;
|
|
146
147
|
continue;
|
package/src/ui-types/index.ts
CHANGED
|
@@ -27,12 +27,14 @@ export type { ParsedRefTarget } from "../engine/parse-ref-target";
|
|
|
27
27
|
export { parseRefTarget } from "../engine/parse-ref-target";
|
|
28
28
|
export {
|
|
29
29
|
evalFieldCondition,
|
|
30
|
+
explicitListScreenId,
|
|
30
31
|
isExtensionEditSection,
|
|
31
32
|
isFieldsEditSection,
|
|
32
33
|
isFormatSpec,
|
|
33
34
|
isWriteFormEditSection,
|
|
34
35
|
normalizeEditField,
|
|
35
36
|
normalizeListColumn,
|
|
37
|
+
resolveNavParentScreen,
|
|
36
38
|
} from "../engine/screen-helpers";
|
|
37
39
|
// Entity + field types. EntityDefinition is the canonical shape that
|
|
38
40
|
// view-model builders iterate; FieldDefinition is the per-field union
|
|
@@ -99,6 +101,7 @@ export type {
|
|
|
99
101
|
PlatformComponent,
|
|
100
102
|
ProjectionDetailScreenDefinition,
|
|
101
103
|
ProjectionListScreenDefinition,
|
|
104
|
+
RelatedListToolbarAction,
|
|
102
105
|
RowAction,
|
|
103
106
|
RowActionDrawer,
|
|
104
107
|
RowActionNavigate,
|