@cosmicdrift/kumiko-framework 0.335.0 → 0.336.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 (49) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/index.d.ts +2 -1
  4. package/dist/api/index.js +1 -0
  5. package/dist/api/routes.js +1 -1
  6. package/dist/api/server.js +6 -3
  7. package/dist/api/sse-route.d.ts +4 -1
  8. package/dist/api/sse-route.js +38 -11
  9. package/dist/crypto/event-pii.js +3 -3
  10. package/dist/db/blind-index-cleanup.js +27 -16
  11. package/dist/db/event-store-executor-read.js +6 -2
  12. package/dist/engine/boot-validator/access-declarations.js +2 -1
  13. package/dist/engine/boot-validator/query-output-columns.js +12 -0
  14. package/dist/engine/boot-validator/screens.js +22 -13
  15. package/dist/engine/build-app-schema.js +5 -2
  16. package/dist/engine/create-app.js +10 -1
  17. package/dist/engine/define-handler.js +3 -5
  18. package/dist/engine/extension-names.d.ts +4 -0
  19. package/dist/engine/extension-names.js +4 -0
  20. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  21. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  22. package/dist/engine/feature-entity-handlers.js +2 -0
  23. package/dist/engine/registry-validate.d.ts +1 -0
  24. package/dist/engine/registry-validate.js +14 -0
  25. package/dist/engine/registry.js +2 -1
  26. package/dist/engine/steps/_event-match.js +12 -4
  27. package/dist/engine/system-user.d.ts +1 -0
  28. package/dist/engine/system-user.js +3 -0
  29. package/dist/env/dry-run.js +10 -3
  30. package/dist/event-store/event-store.js +4 -1
  31. package/dist/jobs/job-runner.js +29 -7
  32. package/dist/migrations/pending-rebuilds.js +4 -3
  33. package/dist/observability/index.d.ts +1 -1
  34. package/dist/observability/index.js +1 -1
  35. package/dist/observability/standard-metrics.d.ts +4 -0
  36. package/dist/observability/standard-metrics.js +16 -0
  37. package/dist/pipeline/dispatch-batch.js +2 -2
  38. package/dist/pipeline/dispatcher.js +1 -1
  39. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  40. package/dist/pipeline/event-dispatcher.js +4 -1
  41. package/dist/pipeline/system-hooks.js +10 -13
  42. package/dist/pipeline/write-origin.js +13 -6
  43. package/dist/stack/drain-event-consumers.js +3 -0
  44. package/dist/stack/drain-jobs.d.ts +3 -1
  45. package/dist/stack/drain-jobs.js +31 -1
  46. package/package.json +4 -4
  47. package/src/changes.json +64 -0
  48. package/src/scripts/codemod/pii-personal-migration.ts +65 -45
  49. package/src/scripts/codemod/unprocessable-error-details-reason.ts +61 -24
@@ -0,0 +1,2 @@
1
+ import type { Registry } from "../engine/types/index.js";
2
+ export declare function collectAnonymousLiveEntities(registry: Registry): ReadonlySet<string>;
@@ -0,0 +1,17 @@
1
+ import { hasAccess } from "../engine/access.js";
2
+ import { ANONYMOUS_ROLE } from "../engine/system-user.js";
3
+ const ANONYMOUS_PRINCIPAL = { roles: [ANONYMOUS_ROLE] };
4
+ // Entities an anonymous /api/sse connection may receive change signals for:
5
+ // only those an anonymously callable query declares via liveEntities. No
6
+ // inference from the query name, because a signal carries the id of every
7
+ // row of the entity, including rows that query filters out (drafts).
8
+ export function collectAnonymousLiveEntities(registry) {
9
+ const entities = new Set();
10
+ for (const handler of registry.getAllQueryHandlers().values()) {
11
+ if (!hasAccess(ANONYMOUS_PRINCIPAL, handler.access))
12
+ continue;
13
+ for (const entityName of handler.liveEntities ?? [])
14
+ entities.add(entityName);
15
+ }
16
+ return entities;
17
+ }
@@ -1,5 +1,6 @@
1
1
  export type { SetTenantCookieOptions } from "./anonymous-cookie.js";
2
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
3
+ export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
3
4
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
4
5
  export type { AnonymousAccessConfig, AnonymousAccessResolved, AuthMiddlewareOptions, AuthSessionChecker, AuthSessionCheckResult, AuthSessionStatus, TenantExists, TenantLifecycleStatusResolver, TenantResolver, TokenVerifier, } from "./auth-middleware.js";
5
6
  export { AUTH_COOKIE_NAME, authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
@@ -26,7 +27,7 @@ export type { KumikoServer, ServerOptions } from "./server.js";
26
27
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
27
28
  export type { SseBroker, SseClient, SseEvent } from "./sse-broker.js";
28
29
  export { createSseBroker } from "./sse-broker.js";
29
- export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
30
+ export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS, type SseRouteOptions } from "./sse-route.js";
30
31
  export { generateToken } from "./tokens.js";
31
32
  export type { KumikoServeEnv, KumikoServerWebSocket, KumikoWebSocketData, WebSocketUpgradeServer, } from "./websocket-route.js";
32
33
  export { createKumikoWebSocketHandler, isWebSocketUpgradeRequest, kumikoWebSocketHandler, WEBSOCKET_BACKPRESSURE_LIMIT_BYTES, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_HEARTBEAT_INTERVAL_MS, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES, WEBSOCKET_REVALIDATION_FAILURE_LIMIT, WEBSOCKET_ROUTE_PATH_PREFIX, } from "./websocket-route.js";
package/dist/api/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
2
+ export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
2
3
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
3
4
  export { AUTH_COOKIE_NAME, authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
4
5
  export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
@@ -301,7 +301,7 @@ function clientFaultLoggingEnabled() {
301
301
  return !FAULT_LOG_SILENCED_LEVELS.has(process.env["LOG_LEVEL"] ?? "");
302
302
  }
303
303
  // A failing request must leave a trace even when it ends in 4xx — a paid
304
- // external call that 422s was invisible before (offlot#117). Status, error
304
+ // external call that 422s was invisible before. Status, error
305
305
  // code and duration only: message/details/stack can carry submitted values.
306
306
  function logClientWarn(message, requestId, type, fields) {
307
307
  if (!clientFaultLoggingEnabled()) {
@@ -5,7 +5,7 @@ import { buildAppSchema } from "../engine/build-app-schema.js";
5
5
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
6
6
  import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles.js";
7
7
  import { runsInLane } from "../engine/run-in.js";
8
- import { ANONYMOUS_ROLE, createAnonymousUser, createSystemUser } from "../engine/system-user.js";
8
+ import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
9
9
  import { isFileField, } from "../engine/types/index.js";
10
10
  import { createFileContext } from "../files/file-handle.js";
11
11
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
@@ -22,6 +22,7 @@ import { createAccessInvalidationEventConsumer, createJobTriggerEventConsumer, c
22
22
  import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } from "../rate-limit/index.js";
23
23
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
24
24
  import { assertUnreachable, generateId } from "../utils/index.js";
25
+ import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
25
26
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
26
27
  import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
27
28
  import { createAuthRoutes } from "./auth-routes.js";
@@ -557,7 +558,9 @@ export function buildServer(options) {
557
558
  app.route("/api", createApiRoutes(dispatcher, {
558
559
  ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
559
560
  }));
560
- app.route("/api", createSseRoute(sseBroker));
561
+ app.route("/api", createSseRoute(sseBroker, {
562
+ anonymousLiveEntities: collectAnonymousLiveEntities(options.registry),
563
+ }));
561
564
  // GET /api/schema — the only way a client obtains the AppSchema. Behind
562
565
  // jwtGuard (NON_PUBLIC_API_PATHS), so anonymousAccess's synthesised user
563
566
  // also gets rejected below — the schema never reaches an unauthenticated
@@ -850,7 +853,7 @@ c, dispatcher) {
850
853
  // real, signed-in principal and reject the synthesised anonymousAccess user
851
854
  // (SessionUser.roles includes ANONYMOUS_ROLE) the same way.
852
855
  function isMissingOrAnonymousUser(user) {
853
- return !user || user.roles.includes(ANONYMOUS_ROLE);
856
+ return !user || isAnonymousSessionUser(user);
854
857
  }
855
858
  function unauthenticatedResponse(c) {
856
859
  return c.json({
@@ -21,4 +21,7 @@ import { type SseBroker } from "./sse-broker.js";
21
21
  * gegen versehentliches Hochsetzen.
22
22
  */
23
23
  export declare const SSE_HEARTBEAT_INTERVAL_MS = 15000;
24
- export declare function createSseRoute(broker: SseBroker): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
24
+ export type SseRouteOptions = {
25
+ readonly anonymousLiveEntities: ReadonlySet<string>;
26
+ };
27
+ export declare function createSseRoute(broker: SseBroker, options: SseRouteOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
3
  import { tenantChannel } from "../engine/constants.js";
4
+ import { isAnonymousSessionUser } from "../engine/system-user.js";
4
5
  import { Routes } from "./api-constants.js";
5
6
  import { getUser } from "./auth-middleware.js";
6
7
  import { accessInvalidationCredentialFor } from "./sse-broker.js";
@@ -33,13 +34,41 @@ export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
33
34
  function isEntityEventData(data) {
34
35
  return typeof data["aggregateType"] === "string";
35
36
  }
36
- // The tenant channel fans out to every member; user-addressed frames
37
- // (e.g. channel-in-app:event:delivered) would otherwise reach the whole tenant.
38
- function isAddressedToOtherUser(event, userId) {
37
+ // Whitelist, never a spread: the wire frame is a signal without field
38
+ // values even if a broker or pusher put a payload into event.data.
39
+ function toEntitySignalWire(event, data) {
40
+ return {
41
+ id: data["id"],
42
+ aggregateType: data.aggregateType,
43
+ eventType: event.type,
44
+ version: data["version"],
45
+ createdAt: data["createdAt"],
46
+ };
47
+ }
48
+ // The tenant channel fans out to every member, so a frame without an entity
49
+ // (e.g. channel-in-app:event:delivered) reaches only its addressee; a frame
50
+ // lacking userId is dropped (fail-closed).
51
+ function isAddressedTo(event, userId) {
39
52
  const addressee = event.data["userId"];
40
- return typeof addressee === "string" && addressee !== userId;
53
+ return typeof addressee === "string" && addressee === userId;
54
+ }
55
+ function mayReceiveEntitySignal(user, aggregateType, options) {
56
+ return !isAnonymousSessionUser(user) || options.anonymousLiveEntities.has(aggregateType);
57
+ }
58
+ function decideWireFrame(event, user, options) {
59
+ const data = event.data;
60
+ if (isEntityEventData(data)) {
61
+ // skip: anonymous connections only get signals for entities an anonymous query declares
62
+ if (!mayReceiveEntitySignal(user, data.aggregateType, options))
63
+ return undefined;
64
+ return { name: data.aggregateType, data: toEntitySignalWire(event, data) };
65
+ }
66
+ // skip: frames without an entity belong to their addressee only
67
+ if (!isAddressedTo(event, user.id))
68
+ return undefined;
69
+ return { name: event.type, data };
41
70
  }
42
- export function createSseRoute(broker) {
71
+ export function createSseRoute(broker, options) {
43
72
  const route = new Hono();
44
73
  route.get(Routes.sse, async (c) => {
45
74
  const user = getUser(c);
@@ -56,13 +85,11 @@ export function createSseRoute(broker) {
56
85
  void stream.close();
57
86
  };
58
87
  const clientId = broker.addClient(channel, (event) => {
59
- // skip: user-addressed frames on the shared tenant channel belong to their recipient only
60
- if (isAddressedToOtherUser(event, user.id))
88
+ const frame = decideWireFrame(event, user, options);
89
+ // skip: decideWireFrame withholds frames this user must not see
90
+ if (!frame)
61
91
  return;
62
- const wireEventName = isEntityEventData(event.data)
63
- ? event.data.aggregateType
64
- : event.type;
65
- stream.writeSSE({ event: wireEventName, data: JSON.stringify(event.data) });
92
+ stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
66
93
  }, closeStream);
67
94
  const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
68
95
  let released = false;
@@ -45,9 +45,9 @@ function resolvePiiStance(eventType) {
45
45
  // (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
46
46
  // type is uncatalogued or no subject KMS is configured (plaintext rollout
47
47
  // mode — the hard boot gate governs whether that is acceptable). A
48
- // user-subject field with no resolvable owner (system cron runs,
49
- // recipient-less skip attempts) leaves the value plaintext: there is no
50
- // user key to shred. This is the ONLY live-write encrypt path — the backfill
48
+ // user-subject field with an empty owner follows its declared `whenAbsent`
49
+ // (tenant key or acknowledged plaintext) and aborts the append when none is
50
+ // declared. This is the ONLY live-write encrypt path — the backfill
51
51
  // catalog branch (`backfill-pii.ts`) resolves through the same
52
52
  // resolveEventSubject so a field never ends up encrypted under different
53
53
  // subjects depending on which path wrote it.
@@ -19,6 +19,7 @@ import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
19
19
  import { columnNamesOf, tableExists } from "./schema-inspection.js";
20
20
  export async function nullBlindIndexesForSubject(db, features, subjectKey) {
21
21
  const likePattern = subjectCiphertextLikePattern(subjectKey);
22
+ const candidates = [];
22
23
  for (const feature of features.values()) {
23
24
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
24
25
  const lookupable = collectLookupableFields(entity);
@@ -27,26 +28,36 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
27
28
  // No featureName prefix — the dispatcher builds entity tables without
28
29
  // one (buildEntityTable with no featureName option), the sweep has to
29
30
  // hit the same names.
30
- const tableName = resolveTableName(entityName, entity, undefined);
31
- // Skip entities whose projection was never migrated — same class as
32
- // subjectRowExistsInTenant (fw#2348). A throw here runs AFTER eraseKey
33
- // in forget-subject and would leave deterministic bidx columns still
34
- // linkable (fw#2550).
35
- if (!(await tableExists(db, tableName)))
31
+ candidates.push({ tableName: resolveTableName(entityName, entity, undefined), lookupable });
32
+ }
33
+ }
34
+ // Skip entities whose projection was never migrated — same class as
35
+ // subjectRowExistsInTenant (fw#2348). A throw here runs AFTER eraseKey
36
+ // in forget-subject and would leave deterministic bidx columns still
37
+ // linkable (fw#2550). One batched existence query instead of one
38
+ // roundtrip per entity.
39
+ const existing = await existingTableNames(db, candidates.map((c) => c.tableName));
40
+ for (const { tableName, lookupable } of candidates) {
41
+ if (!existing.has(tableName))
42
+ continue;
43
+ // Same hazard for a migrated table that predates a later-added
44
+ // lookupable field: skip fields whose columns are missing instead of
45
+ // aborting the sweep for the remaining ones.
46
+ const columns = await columnNamesOf(db, tableName);
47
+ for (const fieldName of lookupable) {
48
+ const snake = toSnakeCase(fieldName);
49
+ if (!columns.has(snake) || !columns.has(`${snake}_bidx`))
36
50
  continue;
37
- // Same hazard for a migrated table that predates a later-added
38
- // lookupable field: skip fields whose columns are missing instead of
39
- // aborting the sweep for the remaining ones.
40
- const columns = await columnNamesOf(db, tableName);
41
- for (const fieldName of lookupable) {
42
- const snake = toSnakeCase(fieldName);
43
- if (!columns.has(snake) || !columns.has(`${snake}_bidx`))
44
- continue;
45
- await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
46
- }
51
+ await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
47
52
  }
48
53
  }
49
54
  }
55
+ async function existingTableNames(db, tableNames) {
56
+ if (tableNames.length === 0)
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]);
59
+ return new Set(rows.map((r) => r.name));
60
+ }
50
61
  // Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
51
62
  // "user"-kind subject id is often not a real user (share-token recipient,
52
63
  // email subscriber, ...) — those entities self-own their PII (`pii: true`,
@@ -568,8 +568,12 @@ export function createReadVerbs(ctx) {
568
568
  : referenceSortExpr !== undefined
569
569
  ? referenceSortExpr(params)
570
570
  : colSql(sortField);
571
- const orderByClause = sortSql !== undefined
572
- ? ` ORDER BY ${sortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
571
+ // The projected label alias is ordered by directly, so Postgres evaluates
572
+ // the correlated subquery once per row instead of once for ORDER BY and
573
+ // once for the SELECT list.
574
+ const orderBySortSql = referenceSortExpr !== undefined ? `"${SORT_LABEL_ALIAS}"` : sortSql;
575
+ const orderByClause = orderBySortSql !== undefined
576
+ ? ` ORDER BY ${orderBySortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
573
577
  : ` ORDER BY ${colSql("id")} ASC`;
574
578
  const useOffset = !payload.cursor && offset > 0;
575
579
  const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
@@ -53,7 +53,8 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
53
53
  return;
54
54
  throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
55
55
  `{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
56
- "explaining why this handler needs db.global() write access or a SYSTEM identity switch.");
56
+ "explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
57
+ "or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
57
58
  }
58
59
  function validatePersonalDataOnlyOnWrite(feature, kind, handlerName, access) {
59
60
  // skip: write handlers may declare personalData, or it wasn't declared here
@@ -164,6 +164,15 @@ function checkDashboardStatPanelFields(queryHandlers, featureName, screenId, pan
164
164
  checkPanelField("deltaDirectionField", panel.deltaDirectionField);
165
165
  checkPanelField("deltaToneField", panel.deltaToneField);
166
166
  }
167
+ // visibleWhen reads a flat record like stat panels; a typo'd field would hide
168
+ // the panel forever without any message.
169
+ function checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel) {
170
+ // skip: panel is always visible, no field to check
171
+ if (panel.visibleWhen === undefined)
172
+ return;
173
+ const { query, field } = panel.visibleWhen;
174
+ checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}" visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema.`);
175
+ }
167
176
  function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen) {
168
177
  for (const panel of screen.panels) {
169
178
  if (panel.kind === "stat") {
@@ -174,6 +183,9 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
174
183
  checkDashboardStatPanelFields(queryHandlers, featureName, screenId, stat);
175
184
  }
176
185
  }
186
+ else if (panel.kind === "screen") {
187
+ checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel);
188
+ }
177
189
  else if (panel.kind === "list") {
178
190
  const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
179
191
  for (const column of panel.columns) {
@@ -690,20 +690,30 @@ function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns
690
690
  }
691
691
  }
692
692
  }
693
- // nav.screen is a full QN (cross-feature), so a standalone r.nav() pointing
694
- // at `targetQn` must be found by scanning every feature's nav entries and actions.
695
- function hasStandaloneNavEntry(targetQn, featureMap) {
693
+ // validateScreenHasNavArea runs once per screen; the index is built once per
694
+ // featureMap (immutable during boot) instead of re-scanning every feature each call.
695
+ const navAreaIndexByFeatureMap = new WeakMap();
696
+ function navTargetScreenQns(nav) {
697
+ return [nav.screen, nav.createAction?.screen, ...(nav.actions ?? []).map((a) => a.screen)].filter((qn) => qn !== undefined);
698
+ }
699
+ function getNavAreaIndex(featureMap) {
700
+ const cached = navAreaIndexByFeatureMap.get(featureMap);
701
+ if (cached !== undefined)
702
+ return cached;
703
+ const navTargetQns = new Set();
704
+ const allScreens = [];
705
+ let anyNavRegistered = false;
696
706
  for (const f of featureMap.values()) {
707
+ allScreens.push(...Object.values(f.screens));
697
708
  for (const nav of Object.values(f.navs)) {
698
- if (nav.screen === targetQn)
699
- return true;
700
- if (nav.createAction?.screen === targetQn)
701
- return true;
702
- if (nav.actions?.some((a) => a.screen === targetQn))
703
- return true;
709
+ anyNavRegistered = true;
710
+ for (const qn of navTargetScreenQns(nav))
711
+ navTargetQns.add(qn);
704
712
  }
705
713
  }
706
- return false;
714
+ const index = { anyNavRegistered, navTargetQns, allScreens };
715
+ navAreaIndexByFeatureMap.set(featureMap, index);
716
+ return index;
707
717
  }
708
718
  // Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
709
719
  // `dormant: true`. Skipped when the composed set has no nav entries at all.
@@ -714,16 +724,15 @@ function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
714
724
  // skip: explicitly opted out via `dormant: true`.
715
725
  if (screen.dormant === true)
716
726
  return;
717
- const anyNavRegistered = [...featureMap.values()].some((f) => Object.keys(f.navs).length > 0);
727
+ const { anyNavRegistered, navTargetQns, allScreens } = getNavAreaIndex(featureMap);
718
728
  // skip: no nav entries exist anywhere in the composed set — a feature/
719
729
  // recipe/test fixture booted without an app shell, not an omission.
720
730
  if (!anyNavRegistered)
721
731
  return;
722
732
  const targetQn = qualifyEntityName(feature.name, "screen", screenId);
723
733
  // skip: a standalone r.nav() elsewhere already points at this screen.
724
- if (hasStandaloneNavEntry(targetQn, featureMap))
734
+ if (navTargetQns.has(targetQn))
725
735
  return;
726
- const allScreens = [...featureMap.values()].flatMap((f) => Object.values(f.screens));
727
736
  // skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
728
737
  if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined)
729
738
  return;
@@ -38,7 +38,9 @@ export function buildAppSchema(registry, options = {}) {
38
38
  ]);
39
39
  const encryptedConfigKeyQns = collectEncryptedConfigKeyQns(registry);
40
40
  for (const [featureName, feature] of registry.features) {
41
- const navs = Object.values(feature.navs);
41
+ // r.nav() may carry explicit `undefined` optionals; drop them so the
42
+ // schema stays free of non-JSON-safe values.
43
+ const navs = Object.values(feature.navs).map((nav) => Object.fromEntries(Object.entries(nav).filter(([, value]) => value !== undefined)));
42
44
  // The nav entry alone doesn't say which kind a collection lists, so the
43
45
  // client can't derive its tree provider from `navs` — project the
44
46
  // collections separately, with the nav QN already qualified.
@@ -430,7 +432,8 @@ function projectField(fieldDef) {
430
432
  out["multiple"] = def["multiple"];
431
433
  // MultiSelect: display picks checkboxes vs. combobox in the renderer,
432
434
  // columns/maxRows size the checkbox grid — without these the renderer
433
- // always falls back to the combobox (fw#2494).
435
+ // always falls back to the combobox (fw#2494). Select: display picks
436
+ // "radio" | "dropdown" the same way.
434
437
  if (typeof def["display"] === "string")
435
438
  out["display"] = def["display"];
436
439
  if (typeof def["columns"] === "number")
@@ -17,7 +17,16 @@ function inlineFormFieldMaps(screen) {
17
17
  function validateLiteralCurrencyCode(where, field, currencies) {
18
18
  // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
19
19
  const shape = field;
20
- if (shape.type === "money" && shape.currency?.kind === "literal") {
20
+ // skip: not a money field, or no currency source declared; nothing to validate.
21
+ if (shape.type !== "money" || shape.currency === undefined)
22
+ return;
23
+ // An unknown kind would project through to the renderer, which silently falls
24
+ // back to entity.defaultCurrency instead of failing.
25
+ if (shape.currency.kind !== "literal" && shape.currency.kind !== "tenant") {
26
+ throw new Error(`${where} declares an unknown currency kind ${JSON.stringify(shape.currency.kind)}. ` +
27
+ `Expected { kind: "tenant" } or { kind: "literal", code }.`);
28
+ }
29
+ if (shape.currency.kind === "literal") {
21
30
  const code = shape.currency.code;
22
31
  if (typeof code !== "string" || !currencies.includes(code)) {
23
32
  throw new Error(`${where} declares currency: { kind: "literal", code: ${JSON.stringify(code)} } which is ` +
@@ -70,11 +70,9 @@ export function defineQueryHandler(def,
70
70
  return def;
71
71
  }
72
72
  // Runtime marker set only by definePagedQueryHandler. A plain
73
- // defineQueryHandler-built object never carries it. Kept as a type-level
74
- // signal (fw#2216) even though no validator gates on it — QueryHandlerDef
75
- // has no output schema, so a boot check can't distinguish "returns
76
- // PagedRows" from "doesn't" without running the handler; the actual
77
- // enforcement is a runtime shape guard in the renderer (kumiko-screen.tsx).
73
+ // defineQueryHandler-built object never carries it. query-output-columns.ts
74
+ // reads it to gate the paged envelope check on outputSchema; the renderer's
75
+ // runtime shape guard (kumiko-screen.tsx) remains as a second line.
78
76
  //
79
77
  // A string key, not a Symbol: bundled-features imports this module through
80
78
  // the package's "@cosmicdrift/kumiko-framework/engine" subpath (symlinked
@@ -101,6 +101,10 @@ export declare const EXT_DERIVATIVE_PUBLIC_PREDICATE: "derivativePublicPredicate
101
101
  * an image silently rendered without its QR would look correct while being
102
102
  * wrong.
103
103
  *
104
+ * `resolve()` runs on every `variant()` call — including cache hits on the
105
+ * anonymous public route — because the spec hash needs the resolved value.
106
+ * It must be cheap or cache its own lookups.
107
+ *
104
108
  * Registered/consumed by: `file-derivatives`'s `variant()`
105
109
  * (derivatives-context.ts), before the variant's spec hash is computed.
106
110
  */
@@ -130,6 +130,10 @@ export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate";
130
130
  * an image silently rendered without its QR would look correct while being
131
131
  * wrong.
132
132
  *
133
+ * `resolve()` runs on every `variant()` call — including cache hits on the
134
+ * anonymous public route — because the spec hash needs the resolved value.
135
+ * It must be cheap or cache its own lookups.
136
+ *
133
137
  * Registered/consumed by: `file-derivatives`'s `variant()`
134
138
  * (derivatives-context.ts), before the variant's spec hash is computed.
135
139
  */
@@ -44,6 +44,7 @@ const QUERY_HANDLER_KEY_KINDS = {
44
44
  agent: "modeled",
45
45
  rateLimit: "modeled",
46
46
  outputSchema: "opaque",
47
+ liveEntities: "opaque",
47
48
  escapeHatch: "modeled",
48
49
  };
49
50
  const STREAM_HANDLER_KEY_KINDS = {
@@ -25,6 +25,7 @@ export declare function buildEntityHandlerMethods<TName extends string>(state: F
25
25
  access: AccessRule;
26
26
  rateLimit?: RateLimitDeclaration;
27
27
  outputSchema?: ZodType;
28
+ liveEntities?: readonly string[];
28
29
  description?: string;
29
30
  agent?: AgentHandlerHints;
30
31
  escapeHatch?: EscapeHatchDeclaration;
@@ -146,6 +146,7 @@ export function buildEntityHandlerMethods(state, name) {
146
146
  ...agentSlots(def),
147
147
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
148
148
  ...(def.outputSchema && { outputSchema: def.outputSchema }),
149
+ ...(def.liveEntities && { liveEntities: def.liveEntities }),
149
150
  ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
150
151
  // Carry the definePagedQueryHandler brand through — this rebuild
151
152
  // drops any field not explicitly listed.
@@ -167,6 +168,7 @@ export function buildEntityHandlerMethods(state, name) {
167
168
  ...agentSlots(inline.options),
168
169
  ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
169
170
  ...(inline.options.outputSchema && { outputSchema: inline.options.outputSchema }),
171
+ ...(inline.options.liveEntities && { liveEntities: inline.options.liveEntities }),
170
172
  ...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
171
173
  };
172
174
  tryMapEntity(state, name, nameOrDef);
@@ -17,6 +17,7 @@ export declare function resolveNotificationTriggersAndRegisterHooks(state: Regis
17
17
  export declare function validateLifecycleHookTargets(state: RegistryState): void;
18
18
  export declare function validateEntityHookTargets(state: RegistryState, features: readonly FeatureDefinition[]): void;
19
19
  export declare function validateJobTriggers(state: RegistryState): void;
20
+ export declare function validateLiveEntities(state: RegistryState): void;
20
21
  export declare function validateBootGates(state: RegistryState): void;
21
22
  export declare function validateJobBackoff(state: RegistryState): void;
22
23
  export declare function validateExtensionUsageTargets(state: RegistryState): void;
@@ -568,6 +568,15 @@ export function validateJobTriggers(state) {
568
568
  }
569
569
  }
570
570
  }
571
+ export function validateLiveEntities(state) {
572
+ for (const [handlerQn, handler] of state.queryHandlerMap) {
573
+ for (const entityName of handler.liveEntities ?? []) {
574
+ if (state.entityMap.has(entityName))
575
+ continue;
576
+ throw new Error(`Query handler "${handlerQn}" declares liveEntities "${entityName}" but no entity with that name is registered`);
577
+ }
578
+ }
579
+ }
571
580
  export function validateBootGates(state) {
572
581
  // Boot gates run inline in JobRunner.start(), which has neither the
573
582
  // per-tenant fan-out (that re-enqueues) nor the sequential lock's
@@ -582,6 +591,11 @@ export function validateBootGates(state) {
582
591
  if (jobDef.concurrency === "sequential") {
583
592
  throw new Error(`Job "${jobName}" cannot combine bootGate with concurrency "sequential"`);
584
593
  }
594
+ // The gate already runs inline each start; runOnBoot would enqueue the same
595
+ // handler again under the same boot job id (runs twice, shared bull_job_id).
596
+ if (jobDef.runOnBoot) {
597
+ throw new Error(`Job "${jobName}" cannot combine bootGate with runOnBoot`);
598
+ }
585
599
  }
586
600
  }
587
601
  export function validateJobBackoff(state) {
@@ -3,7 +3,7 @@ import { dedupeFeatures } from "./dedupe-features.js";
3
3
  import { buildRegistryFacade } from "./registry-facade.js";
4
4
  import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateTranslations, } from "./registry-ingest.js";
5
5
  import { createInitialState } from "./registry-state.js";
6
- import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateJobTriggers, validateLifecycleHookTargets, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
6
+ import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateJobTriggers, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
7
7
  // This is where the magic happens. By "magic" I mean: precomputed maps.
8
8
  // I build everything once at boot (hooks, relations, searchable fields, ...)
9
9
  // so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
@@ -43,6 +43,7 @@ export function createRegistry(rawFeatures) {
43
43
  validateEntityHookTargets(state, features);
44
44
  validateJobTriggers(state);
45
45
  validateBootGates(state);
46
+ validateLiveEntities(state);
46
47
  validateJobBackoff(state);
47
48
  validateExtensionUsageTargets(state);
48
49
  computeHasRateLimitedHandler(state);
@@ -5,14 +5,16 @@
5
5
  function isRecord(value) {
6
6
  return typeof value === "object" && value !== null;
7
7
  }
8
+ // A missing path must stay distinguishable from a present-but-undefined value:
9
+ // `ne` would otherwise match events that lack the field entirely.
8
10
  function resolvePath(payload, path) {
9
11
  let current = payload;
10
12
  for (const segment of path) {
11
- if (!isRecord(current))
12
- return undefined;
13
+ if (!isRecord(current) || !Object.hasOwn(current, segment))
14
+ return { found: false };
13
15
  current = current[segment];
14
16
  }
15
- return current;
17
+ return { found: true, value: current };
16
18
  }
17
19
  function compareOrdered(actual, expected, kind) {
18
20
  if (typeof actual === "number" && typeof expected === "number") {
@@ -73,6 +75,12 @@ function evaluateOp(op, actual) {
73
75
  function malformed(what) {
74
76
  return new Error(`Malformed EventMatch: ${what}`);
75
77
  }
78
+ function evaluateExprAtom(op, resolved) {
79
+ // Evaluate before checking `found` so a malformed op still throws on a
80
+ // payload that lacks the path.
81
+ const matched = evaluateOp(op, resolved.found ? resolved.value : undefined);
82
+ return resolved.found && matched;
83
+ }
76
84
  function evaluateExpr(expr, payload) {
77
85
  if (!isRecord(expr))
78
86
  throw malformed("expr is not an object");
@@ -90,7 +98,7 @@ function evaluateExpr(expr, payload) {
90
98
  throw malformed(`"atom" expr has no "path" array`);
91
99
  if (!isRecord(expr.op))
92
100
  throw malformed(`"atom" expr has no "op" object`);
93
- return evaluateOp(expr.op, resolvePath(payload, expr.path));
101
+ return evaluateExprAtom(expr.op, resolvePath(payload, expr.path));
94
102
  default: {
95
103
  const unrecognized = expr;
96
104
  throw new Error(`Unknown EventMatchExpr kind "${unrecognized.kind}"`);
@@ -5,4 +5,5 @@ export declare const SYSTEM_ROLE: "system";
5
5
  export declare function createSystemUser(tenantId: TenantId, extraRoles?: readonly string[]): SessionUser;
6
6
  export declare const ANONYMOUS_USER_ID = "anonymous";
7
7
  export declare const ANONYMOUS_ROLE: "anonymous";
8
+ export declare function isAnonymousSessionUser(user: Pick<SessionUser, "roles">): boolean;
8
9
  export declare function createAnonymousUser(tenantId: TenantId): SessionUser;
@@ -20,6 +20,9 @@ export function createSystemUser(tenantId, extraRoles = []) {
20
20
  // custom roles.
21
21
  export const ANONYMOUS_USER_ID = "anonymous";
22
22
  export const ANONYMOUS_ROLE = "anonymous";
23
+ export function isAnonymousSessionUser(user) {
24
+ return user.roles.includes(ANONYMOUS_ROLE);
25
+ }
23
26
  export function createAnonymousUser(tenantId) {
24
27
  return {
25
28
  id: ANONYMOUS_USER_ID,