@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.
- package/dist/api/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- package/dist/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- package/dist/api/routes.js +1 -1
- package/dist/api/server.js +6 -3
- package/dist/api/sse-route.d.ts +4 -1
- package/dist/api/sse-route.js +38 -11
- package/dist/crypto/event-pii.js +3 -3
- package/dist/db/blind-index-cleanup.js +27 -16
- package/dist/db/event-store-executor-read.js +6 -2
- package/dist/engine/boot-validator/access-declarations.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +12 -0
- package/dist/engine/boot-validator/screens.js +22 -13
- package/dist/engine/build-app-schema.js +5 -2
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/define-handler.js +3 -5
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/steps/_event-match.js +12 -4
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/env/dry-run.js +10 -3
- package/dist/event-store/event-store.js +4 -1
- package/dist/jobs/job-runner.js +29 -7
- package/dist/migrations/pending-rebuilds.js +4 -3
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -0
- package/dist/pipeline/dispatch-batch.js +2 -2
- package/dist/pipeline/dispatcher.js +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.js +4 -1
- package/dist/pipeline/system-hooks.js +10 -13
- package/dist/pipeline/write-origin.js +13 -6
- package/dist/stack/drain-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +31 -1
- package/package.json +4 -4
- package/src/changes.json +64 -0
- package/src/scripts/codemod/pii-personal-migration.ts +65 -45
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +61 -24
|
@@ -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
|
+
}
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/api/routes.js
CHANGED
|
@@ -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
|
|
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()) {
|
package/dist/api/server.js
CHANGED
|
@@ -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 {
|
|
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
|
|
856
|
+
return !user || isAnonymousSessionUser(user);
|
|
854
857
|
}
|
|
855
858
|
function unauthenticatedResponse(c) {
|
|
856
859
|
return c.json({
|
package/dist/api/sse-route.d.ts
CHANGED
|
@@ -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
|
|
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, "/">;
|
package/dist/api/sse-route.js
CHANGED
|
@@ -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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
function
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
88
|
+
const frame = decideWireFrame(event, user, options);
|
|
89
|
+
// skip: decideWireFrame withholds frames this user must not see
|
|
90
|
+
if (!frame)
|
|
61
91
|
return;
|
|
62
|
-
|
|
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;
|
package/dist/crypto/event-pii.js
CHANGED
|
@@ -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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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
|
|
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
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
*/
|
|
@@ -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) {
|
package/dist/engine/registry.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|