@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.
Files changed (104) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +91 -0
  13. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  14. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  15. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  16. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  17. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  18. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  19. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  20. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  21. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  22. package/src/db/apply-entity-event.ts +1 -1
  23. package/src/db/assert-exists-in.ts +1 -1
  24. package/src/db/event-store-executor-context.ts +5 -2
  25. package/src/db/event-store-executor-read.ts +6 -4
  26. package/src/db/event-store-executor-write.ts +24 -18
  27. package/src/db/queries/shadow-swap.ts +36 -0
  28. package/src/db/replay-migration-sql.ts +5 -5
  29. package/src/db/tenant-db-runner.ts +23 -0
  30. package/src/db/tenant-db.ts +97 -24
  31. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  32. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  33. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  34. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  35. package/src/engine/__tests__/boot-validator.test.ts +51 -0
  36. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  37. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  38. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  39. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  40. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  41. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  42. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  43. package/src/engine/__tests__/screen.test.ts +150 -0
  44. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  45. package/src/engine/__tests__/steps-read.test.ts +204 -8
  46. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  47. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  48. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  49. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  50. package/src/engine/boot-validator/entity-handler.ts +21 -0
  51. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  52. package/src/engine/boot-validator/parent-ref.ts +1 -1
  53. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  54. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  55. package/src/engine/boot-validator/screens.ts +154 -31
  56. package/src/engine/build-config-feature-schema.ts +7 -4
  57. package/src/engine/entity-handlers.ts +71 -19
  58. package/src/engine/feature-entity-handlers.ts +4 -4
  59. package/src/engine/index.ts +5 -0
  60. package/src/engine/registry-validate.ts +12 -4
  61. package/src/engine/schema-builder.ts +6 -0
  62. package/src/engine/screen-helpers.ts +52 -0
  63. package/src/engine/steps/_read-source.ts +12 -0
  64. package/src/engine/steps/read-find-many.ts +8 -2
  65. package/src/engine/steps/read-find-one.ts +11 -8
  66. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  67. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  68. package/src/engine/types/index.ts +8 -1
  69. package/src/errors/i18n/de.yaml +14 -0
  70. package/src/errors/i18n/en.yaml +13 -0
  71. package/src/errors/reasons.ts +4 -0
  72. package/src/i18n/required-surface-keys.ts +6 -2
  73. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  74. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  75. package/src/jobs/job-runner.ts +35 -5
  76. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  77. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  78. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  79. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  80. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  81. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  82. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  83. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  84. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  85. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  86. package/src/pipeline/dispatch-query.ts +27 -8
  87. package/src/pipeline/dispatch-shared.ts +141 -57
  88. package/src/pipeline/dispatch-stream.ts +1 -3
  89. package/src/pipeline/dispatch-write.ts +62 -14
  90. package/src/pipeline/dispatcher.ts +20 -0
  91. package/src/pipeline/escape-hatch-report.ts +127 -0
  92. package/src/pipeline/index.ts +8 -0
  93. package/src/pipeline/member-read-only-transaction.ts +75 -0
  94. package/src/pipeline/msp-rebuild.ts +2 -0
  95. package/src/pipeline/projection-rebuild.ts +2 -0
  96. package/src/pipeline/projections-runner.ts +15 -13
  97. package/src/pipeline/system-identity-switch.ts +121 -9
  98. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  99. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  100. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  101. package/src/ui-types/index.ts +3 -0
  102. package/src/utils/__tests__/ids.test.ts +29 -1
  103. package/src/utils/ids.ts +10 -1
  104. 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, 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 gated = createGatedIdentitySwitch(callerLabel, caller, escapeHatch !== undefined, ungated);
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 ungated = ungatedMemberReaderByGated.get(ctxQueryAsMember) ?? ctxQueryAsMember;
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
- queryAsMember: createGatedMemberReader(callerLabel, escapeHatch !== undefined, ungated),
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[0]!)) return undefined;
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[1]!;
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[2]!;
152
+ const objArg = nthArg(args, 2);
144
153
  if (objectHasValidOwnTenantId(objArg)) {
145
- const rest = [args[1]!, objArg, ...args.slice(3)].map((a) => a.getText());
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) && current.getName()) return current.getName()!;
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;
@@ -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,