@cosmicdrift/kumiko-framework 0.336.1 → 0.337.1

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 (96) hide show
  1. package/dist/api/auth-middleware.js +43 -19
  2. package/dist/api/auth-routes.d.ts +1 -0
  3. package/dist/api/auth-routes.js +20 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +4 -5
  6. package/dist/api/server.js +3 -3
  7. package/dist/api/sse-route.js +14 -1
  8. package/dist/bun-db/query.js +6 -4
  9. package/dist/crypto/kek-source.js +1 -0
  10. package/dist/db/blind-index-cleanup.js +1 -1
  11. package/dist/db/event-store-executor-read.js +15 -4
  12. package/dist/db/queries/event-consumer.d.ts +2 -2
  13. package/dist/db/queries/event-consumer.js +5 -4
  14. package/dist/db/queries/event-store.js +6 -6
  15. package/dist/db/queries/projection-rebuild.js +4 -4
  16. package/dist/db/queries/seed-context.js +6 -6
  17. package/dist/derivatives/derivatives-context.js +5 -2
  18. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  19. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  20. package/dist/engine/boot-validator/screens.js +12 -0
  21. package/dist/engine/build-config-feature-schema.js +4 -0
  22. package/dist/engine/define-feature.js +1 -0
  23. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  24. package/dist/engine/feature-ast/extractors/index.js +1 -1
  25. package/dist/engine/feature-ast/extractors/jobs-routes.js +6 -2
  26. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -1
  27. package/dist/engine/feature-ast/extractors/round3.js +11 -0
  28. package/dist/engine/feature-ast/extractors/shared.js +1 -3
  29. package/dist/engine/feature-ast/index.d.ts +1 -1
  30. package/dist/engine/feature-ast/parse.js +3 -1
  31. package/dist/engine/feature-ast/patch.d.ts +3 -0
  32. package/dist/engine/feature-ast/patch.js +8 -11
  33. package/dist/engine/feature-ast/patcher.d.ts +6 -1
  34. package/dist/engine/feature-ast/patcher.js +3 -0
  35. package/dist/engine/feature-ast/pattern-change-schema.d.ts +590 -1
  36. package/dist/engine/feature-ast/pattern-change-schema.js +17 -2
  37. package/dist/engine/feature-ast/patterns.d.ts +8 -2
  38. package/dist/engine/feature-ast/patterns.js +1 -0
  39. package/dist/engine/feature-ast/render.js +9 -0
  40. package/dist/engine/feature-builder-state.d.ts +2 -1
  41. package/dist/engine/feature-builder-state.js +1 -0
  42. package/dist/engine/feature-config-events-jobs.d.ts +4 -1
  43. package/dist/engine/feature-config-events-jobs.js +20 -0
  44. package/dist/engine/feature-ui-extensions.js +15 -21
  45. package/dist/engine/index.d.ts +1 -1
  46. package/dist/engine/pattern-library/library.js +2 -1
  47. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  48. package/dist/engine/pattern-library/static-schemas.js +47 -4
  49. package/dist/engine/registry-facade.js +7 -0
  50. package/dist/engine/registry-ingest.js +7 -0
  51. package/dist/engine/registry-state.d.ts +2 -1
  52. package/dist/engine/registry-state.js +1 -0
  53. package/dist/engine/registry-validate.d.ts +1 -1
  54. package/dist/engine/registry-validate.js +39 -17
  55. package/dist/engine/registry.js +2 -2
  56. package/dist/engine/types/index.d.ts +2 -2
  57. package/dist/engine/types/index.js +1 -1
  58. package/dist/engine/types/screen.d.ts +1 -1
  59. package/dist/engine/types/screen.js +1 -1
  60. package/dist/errors/classes.d.ts +5 -0
  61. package/dist/errors/classes.js +15 -0
  62. package/dist/errors/index.d.ts +1 -1
  63. package/dist/errors/index.js +1 -1
  64. package/dist/i18n/required-surface-keys.js +3 -5
  65. package/dist/migrations/pending-rebuilds.d.ts +2 -0
  66. package/dist/migrations/pending-rebuilds.js +3 -0
  67. package/dist/pipeline/dispatch-query.js +3 -5
  68. package/dist/pipeline/dispatch-stream.js +2 -4
  69. package/dist/pipeline/dispatch-write.js +2 -4
  70. package/dist/pipeline/event-dispatcher-delivery.d.ts +0 -1
  71. package/dist/pipeline/event-dispatcher-delivery.js +1 -8
  72. package/dist/pipeline/event-dispatcher.js +16 -25
  73. package/dist/pipeline/handler-access-error.d.ts +3 -0
  74. package/dist/pipeline/handler-access-error.js +17 -0
  75. package/dist/pipeline/irreversible-operation-gate.js +9 -3
  76. package/dist/pipeline/member-read-only-transaction.js +8 -3
  77. package/dist/pipeline/system-hooks.js +7 -1
  78. package/dist/pipeline/system-identity-switch.js +1 -1
  79. package/dist/search/meilisearch-adapter.js +0 -2
  80. package/dist/stack/push-entity-projection-tables.js +5 -3
  81. package/dist/stack/redis.d.ts +1 -0
  82. package/dist/stack/redis.js +11 -2
  83. package/dist/stack/table-helpers.d.ts +3 -1
  84. package/dist/stack/table-helpers.js +9 -7
  85. package/dist/stack/test-stack.js +1 -7
  86. package/dist/testing/handler-context.js +3 -6
  87. package/dist/ui-types/index.d.ts +1 -1
  88. package/dist/ui-types/index.js +1 -1
  89. package/dist/upgrade-cli.js +29 -2
  90. package/package.json +4 -4
  91. package/src/changes.json +92 -0
  92. package/src/scripts/codemod/collect-source-files.ts +48 -0
  93. package/src/scripts/codemod/migrate-cross-tenant.ts +3 -23
  94. package/src/scripts/codemod/migrate-db-raw.ts +6 -23
  95. package/src/scripts/codemod/migrate-open-to-all.ts +4 -24
  96. package/src/scripts/codemod/unprocessable-error-details-reason.ts +47 -3
@@ -33,15 +33,29 @@ function middlewareReject(c, opts) {
33
33
  },
34
34
  }, opts.status);
35
35
  }
36
- // @wrapper-known error-helper
37
- function sessionInvalid(c, reason) {
38
- return middlewareReject(c, {
36
+ function sessionInvalidRejection(reason) {
37
+ return {
39
38
  code: "session_invalid",
40
39
  status: 401,
41
40
  message: `session ${reason}`,
42
41
  i18nKey: "auth.errors.sessionInvalid",
43
42
  details: { reason },
44
- });
43
+ };
44
+ }
45
+ const INVALID_TOKEN_REJECTION = {
46
+ code: "invalid_token",
47
+ status: 401,
48
+ message: "token verification failed",
49
+ i18nKey: "auth.errors.invalidToken",
50
+ };
51
+ // A stale auth cookie (revoked session, rotated signing key) must not lock the
52
+ // browser out of public handlers until the cookie expires, so with
53
+ // anonymousAccess on the request continues anonymously and protected handlers
54
+ // answer 401 unauthenticated in the dispatcher. Bearer clients own their header
55
+ // and keep the explicit reject; /api/auth/* keeps it for the same reason as the
56
+ // missing-token branch.
57
+ function isStaleCookieDowngradable(c, transport, anonymousAccess) {
58
+ return (transport === "cookie" && anonymousAccess !== undefined && !c.req.path.startsWith("/api/auth/"));
45
59
  }
46
60
  // Extract the JWT from either the kumiko_auth cookie (web) or the
47
61
  // Authorization Bearer header (native / server-to-server). The two paths
@@ -136,17 +150,20 @@ export function authMiddleware(jwt, options = {}) {
136
150
  return await handleVerifiedBearerUser(c, verifiedUser, next, resolveTenantLifecycleStatus);
137
151
  }
138
152
  }
153
+ const rejectInvalidCredential = async (rejection) => {
154
+ if (isStaleCookieDowngradable(c, transport, anonymousAccess)) {
155
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, {
156
+ rejectionIfTenantUnresolved: rejection,
157
+ });
158
+ }
159
+ return middlewareReject(c, rejection);
160
+ };
139
161
  let payload;
140
162
  try {
141
163
  payload = await jwt.verify(token);
142
164
  }
143
165
  catch {
144
- return middlewareReject(c, {
145
- code: "invalid_token",
146
- status: 401,
147
- message: "token verification failed",
148
- i18nKey: "auth.errors.invalidToken",
149
- });
166
+ return await rejectInvalidCredential(INVALID_TOKEN_REJECTION);
150
167
  }
151
168
  // Session liveness check — only when both a checker is wired AND the
152
169
  // token carries a sid.
@@ -158,14 +175,14 @@ export function authMiddleware(jwt, options = {}) {
158
175
  const result = await sessionChecker(payload.jti, payload.sub);
159
176
  const status = sessionCheckStatus(result);
160
177
  if (status !== "live") {
161
- return sessionInvalid(c, status);
178
+ return await rejectInvalidCredential(sessionInvalidRejection(status));
162
179
  }
163
180
  if (typeof result === "object") {
164
181
  derivedRoles = result.roles;
165
182
  }
166
183
  }
167
184
  else {
168
- return sessionInvalid(c, "no_sid");
185
+ return await rejectInvalidCredential(sessionInvalidRejection("no_sid"));
169
186
  }
170
187
  }
171
188
  // Tenant-mismatch guard: if the caller sends BOTH a JWT (carrying a
@@ -254,17 +271,21 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
254
271
  // 5. Synthesise the anonymous SessionUser. The transport flag stays unset
255
272
  // so csrf-middleware skips the double-submit check (no auth-cookie ⇒
256
273
  // no CSRF vector to defend against).
257
- async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
274
+ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, staleCredential) {
275
+ // A downgraded stale credential whose anonymous tenant can't be resolved or
276
+ // is torn down answers with the credential's own 401: a tenant error would
277
+ // hide the session end from the client.
278
+ const rejectTenant = (tenantError) => middlewareReject(c, staleCredential?.rejectionIfTenantUnresolved ?? tenantError);
258
279
  // Step 1+2: parse client-supplied tenant. Reject malformed values before
259
280
  // they touch any downstream consumer (DB, cache, audit row).
260
281
  const headerRaw = c.req.header(TENANT_HEADER_NAME);
261
282
  const cookieRaw = getCookie(c, TENANT_COOKIE_NAME);
262
283
  const headerCheck = parseClientTenant(headerRaw, "X-Tenant header");
263
284
  if (headerCheck.error)
264
- return middlewareReject(c, headerCheck.error);
285
+ return rejectTenant(headerCheck.error);
265
286
  const cookieCheck = parseClientTenant(cookieRaw, "kumiko_tenant cookie");
266
287
  if (cookieCheck.error)
267
- return middlewareReject(c, cookieCheck.error);
288
+ return rejectTenant(cookieCheck.error);
268
289
  const clientTenant = headerCheck.tenantId !== null
269
290
  ? { id: headerCheck.tenantId, source: "header" }
270
291
  : cookieCheck.tenantId !== null
@@ -273,12 +294,12 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
273
294
  // Step 3: pick the authoritative tenant.
274
295
  const resolved = await resolveTenant(c, config, clientTenant);
275
296
  if ("error" in resolved)
276
- return middlewareReject(c, resolved.error);
297
+ return rejectTenant(resolved.error);
277
298
  // Step 4: existence check for untrusted sources.
278
299
  if (resolved.source !== "default" && config.tenantExists) {
279
300
  const exists = await config.tenantExists(resolved.tenantId);
280
301
  if (!exists) {
281
- return middlewareReject(c, {
302
+ return rejectTenant({
282
303
  code: "tenant_not_found",
283
304
  status: 404,
284
305
  message: `tenant "${resolved.tenantId}" does not exist`,
@@ -289,8 +310,11 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus) {
289
310
  }
290
311
  // Step 5: synthesise + continue.
291
312
  const lifecycleReject = await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
292
- if (lifecycleReject)
293
- return lifecycleReject;
313
+ if (lifecycleReject) {
314
+ return staleCredential
315
+ ? middlewareReject(c, staleCredential.rejectionIfTenantUnresolved)
316
+ : lifecycleReject;
317
+ }
294
318
  c.set(USER_KEY, createAnonymousUser(resolved.tenantId));
295
319
  await next();
296
320
  // skip: anonymous path completed — Hono middleware contract returns void
@@ -19,6 +19,7 @@ export type SessionRevoker = (sid: string) => Promise<void>;
19
19
  export type SessionMassRevoker = (userId: string) => Promise<number>;
20
20
  export type SessionChecker = AuthSessionChecker;
21
21
  export type { AuthSessionStatus };
22
+ export declare function assertSessionCallbacksPaired(auth: Pick<AuthRoutesConfig, "sessionCreator" | "sessionRevoker" | "sessionChecker"> | undefined): void;
22
23
  export type AuthRoutesConfig = {
23
24
  membershipQuery: string;
24
25
  userQuery?: string;
@@ -159,6 +159,26 @@ function switchTenantRejectionResponse(c, reason) {
159
159
  return assertUnreachable(reason, "ActiveMembershipRejection");
160
160
  }
161
161
  }
162
+ // A sid minted by sessionCreator is only revocable when logout can revoke it
163
+ // (sessionRevoker) AND every request re-checks it (sessionChecker). Wiring the
164
+ // first without the other two would make logout look effective while the JWT
165
+ // stays valid until expiry. A checker alone stays allowed: it fails closed.
166
+ export function assertSessionCallbacksPaired(auth) {
167
+ const issuesSessions = auth?.sessionCreator !== undefined || auth?.sessionRevoker !== undefined;
168
+ const missing = issuesSessions
169
+ ? [
170
+ ...(auth?.sessionCreator === undefined ? ["sessionCreator"] : []),
171
+ ...(auth?.sessionRevoker === undefined ? ["sessionRevoker"] : []),
172
+ ...(auth?.sessionChecker === undefined ? ["sessionChecker"] : []),
173
+ ]
174
+ : [];
175
+ if (missing.length > 0) {
176
+ throw new Error(`[kumiko:boot] auth session callbacks are wired partially — missing ${missing.join(", ")}. ` +
177
+ "Server sessions need sessionCreator, sessionRevoker and sessionChecker together, " +
178
+ "otherwise logout leaves the JWT valid until it expires. Wire all three (the sessions " +
179
+ "feature's sessionStore provides them) or none for stateless JWTs.");
180
+ }
181
+ }
162
182
  // Second rate-limit axis for preauth-enable-start: unlike the IP, a
163
183
  // preauthSetupToken isn't freely choosable by the attacker (it's minted by
164
184
  // login.write.ts), so hashing it into a bucket key still caps the replay
@@ -5,6 +5,7 @@ export type RedisSseBrokerOptions = {
5
5
  readonly clientOptions?: RedisClientOptions;
6
6
  };
7
7
  export type RedisSseBroker = SseBroker & {
8
+ readonly kind: "redis";
8
9
  close(): Promise<void>;
9
10
  };
10
11
  export declare function isRedisSseBroker(broker: SseBroker): broker is RedisSseBroker;
@@ -9,12 +9,10 @@ import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
9
9
  const CHANNEL_PREFIX = "kumiko:sse:ch:";
10
10
  const INVALIDATION_PREFIX = "kumiko:sse:inval:";
11
11
  const PSUBSCRIBE_PATTERN = "kumiko:sse:*";
12
- // The one property RedisSseBroker adds over SseBroker — cheap and reliable
13
- // to narrow on, so callers that only hold an SseBroker (e.g. a test that
14
- // pulled it back out of a generic ServerOptions.sseBroker slot) can tell
15
- // which lifecycle they own without an unsound `as` cast.
12
+ // Narrows on the `kind` discriminant, not on `close`: an app-owned SseBroker
13
+ // may have its own close() and must not be mistaken for the Redis one.
16
14
  export function isRedisSseBroker(broker) {
17
- return "close" in broker;
15
+ return "kind" in broker && broker.kind === "redis";
18
16
  }
19
17
  // Single decision point for "which SseBroker should a caller default to
20
18
  // when it doesn't inject its own": REDIS_URL present → cross-replica
@@ -117,6 +115,7 @@ export function createRedisSseBroker(opts) {
117
115
  }
118
116
  });
119
117
  return {
118
+ kind: "redis",
120
119
  addClient: inner.addClient,
121
120
  removeClient: inner.removeClient,
122
121
  getClientCount: inner.getClientCount,
@@ -25,7 +25,7 @@ import { assertUnreachable, generateId } from "../utils/index.js";
25
25
  import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
26
26
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
27
27
  import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
28
- import { createAuthRoutes } from "./auth-routes.js";
28
+ import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
29
29
  import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
30
30
  import { csrfMiddleware } from "./csrf-middleware.js";
31
31
  import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
@@ -505,6 +505,7 @@ export function buildServer(options) {
505
505
  // without an allowlist and without an explicit opt-out — that config is the
506
506
  // unguarded-subdomain-XSS footgun, not a warn-and-continue case.
507
507
  assertOriginGuardConfig(options.auth);
508
+ assertSessionCallbacksPaired(options.auth);
508
509
  const allowedOrigins = options.auth?.allowedOrigins;
509
510
  const originGuard = allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
510
511
  if (originGuard) {
@@ -527,11 +528,10 @@ export function buildServer(options) {
527
528
  return next();
528
529
  return csrfGuard(c, next);
529
530
  });
530
- // Same order as /api/* above: auth → PAT route scope → PAT rate-limit → origin → CSRF.
531
+ // No PAT rate-limit here: patRouteGuardMiddleware rejects every PAT on httpRoutes before it could count.
531
532
  const sessionOnlyHttpRouteGuards = [
532
533
  sessionOnlyGuard,
533
534
  patRouteGuardMiddleware,
534
- ...(patRateLimitGuard ? [patRateLimitGuard] : []),
535
535
  ...(originGuard ? [originGuard] : []),
536
536
  csrfGuard,
537
537
  ];
@@ -45,6 +45,16 @@ function toEntitySignalWire(event, data) {
45
45
  createdAt: data["createdAt"],
46
46
  };
47
47
  }
48
+ // The signal covers every row of the entity, including rows the anonymous
49
+ // query hides, so id and version would leak which hidden rows exist and
50
+ // change. Anonymous clients refetch the whole query anyway.
51
+ function toAnonymousEntitySignalWire(event, data) {
52
+ return {
53
+ aggregateType: data.aggregateType,
54
+ eventType: event.type,
55
+ createdAt: data["createdAt"],
56
+ };
57
+ }
48
58
  // The tenant channel fans out to every member, so a frame without an entity
49
59
  // (e.g. channel-in-app:event:delivered) reaches only its addressee; a frame
50
60
  // lacking userId is dropped (fail-closed).
@@ -61,7 +71,10 @@ function decideWireFrame(event, user, options) {
61
71
  // skip: anonymous connections only get signals for entities an anonymous query declares
62
72
  if (!mayReceiveEntitySignal(user, data.aggregateType, options))
63
73
  return undefined;
64
- return { name: data.aggregateType, data: toEntitySignalWire(event, data) };
74
+ const signal = isAnonymousSessionUser(user)
75
+ ? toAnonymousEntitySignalWire(event, data)
76
+ : toEntitySignalWire(event, data);
77
+ return { name: data.aggregateType, data: signal };
65
78
  }
66
79
  // skip: frames without an entity belong to their addressee only
67
80
  if (!isAddressedTo(event, user.id))
@@ -735,10 +735,11 @@ async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
735
735
  throw err;
736
736
  }
737
737
  }
738
+ const DEFAULT_POOL_MAX = 10;
738
739
  function poolMaxOf(raw) {
739
740
  // @cast-boundary driver pool options — both postgres-js and Bun.SQL expose options.max
740
741
  const max = raw.options?.max;
741
- return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 10;
742
+ return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
742
743
  }
743
744
  // Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
744
745
  // db/queries/*.ts) can opt into the same closed-connection retry instead of
@@ -754,9 +755,10 @@ export async function unsafeReadRetrying(db, sqlText, params) {
754
755
  logClosedConnection(raw, err, 1);
755
756
  throw err;
756
757
  }
757
- const maxAttempts = poolMaxOf(raw) + 1;
758
+ // Enough retries to flush every dead pooled connection plus one fresh one; total calls = maxRetries + 1.
759
+ const maxRetries = poolMaxOf(raw) + 1;
758
760
  let lastErr = err;
759
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
761
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
760
762
  try {
761
763
  return (await raw.unsafe(sqlText, params));
762
764
  }
@@ -768,7 +770,7 @@ export async function unsafeReadRetrying(db, sqlText, params) {
768
770
  }
769
771
  }
770
772
  }
771
- logClosedConnection(raw, lastErr, maxAttempts + 1);
773
+ logClosedConnection(raw, lastErr, maxRetries + 1);
772
774
  throw lastErr;
773
775
  }
774
776
  }
@@ -43,6 +43,7 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
43
43
  }
44
44
  if (!response.ok) {
45
45
  if (isRetryableStatus(response.status) && attempt < maxAttempts) {
46
+ await response.body?.cancel();
46
47
  await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
47
48
  continue;
48
49
  }
@@ -55,7 +55,7 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
55
55
  async function existingTableNames(db, tableNames) {
56
56
  if (tableNames.length === 0)
57
57
  return new Set();
58
- const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass(quote_ident(t.name)) IS NOT NULL`, [tableNames]);
58
+ const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [tableNames]);
59
59
  return new Set(rows.map((r) => r.name));
60
60
  }
61
61
  // Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
@@ -249,11 +249,16 @@ function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
249
249
  }
250
250
  return descriptors;
251
251
  }
252
+ // Matches the main list's own default: soft-deleted rows are invisible, so a
253
+ // deleted target must neither match a search nor influence an ordering.
254
+ function isSoftDeletedTargetHidden(targetEntity, targetTable) {
255
+ return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
256
+ }
252
257
  // Tenant-scoped ILIKE lookup against one reference target — never raw/
253
258
  // unscoped SQL, a match in a foreign tenant must never surface a row here
254
259
  // (fw#2660's hard constraint). Mirrors the main table's own tenant filter
255
260
  // (see the `table["tenantId"]` check further down in list()).
256
- async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db) {
261
+ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db) {
257
262
  const labelCol = physicalColumnName(targetTable, descriptor.labelField);
258
263
  const subParams = [`%${escapeLikePattern(searchTerm)}%`];
259
264
  let tenantClause = "";
@@ -261,9 +266,12 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTab
261
266
  subParams.push(db.tenantId, SYSTEM_TENANT_ID);
262
267
  tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
263
268
  }
269
+ const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
270
+ ? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
271
+ : "";
264
272
  // ::text cast covers a non-text labelField (e.g. a number/select column
265
273
  // used as label) — Postgres has no ILIKE for those types otherwise.
266
- const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
274
+ const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${deletedClause} ` +
267
275
  `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
268
276
  const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
269
277
  if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
@@ -320,7 +328,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
320
328
  const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
321
329
  const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
322
330
  if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
323
- return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db);
331
+ return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db);
324
332
  }
325
333
  if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
326
334
  // skip: no plaintext anywhere to match — boot rejects this normally,
@@ -373,6 +381,9 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
373
381
  const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
374
382
  const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
375
383
  const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
384
+ const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
385
+ ? ` AND t."${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
386
+ : "";
376
387
  // No ::text cast (unlike the ILIKE search path): a non-text label must keep
377
388
  // its native collation/numeric order, and the keyset param infers its type
378
389
  // from the comparison anyway.
@@ -383,7 +394,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
383
394
  tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
384
395
  }
385
396
  return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
386
- `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause})`);
397
+ `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
387
398
  };
388
399
  }
389
400
  export function createReadVerbs(ctx) {
@@ -1,4 +1,4 @@
1
- import type { PendingGapEntry } from "../../pipeline/event-consumer-state.js";
1
+ import type { ConsumerStatuses, PendingGapEntry } from "../../pipeline/event-consumer-state.js";
2
2
  import type { AnyDb } from "../query.js";
3
3
  export declare function selectSnapshotXmin(db: AnyDb): Promise<string>;
4
4
  export declare function selectSnapshotXmax(db: AnyDb): Promise<string>;
@@ -10,7 +10,7 @@ export declare function selectEventIdHorizonWithMissingRanges(db: AnyDb): Promis
10
10
  }>;
11
11
  export declare function insertConsumerIfAbsent(db: AnyDb, name: string, instanceId: string, startFrom?: "beginning" | "now"): Promise<void>;
12
12
  export declare function deleteOrphanedPerInstanceConsumerRows(db: AnyDb, consumerNames: readonly string[], sharedInstanceSentinel: string): Promise<void>;
13
- export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus: string): Promise<ReadonlyArray<{
13
+ export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus: typeof ConsumerStatuses.dead): Promise<ReadonlyArray<{
14
14
  readonly name: string;
15
15
  readonly instanceId: string;
16
16
  }>>;
@@ -112,10 +112,11 @@ sharedInstanceSentinel) {
112
112
  // exists past its cursor. Paired via unnest so the two positional arrays
113
113
  // zip element-wise into rows instead of a cartesian product.
114
114
  //
115
- // `deadStatus` is passed in rather than imported from event-consumer-state.ts
116
- // (which defines ConsumerStatuses) — that module already imports from this
117
- // one, so importing back would be a require cycle (same pattern as
118
- // deleteOrphanedPerInstanceConsumerRows above).
115
+ // `deadStatus` is passed in rather than imported as a value from
116
+ // event-consumer-state.ts (which defines ConsumerStatuses) — that module
117
+ // already imports from this one, so a value import back would be a require
118
+ // cycle (same pattern as deleteOrphanedPerInstanceConsumerRows above). The
119
+ // type-only import keeps the parameter pinned to the "dead" literal.
119
120
  export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
120
121
  const rows = (await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
121
122
  FROM "kumiko_event_consumers" c
@@ -130,16 +130,16 @@ export async function insertSubsequentEventRow(db, params) {
130
130
  return rows[0];
131
131
  }
132
132
  export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
133
- const rows = (await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]));
133
+ const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]);
134
134
  return rows[0]?.v ?? 0;
135
135
  }
136
136
  /** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
137
137
  export async function selectAggregateMaxVersion(db, aggregateId) {
138
- const rows = (await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]));
138
+ const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]);
139
139
  return rows[0]?.v ?? 0;
140
140
  }
141
141
  export async function selectEventsHighWaterMark(db) {
142
- const rows = (await unsafeReadRetrying(db, `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`, []));
142
+ const rows = await unsafeReadRetrying(db, `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`, []);
143
143
  const raw = rows[0]?.max;
144
144
  if (typeof raw === "bigint")
145
145
  return raw;
@@ -153,7 +153,7 @@ export async function selectEventsHeadId(db) {
153
153
  return selectEventsHighWaterMark(db);
154
154
  }
155
155
  export async function selectNextEventIdAfter(db, afterId) {
156
- const rows = (await unsafeReadRetrying(db, `SELECT "id" FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT 1`, [afterId]));
156
+ const rows = await unsafeReadRetrying(db, `SELECT "id" FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT 1`, [afterId]);
157
157
  const row = rows[0];
158
158
  if (!row)
159
159
  return null;
@@ -222,10 +222,10 @@ export async function selectPendingAndNewEventRows(db, cursor, pendingRanges, ba
222
222
  export async function selectSmallestVisibleIdInRanges(db, pendingRanges) {
223
223
  if (pendingRanges.length === 0)
224
224
  return null;
225
- const rows = (await unsafeReadRetrying(db, `SELECT MIN("id")::text AS id FROM "kumiko_events"
225
+ const rows = await unsafeReadRetrying(db, `SELECT MIN("id")::text AS id FROM "kumiko_events"
226
226
  WHERE EXISTS (
227
227
  SELECT 1 FROM unnest($1::bigint[], $2::bigint[]) AS g(f, t) WHERE "id" BETWEEN g.f AND g.t
228
- )`, [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())]));
228
+ )`, [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())]);
229
229
  const id = rows[0]?.id;
230
230
  return id === null || id === undefined ? null : BigInt(id);
231
231
  }
@@ -14,7 +14,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
14
14
  // Archived streams don't replay (Marten-aligned): their aggregates are
15
15
  // frozen ops-tombstones — replaying them would resurrect rows (or, for
16
16
  // stranded duplicate-aggregates like fw#832, collide on unique indexes).
17
- return (await unsafeReadRetrying(db, `SELECT * FROM "kumiko_events" e
17
+ return unsafeReadRetrying(db, `SELECT * FROM "kumiko_events" e
18
18
  WHERE e."aggregate_type" = ANY($1::text[])
19
19
  AND e."type" = ANY($2::text[])
20
20
  AND e."id" > $3
@@ -23,7 +23,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
23
23
  WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
24
24
  )
25
25
  ORDER BY e."id" ASC
26
- LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit]));
26
+ LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit]);
27
27
  }
28
28
  // Total subscribed events in the log — same source/type filter as
29
29
  // selectEventsForProjectionRebuildBatch, no id cursor. Under the cutover fence
@@ -33,13 +33,13 @@ export async function countSubscribedEvents(db, aggregateTypes, eventTypes) {
33
33
  // Same archived-streams exclusion as the batch query — the #443 recompute
34
34
  // compares this count against applied events; a filter mismatch would make
35
35
  // every rebuild with an archived stream loop the full-re-replay forever.
36
- const rows = (await unsafeReadRetrying(db, `SELECT count(*)::bigint AS n FROM "kumiko_events" e
36
+ const rows = await unsafeReadRetrying(db, `SELECT count(*)::bigint AS n FROM "kumiko_events" e
37
37
  WHERE e."aggregate_type" = ANY($1::text[])
38
38
  AND e."type" = ANY($2::text[])
39
39
  AND NOT EXISTS (
40
40
  SELECT 1 FROM "kumiko_archived_streams" a
41
41
  WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
42
- )`, [aggregateTypes, eventTypes]));
42
+ )`, [aggregateTypes, eventTypes]);
43
43
  const raw = rows[0]?.n;
44
44
  if (typeof raw === "bigint")
45
45
  return raw;
@@ -1,25 +1,25 @@
1
1
  import { unsafeReadRetrying } from "../query.js";
2
2
  export async function selectUserByEmail(db, email) {
3
- const rows = (await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
3
+ const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
4
4
  FROM read_users
5
5
  WHERE email = $1
6
- LIMIT 1`, [email]));
6
+ LIMIT 1`, [email]);
7
7
  const row = rows[0];
8
8
  if (!row)
9
9
  return null;
10
10
  return { id: row.id, email: row.email, tenantId: row.tenant_id };
11
11
  }
12
12
  export async function selectMembershipsOfUser(db, userId) {
13
- return (await unsafeReadRetrying(db, `SELECT m.user_id::text AS user_id,
13
+ return unsafeReadRetrying(db, `SELECT m.user_id::text AS user_id,
14
14
  m.tenant_id::text AS tenant_id,
15
15
  e.tenant_id::text AS stream_tenant_id,
16
16
  m.roles
17
17
  FROM read_tenant_memberships m
18
18
  JOIN kumiko_events e ON e.aggregate_id = m.id AND e.version = 1
19
- WHERE m.user_id = $1`, [userId]));
19
+ WHERE m.user_id = $1`, [userId]);
20
20
  }
21
21
  export async function selectAllTenants(db) {
22
- return (await unsafeReadRetrying(db, `SELECT id::text AS id, name, key AS tenant_key
22
+ return unsafeReadRetrying(db, `SELECT id::text AS id, name, key AS tenant_key
23
23
  FROM read_tenants
24
- ORDER BY inserted_at`, []));
24
+ ORDER BY inserted_at`, []);
25
25
  }
@@ -97,8 +97,11 @@ function resolveOverlayLayer(layer, qrContext, tenantId) {
97
97
  // the spec, so a resolver whose target changes (e.g. a base-URL rotation)
98
98
  // invalidates the cache instead of serving a stale target under the old key.
99
99
  async function resolveOverlaySpec(spec, row, registry, tenantId) {
100
- if (!spec.overlays || spec.overlays.length === 0)
101
- return spec;
100
+ // `resolvedOverlays` is framework-internal; a caller-supplied value must
101
+ // never reach the renderer past the token resolution below.
102
+ if (!spec.overlays || spec.overlays.length === 0) {
103
+ return spec.resolvedOverlays === undefined ? spec : { ...spec, resolvedOverlays: undefined };
104
+ }
102
105
  const qrContext = spec.overlays.some((layer) => layer.kind === "qr")
103
106
  ? resolveQrOverlayContext(row, registry)
104
107
  : undefined;
@@ -1,4 +1,4 @@
1
- import type { FeatureDefinition, QueryHandlerDef } from "../types/index.js";
1
+ import { type FeatureDefinition, type QueryHandlerDef } from "../types/index.js";
2
2
  export declare function buildQueryHandlerMap(features: readonly FeatureDefinition[]): ReadonlyMap<string, QueryHandlerDef>;
3
3
  export declare function validateProjectionListScreens(features: readonly FeatureDefinition[]): void;
4
4
  export declare function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void;
@@ -1,6 +1,7 @@
1
1
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { parseQn, QnTypes, qualifyEntityName } from "../qualified-name.js";
3
3
  import { isEditLayoutScreen } from "../screen-helpers.js";
4
+ import { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../types/index.js";
4
5
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
5
6
  import { findEntity } from "./parent-ref.js";
6
7
  import { getZodObjectShape } from "./zod-shape.js";
@@ -198,6 +199,17 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
198
199
  `entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`);
199
200
  }
200
201
  }
202
+ // A collapsed group without a header has no toggle, so its rows could never be opened.
203
+ function validateRelatedListGroupBy(prefix, section) {
204
+ const groupBy = section.groupBy;
205
+ const collapsedKey = groupBy?.collapsedWhen === undefined ? undefined : relatedListGroupKey(groupBy.collapsedWhen);
206
+ if (groupBy !== undefined &&
207
+ collapsedKey !== undefined &&
208
+ relatedListGroupHeaderLabel(groupBy, collapsedKey) === undefined) {
209
+ throw new Error(`${prefix}: groupBy.collapsedWhen "${collapsedKey}" names a group without a header; ` +
210
+ `set groupBy.label or groupBy.labels["${collapsedKey}"], or drop collapsedWhen.`);
211
+ }
212
+ }
201
213
  // relatedList sections declare search/facets the same way a projectionList
202
214
  // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
203
215
  // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
@@ -214,6 +226,7 @@ export function validateRelatedListSectionQueries(features) {
214
226
  validateRelatedListSearchable(prefix, section, schema);
215
227
  validateRelatedListFacets(prefix, section, schema);
216
228
  validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
229
+ validateRelatedListGroupBy(prefix, section);
217
230
  };
218
231
  for (const feature of features) {
219
232
  for (const screen of Object.values(feature.screens)) {
@@ -620,6 +620,15 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
620
620
  }
621
621
  validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
622
622
  }
623
+ // redirect is only honored on record actions; on list/related-list row
624
+ // actions it would be silently dropped, so the author would believe it works.
625
+ function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action) {
626
+ if (action.kind !== "navigate" && action.kind !== "drawer" && action.redirect !== undefined) {
627
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) rowAction "${action.id}" sets redirect — ` +
628
+ `redirect is only honored on record actions (projectionDetail/entityEdit header and section ` +
629
+ `actions) and is ignored on list row actions. Remove it.`);
630
+ }
631
+ }
623
632
  // A writeHandler record action's redirect (projectionDetail/entityEdit
624
633
  // header and section actions) follows the entityEdit.redirect rule.
625
634
  function validateRecordActionRedirect(feature, screenId, screenKind, action, allScreenQns) {
@@ -780,6 +789,7 @@ function validateRelatedListSection(args) {
780
789
  function validateRelatedListRowActions(args, rowActions) {
781
790
  const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
782
791
  for (const action of rowActions) {
792
+ rejectRedirectOnListRowAction(featureName, screenId, screenType, action);
783
793
  if (action.kind === "navigate") {
784
794
  const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
785
795
  validateRowActionNavigateParams(featureName, screenId, screenType, undefined, action, target);
@@ -899,6 +909,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
899
909
  }
900
910
  if (screen.rowActions !== undefined) {
901
911
  for (const action of screen.rowActions) {
912
+ rejectRedirectOnListRowAction(feature.name, screenId, "projectionList", action);
902
913
  if (action.kind === "navigate") {
903
914
  const target = resolveRowActionNavigateTarget(feature.name, screenId, "projectionList", "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
904
915
  validateRowActionNavigateParams(feature.name, screenId, "projectionList", undefined, action, target);
@@ -1414,6 +1425,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1414
1425
  // erst beim ersten Klick als "Screen not found" / 404 auf.
1415
1426
  if (screen.rowActions !== undefined) {
1416
1427
  for (const action of screen.rowActions) {
1428
+ rejectRedirectOnListRowAction(feature.name, screenId, "entityList", action);
1417
1429
  if (action.kind === "navigate") {
1418
1430
  const target = resolveRowActionNavigateTarget(feature.name, screenId, "entityList", "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1419
1431
  // The renderer's default-entityId fallback (row["id"]) only fires