@cosmicdrift/kumiko-framework 0.352.0 → 0.353.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/server.js +11 -3
- package/dist/api/sse-route.js +11 -1
- package/dist/bun-db/query.js +7 -2
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.js +20 -25
- package/dist/db/migrate-generator.js +9 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +13 -0
- package/dist/db/queries/shadow-swap.js +5 -2
- package/dist/engine/boot-validator/access-declarations.js +16 -1
- package/dist/engine/boot-validator/entity-handler.js +13 -0
- package/dist/engine/boot-validator/screens.js +45 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/build-app-schema.js +30 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/entity-handlers.js +15 -6
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/i18n-key.js +14 -3
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +8 -2
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +4 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.js +3 -3
- package/dist/files/types.js +21 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/testing/shared-entities.d.ts +10 -0
- package/package.json +4 -4
- package/src/changes.json +30 -0
|
@@ -415,10 +415,16 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
|
|
|
415
415
|
// need a writable local type; each entry is still a fresh, never-mutated object.
|
|
416
416
|
const translations = {};
|
|
417
417
|
const sections = [];
|
|
418
|
+
const seenFieldIds = new Map();
|
|
418
419
|
for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
|
|
419
420
|
const fieldIds = [];
|
|
420
421
|
for (const s of secrets.filter((v) => v.feature === feature)) {
|
|
421
422
|
const fieldId = `${s.feature}-${s.shortKey}`;
|
|
423
|
+
const prevQn = seenFieldIds.get(fieldId);
|
|
424
|
+
if (prevQn !== undefined) {
|
|
425
|
+
throw new Error(`[Settings-Hub] secrets "${prevQn}" and "${s.qn}" both resolve to field id "${fieldId}" — rename one secret or its feature.`);
|
|
426
|
+
}
|
|
427
|
+
seenFieldIds.set(fieldId, s.qn);
|
|
422
428
|
fieldIds.push(fieldId);
|
|
423
429
|
secretKeys[fieldId] = s.qn;
|
|
424
430
|
const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
|
|
@@ -6,15 +6,31 @@ function dedupeOptionsShallowEqual(a, b) {
|
|
|
6
6
|
}
|
|
7
7
|
return true;
|
|
8
8
|
}
|
|
9
|
+
function sameKeys(a, b) {
|
|
10
|
+
const aKeys = Object.keys(a ?? {});
|
|
11
|
+
const bKeys = new Set(Object.keys(b ?? {}));
|
|
12
|
+
return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
|
|
13
|
+
}
|
|
14
|
+
// dedupeOptions is a declaration, not proof: two same-named features that both
|
|
15
|
+
// carry `{}` but register different surface must still clash, not silently drop one.
|
|
16
|
+
function sameRegisteredSurface(a, b) {
|
|
17
|
+
return (sameKeys(a.entities, b.entities) &&
|
|
18
|
+
sameKeys(a.writeHandlers, b.writeHandlers) &&
|
|
19
|
+
sameKeys(a.queryHandlers, b.queryHandlers) &&
|
|
20
|
+
sameKeys(a.streamHandlers, b.streamHandlers));
|
|
21
|
+
}
|
|
9
22
|
function isInterchangeable(existing, next) {
|
|
10
23
|
if (existing === next)
|
|
11
24
|
return true;
|
|
12
25
|
if (existing.dedupeOptions !== undefined && next.dedupeOptions !== undefined) {
|
|
13
|
-
return dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions)
|
|
26
|
+
return (dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions) &&
|
|
27
|
+
sameRegisteredSurface(existing, next));
|
|
14
28
|
}
|
|
15
29
|
return false;
|
|
16
30
|
}
|
|
17
31
|
// First occurrence wins: runtimes late-bind closure state (sessions' autoRevoke) on the kept instance.
|
|
32
|
+
// An app holding its own handle to a later, dropped instance would bind state the registry never
|
|
33
|
+
// runs — resolve the kept instance from the composed list (`features.find(name)`), not a local handle.
|
|
18
34
|
export function dedupeFeatures(features) {
|
|
19
35
|
const byName = new Map();
|
|
20
36
|
const result = [];
|
|
@@ -198,11 +198,14 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
198
198
|
// query-handler branch below: this ONE write handler reads/writes across
|
|
199
199
|
// every tenant without making the whole feature r.systemScope().
|
|
200
200
|
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
201
|
+
// create always writes into the acting user's own tenant, so its FK,
|
|
202
|
+
// unique and preSave lookups must stay tenant-filtered even when the
|
|
203
|
+
// handler declares cross-tenant access for the row-addressing verbs.
|
|
201
204
|
const dbFor = (ctx) => {
|
|
202
205
|
if (ctx.systemDb) {
|
|
203
206
|
return ctx.systemDb.acknowledgeCrossTenant(`entity convention handler for r.systemScope() feature (${name})`);
|
|
204
207
|
}
|
|
205
|
-
return crossTenantReason !== undefined
|
|
208
|
+
return crossTenantReason !== undefined && verb !== "create"
|
|
206
209
|
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
207
210
|
: ctx.db;
|
|
208
211
|
};
|
|
@@ -539,18 +542,24 @@ export function registerEntityCrud(r, entityName, entity, options) {
|
|
|
539
542
|
};
|
|
540
543
|
const resolveWriteOpts = (verb) => {
|
|
541
544
|
const excludeFields = verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
|
|
545
|
+
const verbAgent = options?.agents?.[verb];
|
|
542
546
|
return {
|
|
543
547
|
...writeOpts,
|
|
544
548
|
access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
|
|
545
549
|
description: options?.descriptions?.[verb] ?? writeOpts?.description,
|
|
550
|
+
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
546
551
|
...(excludeFields !== undefined && { excludeFields }),
|
|
547
552
|
};
|
|
548
553
|
};
|
|
549
|
-
const resolveReadOpts = (verb) =>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
+
const resolveReadOpts = (verb) => {
|
|
555
|
+
const verbAgent = options?.agents?.[verb];
|
|
556
|
+
return {
|
|
557
|
+
...readOpts,
|
|
558
|
+
access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
|
|
559
|
+
description: options?.descriptions?.[verb] ?? readOpts?.description,
|
|
560
|
+
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
561
|
+
};
|
|
562
|
+
};
|
|
554
563
|
if (verbs.create) {
|
|
555
564
|
r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
|
|
556
565
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
|
|
2
2
|
import type * as z from "zod";
|
|
3
|
-
import type { ZodType } from "zod";
|
|
4
3
|
import type { HttpRouteDefinition } from "./types/http-route.js";
|
|
5
|
-
import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
|
|
4
|
+
import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
|
|
6
5
|
import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
|
|
7
6
|
import type { ScreenDefinition } from "./types/screen.js";
|
|
8
7
|
import type { WorkspaceDefinition } from "./types/workspace.js";
|
|
@@ -23,11 +22,7 @@ export type FeatureBuilderState = {
|
|
|
23
22
|
configKeys: Record<string, ConfigKeyDefinition>;
|
|
24
23
|
configSeeds: ConfigSeedDef[];
|
|
25
24
|
jobs: Record<string, JobDefinition>;
|
|
26
|
-
events: Record<string,
|
|
27
|
-
name: string;
|
|
28
|
-
schema: ZodType;
|
|
29
|
-
version: number;
|
|
30
|
-
}>;
|
|
25
|
+
events: Record<string, EventDef>;
|
|
31
26
|
eventMigrations: Record<string, EventMigrationDef[]>;
|
|
32
27
|
configReads: string[];
|
|
33
28
|
entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]>;
|
|
@@ -73,6 +73,9 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
73
73
|
if (field === normalized.ownerField) {
|
|
74
74
|
throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as the owner field — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`);
|
|
75
75
|
}
|
|
76
|
+
if (normalized.ownerField === "id") {
|
|
77
|
+
throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" uses owner field "id" — on entities \`of: "id"\` means record-owned, but on events it would encrypt under a phantom user key that a forget never reaches. Use \`personal: "self"\` for a record-owned event field.`);
|
|
78
|
+
}
|
|
76
79
|
for (const required of [field, normalized.ownerField]) {
|
|
77
80
|
if (shape && !(required in shape)) {
|
|
78
81
|
throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`);
|
|
@@ -55,7 +55,7 @@ function agentSlots(source) {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
function requireInlineHandlerArgs(kind, schema, handler, options) {
|
|
58
|
-
if (!schema || !handler || !options) {
|
|
58
|
+
if (!schema || !handler || !options?.access) {
|
|
59
59
|
throw new Error(`${kind} inline form requires schema + handler + options.access`);
|
|
60
60
|
}
|
|
61
61
|
return { schema, handler, options };
|
package/dist/engine/i18n-key.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
// Dot-form labels are indistinguishable from literal display text ("actions.open"),
|
|
2
2
|
// so authors mark the i18n ones explicitly; the registry is what boot validation reads.
|
|
3
|
-
|
|
3
|
+
// Marking is by string value and therefore global across features. The registry sits on
|
|
4
|
+
// globalThis so a second installed copy of the framework still reads the marks the
|
|
5
|
+
// other copy wrote; otherwise they are silently ignored and #2313 reopens.
|
|
6
|
+
const REGISTRY_KEY = Symbol.for("kumiko.i18n.explicitDotFormKeys");
|
|
7
|
+
function explicitDotFormKeys() {
|
|
8
|
+
const existing = Reflect.get(globalThis, REGISTRY_KEY);
|
|
9
|
+
if (existing instanceof Set)
|
|
10
|
+
return existing;
|
|
11
|
+
const created = new Set();
|
|
12
|
+
Reflect.set(globalThis, REGISTRY_KEY, created);
|
|
13
|
+
return created;
|
|
14
|
+
}
|
|
4
15
|
export function i18nKey(value) {
|
|
5
|
-
explicitDotFormKeys.add(value);
|
|
16
|
+
explicitDotFormKeys().add(value);
|
|
6
17
|
return value;
|
|
7
18
|
}
|
|
8
19
|
export function isExplicitDotFormKey(value) {
|
|
9
|
-
return explicitDotFormKeys.has(value);
|
|
20
|
+
return explicitDotFormKeys().has(value);
|
|
10
21
|
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export { createRegistry } from "./registry.js";
|
|
|
59
59
|
export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param.js";
|
|
60
60
|
export { resolveConfigOrParam } from "./resolve-config-or-param.js";
|
|
61
61
|
export type { AssignableAppRoles, AssignableFromRole } from "./role-assignment.js";
|
|
62
|
-
export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
62
|
+
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
63
63
|
export { runsInLane } from "./run-in.js";
|
|
64
64
|
export type { StepListOutcome } from "./run-pipeline.js";
|
|
65
65
|
export { runPipeline, runStepList } from "./run-pipeline.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -55,7 +55,7 @@ export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
|
55
55
|
export { readClaim } from "./read-claim.js";
|
|
56
56
|
export { createRegistry } from "./registry.js";
|
|
57
57
|
export { resolveConfigOrParam } from "./resolve-config-or-param.js";
|
|
58
|
-
export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
58
|
+
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
59
59
|
export { runsInLane } from "./run-in.js";
|
|
60
60
|
export { runPipeline, runStepList } from "./run-pipeline.js";
|
|
61
61
|
export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
|
package/dist/engine/pipeline.js
CHANGED
|
@@ -79,9 +79,26 @@ export function stepsPipeline(closure) {
|
|
|
79
79
|
// carried in (D4, workflow-resume-loop.md), never an unchecked external
|
|
80
80
|
// value.
|
|
81
81
|
export function buildPipelineSteps(pipelineDef, event) {
|
|
82
|
-
|
|
82
|
+
const steps = pipelineDef.build({
|
|
83
83
|
event,
|
|
84
84
|
r: stepBuilder,
|
|
85
85
|
awaits: (pipelineDef.awaits ?? {}),
|
|
86
86
|
});
|
|
87
|
+
assertUniqueWaitForEventTypes(steps);
|
|
88
|
+
return steps;
|
|
89
|
+
}
|
|
90
|
+
// waitForEvent's result key is the awaited event type, so a second wait on the
|
|
91
|
+
// same type would overwrite the first one's resumed payload.
|
|
92
|
+
function assertUniqueWaitForEventTypes(steps) {
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const step of steps) {
|
|
95
|
+
if (step.kind !== "workflow.waitForEvent")
|
|
96
|
+
continue;
|
|
97
|
+
const { event } = step.args; // @cast-boundary engine-payload
|
|
98
|
+
if (seen.has(event)) {
|
|
99
|
+
throw new Error(`r.step.waitForEvent: event "${String(event)}" is awaited more than once in one workflow — ` +
|
|
100
|
+
"each awaits entry can back only one waitForEvent step.");
|
|
101
|
+
}
|
|
102
|
+
seen.add(event);
|
|
103
|
+
}
|
|
87
104
|
}
|
|
@@ -530,20 +530,26 @@ function projectActionFormScreen(screen, feature, indices, keptScreenQns) {
|
|
|
530
530
|
};
|
|
531
531
|
}
|
|
532
532
|
function projectSecretMintScreen(screen, feature, indices, keptScreenQns) {
|
|
533
|
-
const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
|
|
533
|
+
const { redirect, cancelTarget, layout, listScreenId, confirm, ...rest } = screen;
|
|
534
534
|
const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
|
|
535
535
|
const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
|
|
536
536
|
const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
|
|
537
537
|
const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
|
|
538
|
+
const nextConfirmLayout = confirm === undefined
|
|
539
|
+
? undefined
|
|
540
|
+
: projectEditLayout(confirm.layout, feature, indices, keptScreenQns);
|
|
538
541
|
const unchanged = nextRedirect === redirect &&
|
|
539
542
|
nextCancelTarget === cancelTarget &&
|
|
540
543
|
nextLayout === layout &&
|
|
541
|
-
nextListScreenId === listScreenId
|
|
544
|
+
nextListScreenId === listScreenId &&
|
|
545
|
+
nextConfirmLayout === confirm?.layout;
|
|
542
546
|
if (unchanged)
|
|
543
547
|
return screen;
|
|
544
548
|
return {
|
|
545
549
|
...rest,
|
|
546
550
|
layout: nextLayout,
|
|
551
|
+
...(confirm !== undefined &&
|
|
552
|
+
nextConfirmLayout !== undefined && { confirm: { ...confirm, layout: nextConfirmLayout } }),
|
|
547
553
|
...(nextRedirect !== undefined && { redirect: nextRedirect }),
|
|
548
554
|
...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
|
|
549
555
|
...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Registry } from "./types/feature.js";
|
|
2
|
+
/** Every membership role the engine ranks, excluding the internal `system` actor. */
|
|
3
|
+
export declare const BUILT_IN_MEMBERSHIP_ROLES: readonly string[];
|
|
2
4
|
declare const ASSIGNABLE_FROM_ROLE_LIST: readonly ["User", "Member", "Editor", "Admin", "TenantAdmin", "SystemAdmin"];
|
|
3
5
|
export type AssignableFromRole = (typeof ASSIGNABLE_FROM_ROLE_LIST)[number];
|
|
4
6
|
/** App role → lowest built-in role allowed to assign it. */
|
|
@@ -16,6 +16,8 @@ const ROLE_RANKS = new Map([
|
|
|
16
16
|
["SystemAdmin", 4],
|
|
17
17
|
["system", 5],
|
|
18
18
|
]);
|
|
19
|
+
/** Every membership role the engine ranks, excluding the internal `system` actor. */
|
|
20
|
+
export const BUILT_IN_MEMBERSHIP_ROLES = [...ROLE_RANKS.keys()].filter((role) => role !== "system");
|
|
19
21
|
const ASSIGNABLE_FROM_ROLE_LIST = [
|
|
20
22
|
"User",
|
|
21
23
|
"Member",
|
|
@@ -87,6 +87,14 @@ export function fieldToZod(field, currencies, opts = {}) {
|
|
|
87
87
|
let schema = z.string();
|
|
88
88
|
if (field.maxLength)
|
|
89
89
|
schema = schema.max(field.maxLength);
|
|
90
|
+
if (field.minLength) {
|
|
91
|
+
// "" must stay valid for optional fields and writeOnly updates (""
|
|
92
|
+
// = unchanged); `required` rejects it separately via min(1) below.
|
|
93
|
+
const minLength = field.minLength;
|
|
94
|
+
schema = schema.refine((value) => value.length === 0 || value.length >= minLength, {
|
|
95
|
+
message: `at least ${minLength} characters`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
90
98
|
if (field.writeOnly === true) {
|
|
91
99
|
// Update: "" = unchanged (dropped before the executor), null = clear,
|
|
92
100
|
// unless required. Create: "" would leave a required field unset.
|
|
@@ -17,9 +17,9 @@ const SELF_COMPARISON_RE = new RegExp(`(${QUALIFIED_SRC})\\s*(?<![!<>=])=(?!=)\\
|
|
|
17
17
|
const QUALIFIED_PARSE_RE = new RegExp(`^(${IDENT_SRC})(?:\\.(${IDENT_SRC}))?$`);
|
|
18
18
|
// Resolve the SQL column names a table exposes, so the lint can tell a
|
|
19
19
|
// real column reference apart from a table alias / function name / SQL
|
|
20
|
-
// keyword. Returns an empty set when nothing is derivable;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// keyword. Returns an empty set when nothing is derivable; callers that need
|
|
21
|
+
// the column-aware check must treat that as "unknown" (see
|
|
22
|
+
// assertQualifiedWhereFragment, which fails closed on it).
|
|
23
23
|
export function tableColumnSqlNames(table) {
|
|
24
24
|
if (table === null || typeof table !== "object")
|
|
25
25
|
return new Set();
|
|
@@ -182,6 +182,9 @@ export function assertQualifiedWhereFragment(sqlText, columnSqlNames, scope) {
|
|
|
182
182
|
// the fail-open shape this lint targets is impossible here.
|
|
183
183
|
if (!/\bSELECT\b/i.test(cleaned))
|
|
184
184
|
return;
|
|
185
|
+
if (columnSqlNames.size === 0) {
|
|
186
|
+
throw new Error(`[Kumiko Ownership] ${scope}: cannot verify where-rule SQL containing a subquery — the table's column set is unknown, so unqualified column references cannot be detected.`);
|
|
187
|
+
}
|
|
185
188
|
const unqualified = findUnqualifiedColumnReference(cleaned, columnSqlNames);
|
|
186
189
|
if (unqualified !== null) {
|
|
187
190
|
throw new Error(`[Kumiko Ownership] ${scope}: where-rule SQL references column "${unqualified}" unqualified inside a subquery. Postgres binds an unqualified name to the innermost table first, which silently turns the ownership predicate into a tautology (fail-open). Qualify it with the outer table, e.g. \`\${ctx.tableName}.${unqualified}\`.`);
|
package/dist/env/index.d.ts
CHANGED
|
@@ -88,6 +88,10 @@ export type ParseEnvOptions = {
|
|
|
88
88
|
/** When set, error suggestions include `pulumi config set <prefix>...`. */
|
|
89
89
|
readonly pulumiPrefix?: string;
|
|
90
90
|
};
|
|
91
|
+
/** Validates `env` against `schema` and throws KumikoBootError on any issue.
|
|
92
|
+
* A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
|
|
93
|
+
* is `undefined` in the result despite its `string` type — it is only set once
|
|
94
|
+
* the boot-time decrypt (resolvePlatformKeks) has run. */
|
|
91
95
|
export declare function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S, env: Record<string, string | undefined>, options?: ParseEnvOptions): z.infer<S>;
|
|
92
96
|
/** Dry-Run-Gegenstück zu parseEnv (KUMIKO_DRY_RUN_ENV-Pfad): parst jedes
|
|
93
97
|
* Feld einzeln und liefert ein ehrliches Partial — vorhandene Werte kommen
|
package/dist/env/index.js
CHANGED
|
@@ -259,6 +259,10 @@ function safeParseReportingThrows(schema, input) {
|
|
|
259
259
|
]);
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
|
+
/** Validates `env` against `schema` and throws KumikoBootError on any issue.
|
|
263
|
+
* A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
|
|
264
|
+
* is `undefined` in the result despite its `string` type — it is only set once
|
|
265
|
+
* the boot-time decrypt (resolvePlatformKeks) has run. */
|
|
262
266
|
export function parseEnv(schema, env, options = {}) {
|
|
263
267
|
// Filter undefined values to keep Zod's required-vs-invalid signal clean.
|
|
264
268
|
// (process.env returns string|undefined; passing undefined would parse
|
package/dist/es-ops/context.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// SystemUser bypassed Access-Checks (Standard-Seed-Pattern, siehe
|
|
7
7
|
// config-seed.ts:40). Events haben createdBy = SYSTEM_TENANT_ID-User
|
|
8
8
|
// → audit-fähig.
|
|
9
|
+
import { configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto/index.js";
|
|
9
10
|
import { selectAllTenants, selectMembershipsOfUser, selectTemplateResources, selectUserByEmail, } from "../db/queries/seed-context.js";
|
|
10
11
|
import { createSystemUser, SYSTEM_TENANT_ID } from "../engine/index.js";
|
|
11
12
|
import { parseStringArrayJson } from "../utils/parse-string-array-json.js";
|
|
@@ -63,7 +64,24 @@ export function createSeedMigrationContext(args) {
|
|
|
63
64
|
},
|
|
64
65
|
findTenants: async () => {
|
|
65
66
|
const rows = await selectAllTenants(args.dbRunner);
|
|
66
|
-
|
|
67
|
+
const kms = configuredPiiSubjectKms();
|
|
68
|
+
// Sequential on purpose: each decrypt borrows from the KMS adapter's small pool.
|
|
69
|
+
const tenants = [];
|
|
70
|
+
for (const r of rows) {
|
|
71
|
+
// tenant.name is ciphertext at rest under an active KMS
|
|
72
|
+
const decrypted = kms
|
|
73
|
+
? await decryptPiiFieldValues({ name: r.name }, ["name"], kms, {
|
|
74
|
+
requestId: "es-ops:find-tenants",
|
|
75
|
+
})
|
|
76
|
+
: { name: r.name };
|
|
77
|
+
const name = decrypted["name"];
|
|
78
|
+
tenants.push({
|
|
79
|
+
id: r.id,
|
|
80
|
+
name: typeof name === "string" ? name : r.name,
|
|
81
|
+
tenantKey: r.tenant_key,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return tenants;
|
|
67
85
|
},
|
|
68
86
|
findTemplateResources: async (filter) => {
|
|
69
87
|
const rows = await selectTemplateResources(args.dbRunner, {
|
|
@@ -3,6 +3,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
|
+
readonly minLength?: number;
|
|
6
7
|
readonly required?: boolean;
|
|
7
8
|
readonly searchable?: boolean;
|
|
8
9
|
readonly sortable?: boolean;
|
|
@@ -24,6 +25,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
24
25
|
readonly type: "text";
|
|
25
26
|
readonly description?: string;
|
|
26
27
|
readonly maxLength?: number;
|
|
28
|
+
readonly minLength?: number;
|
|
27
29
|
readonly required?: boolean;
|
|
28
30
|
readonly searchable?: boolean;
|
|
29
31
|
readonly sortable?: boolean;
|
|
@@ -45,6 +47,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
45
47
|
readonly type: "text";
|
|
46
48
|
readonly description?: string;
|
|
47
49
|
readonly maxLength?: number;
|
|
50
|
+
readonly minLength?: number;
|
|
48
51
|
readonly required?: boolean;
|
|
49
52
|
readonly searchable?: boolean;
|
|
50
53
|
readonly sortable?: boolean;
|
|
@@ -78,6 +81,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
78
81
|
readonly type: "text";
|
|
79
82
|
readonly description?: string;
|
|
80
83
|
readonly maxLength?: number;
|
|
84
|
+
readonly minLength?: number;
|
|
81
85
|
readonly required?: boolean;
|
|
82
86
|
readonly searchable?: boolean;
|
|
83
87
|
readonly sortable?: boolean;
|
|
@@ -99,6 +103,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
99
103
|
readonly type: "text";
|
|
100
104
|
readonly description?: string;
|
|
101
105
|
readonly maxLength?: number;
|
|
106
|
+
readonly minLength?: number;
|
|
102
107
|
readonly required?: boolean;
|
|
103
108
|
readonly searchable?: boolean;
|
|
104
109
|
readonly sortable?: boolean;
|
|
@@ -120,6 +125,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
120
125
|
readonly type: "text";
|
|
121
126
|
readonly description?: string;
|
|
122
127
|
readonly maxLength?: number;
|
|
128
|
+
readonly minLength?: number;
|
|
123
129
|
readonly required?: boolean;
|
|
124
130
|
readonly searchable?: boolean;
|
|
125
131
|
readonly sortable?: boolean;
|
|
@@ -3,6 +3,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
|
+
readonly minLength?: number;
|
|
6
7
|
readonly required?: boolean;
|
|
7
8
|
readonly searchable?: boolean;
|
|
8
9
|
readonly sortable?: boolean;
|
|
@@ -24,6 +25,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
24
25
|
readonly type: "text";
|
|
25
26
|
readonly description?: string;
|
|
26
27
|
readonly maxLength?: number;
|
|
28
|
+
readonly minLength?: number;
|
|
27
29
|
readonly required?: boolean;
|
|
28
30
|
readonly searchable?: boolean;
|
|
29
31
|
readonly sortable?: boolean;
|
|
@@ -45,6 +47,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
45
47
|
readonly type: "text";
|
|
46
48
|
readonly description?: string;
|
|
47
49
|
readonly maxLength?: number;
|
|
50
|
+
readonly minLength?: number;
|
|
48
51
|
readonly required?: boolean;
|
|
49
52
|
readonly searchable?: boolean;
|
|
50
53
|
readonly sortable?: boolean;
|
|
@@ -78,6 +81,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
78
81
|
readonly type: "text";
|
|
79
82
|
readonly description?: string;
|
|
80
83
|
readonly maxLength?: number;
|
|
84
|
+
readonly minLength?: number;
|
|
81
85
|
readonly required?: boolean;
|
|
82
86
|
readonly searchable?: boolean;
|
|
83
87
|
readonly sortable?: boolean;
|
|
@@ -99,6 +103,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
99
103
|
readonly type: "text";
|
|
100
104
|
readonly description?: string;
|
|
101
105
|
readonly maxLength?: number;
|
|
106
|
+
readonly minLength?: number;
|
|
102
107
|
readonly required?: boolean;
|
|
103
108
|
readonly searchable?: boolean;
|
|
104
109
|
readonly sortable?: boolean;
|
|
@@ -120,6 +125,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
|
|
|
120
125
|
readonly type: "text";
|
|
121
126
|
readonly description?: string;
|
|
122
127
|
readonly maxLength?: number;
|
|
128
|
+
readonly minLength?: number;
|
|
123
129
|
readonly required?: boolean;
|
|
124
130
|
readonly searchable?: boolean;
|
|
125
131
|
readonly sortable?: boolean;
|
|
@@ -47,7 +47,7 @@ function resolveAttachedField(registry, entityType, fieldName) {
|
|
|
47
47
|
return { kind: "unattached" };
|
|
48
48
|
const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
|
|
49
49
|
const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
|
|
50
|
-
if (!entityType || !fieldName || !entity || !fieldDef)
|
|
50
|
+
if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
|
|
51
51
|
return { kind: "unresolvable" };
|
|
52
52
|
return { kind: "resolved", fieldDef };
|
|
53
53
|
}
|
|
@@ -168,10 +168,10 @@ export function createFileRoutes(options) {
|
|
|
168
168
|
const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
|
|
169
169
|
if (attachedField.kind === "unresolvable") {
|
|
170
170
|
return c.json({
|
|
171
|
-
error: "unresolvable_field: entityType/fieldName must resolve to a registered
|
|
171
|
+
error: "unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload",
|
|
172
172
|
}, 400);
|
|
173
173
|
}
|
|
174
|
-
if (attachedField.kind === "resolved"
|
|
174
|
+
if (attachedField.kind === "resolved") {
|
|
175
175
|
if (attachedField.fieldDef.maxSize)
|
|
176
176
|
maxSize = attachedField.fieldDef.maxSize;
|
|
177
177
|
if (attachedField.fieldDef.accept)
|
package/dist/files/types.js
CHANGED
|
@@ -75,6 +75,7 @@ function extensionsForMimeType(mimeType) {
|
|
|
75
75
|
// application/octet-stream in resolveServedContentType below — this also
|
|
76
76
|
// guarantees text/html and image/svg+xml are never served inline, even for
|
|
77
77
|
// an honestly-declared upload.
|
|
78
|
+
const DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
78
79
|
const MAGIC_BYTE_SIGNATURES = [
|
|
79
80
|
{
|
|
80
81
|
mimeType: "image/png",
|
|
@@ -99,10 +100,22 @@ const MAGIC_BYTE_SIGNATURES = [
|
|
|
99
100
|
// ZIP local-file-header signature — .docx/.xlsx/.pptx/plain .zip all share
|
|
100
101
|
// it; same caveat as OLE above.
|
|
101
102
|
{
|
|
102
|
-
mimeType:
|
|
103
|
+
mimeType: DOCX_MIME_TYPE,
|
|
103
104
|
matches: (bytes) => startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]),
|
|
104
105
|
},
|
|
105
106
|
];
|
|
107
|
+
// ZIP entry names are stored uncompressed in the local and central headers,
|
|
108
|
+
// so a byte search finds the docx part directory without unzipping. The main
|
|
109
|
+
// part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
|
|
110
|
+
const DOCX_PART_PREFIX = "word/";
|
|
111
|
+
function containsAscii(bytes, ascii) {
|
|
112
|
+
const first = ascii.charCodeAt(0);
|
|
113
|
+
for (let i = bytes.indexOf(first); i !== -1; i = bytes.indexOf(first, i + 1)) {
|
|
114
|
+
if (startsWithAscii(bytes, ascii, i))
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
106
119
|
function startsWithBytes(bytes, signature) {
|
|
107
120
|
if (bytes.length < signature.length)
|
|
108
121
|
return false;
|
|
@@ -220,6 +233,13 @@ export function validateFileContent(fileName, content, accept) {
|
|
|
220
233
|
if (signatureMimeTypes.length === 0)
|
|
221
234
|
return { kind: "ok" };
|
|
222
235
|
const sniffed = sniffMimeType(content);
|
|
236
|
+
// sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
|
|
237
|
+
if (sniffed === DOCX_MIME_TYPE && !containsAscii(content, DOCX_PART_PREFIX)) {
|
|
238
|
+
return {
|
|
239
|
+
kind: "rejected",
|
|
240
|
+
error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
223
243
|
if (sniffed && signatureMimeTypes.includes(sniffed))
|
|
224
244
|
return { kind: "ok" };
|
|
225
245
|
if (sniffed) {
|
|
@@ -2,7 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import { type PrometheusMeter } from "./prometheus-meter.js";
|
|
3
3
|
import type { ObservabilityProvider } from "./types/index.js";
|
|
4
4
|
export declare const prometheusMetricsEnvSchema: z.ZodObject<{
|
|
5
|
-
PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodString
|
|
5
|
+
PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodLiteral<"">, z.ZodTransform<undefined, "">>, z.ZodString]>>;
|
|
6
6
|
}, z.core.$strip>;
|
|
7
7
|
type PrometheusObservabilityProvider = ObservabilityProvider & {
|
|
8
8
|
readonly meter: PrometheusMeter;
|
|
@@ -2,9 +2,9 @@ import * as z from "zod";
|
|
|
2
2
|
import { createNoopProvider } from "./noop-provider.js";
|
|
3
3
|
import { createPrometheusMeter } from "./prometheus-meter.js";
|
|
4
4
|
export const prometheusMetricsEnvSchema = z.object({
|
|
5
|
+
// An empty value (blank .env line, empty k8s secret) means "unset", not "invalid".
|
|
5
6
|
PROMETHEUS_METRICS_TOKEN: z
|
|
6
|
-
.string()
|
|
7
|
-
.min(32)
|
|
7
|
+
.union([z.literal("").transform(() => undefined), z.string().min(32)])
|
|
8
8
|
.optional()
|
|
9
9
|
.describe("Bearer token for /metrics; unset keeps the endpoint off.")
|
|
10
10
|
.meta({ kumiko: { pulumi: { secret: true, generator: "openssl rand -base64 32" } } }),
|
|
@@ -67,6 +67,7 @@ export function createMemberReaderFn(ctx, tenantId, tx) {
|
|
|
67
67
|
}
|
|
68
68
|
// Only successful resolutions are cached — a rejected/failed resolution
|
|
69
69
|
// deletes its own entry so a transient error can't poison the rest of the run.
|
|
70
|
+
// No TTL: a membership revoked mid-run is only noticed by the next reader.
|
|
70
71
|
const cache = new Map();
|
|
71
72
|
function resolve(userId) {
|
|
72
73
|
const cached = cache.get(userId);
|
|
@@ -3,6 +3,7 @@ export declare const sharedWidgetEntity: import("@cosmicdrift/kumiko-types/field
|
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
6
|
+
readonly minLength?: number;
|
|
6
7
|
readonly required?: boolean;
|
|
7
8
|
readonly searchable?: boolean;
|
|
8
9
|
readonly sortable?: boolean;
|
|
@@ -26,6 +27,7 @@ export declare const sharedWidgetTable: import("../db/table-builder.js").EntityT
|
|
|
26
27
|
readonly type: "text";
|
|
27
28
|
readonly description?: string;
|
|
28
29
|
readonly maxLength?: number;
|
|
30
|
+
readonly minLength?: number;
|
|
29
31
|
readonly required?: boolean;
|
|
30
32
|
readonly searchable?: boolean;
|
|
31
33
|
readonly sortable?: boolean;
|
|
@@ -49,6 +51,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
49
51
|
readonly type: "text";
|
|
50
52
|
readonly description?: string;
|
|
51
53
|
readonly maxLength?: number;
|
|
54
|
+
readonly minLength?: number;
|
|
52
55
|
readonly required?: boolean;
|
|
53
56
|
readonly searchable?: boolean;
|
|
54
57
|
readonly sortable?: boolean;
|
|
@@ -70,6 +73,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
70
73
|
readonly type: "text";
|
|
71
74
|
readonly description?: string;
|
|
72
75
|
readonly maxLength?: number;
|
|
76
|
+
readonly minLength?: number;
|
|
73
77
|
readonly required?: boolean;
|
|
74
78
|
readonly searchable?: boolean;
|
|
75
79
|
readonly sortable?: boolean;
|
|
@@ -91,6 +95,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
91
95
|
readonly type: "text";
|
|
92
96
|
readonly description?: string;
|
|
93
97
|
readonly maxLength?: number;
|
|
98
|
+
readonly minLength?: number;
|
|
94
99
|
readonly required?: boolean;
|
|
95
100
|
readonly searchable?: boolean;
|
|
96
101
|
readonly sortable?: boolean;
|
|
@@ -117,6 +122,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
|
|
|
117
122
|
readonly type: "text";
|
|
118
123
|
readonly description?: string;
|
|
119
124
|
readonly maxLength?: number;
|
|
125
|
+
readonly minLength?: number;
|
|
120
126
|
readonly required?: boolean;
|
|
121
127
|
readonly searchable?: boolean;
|
|
122
128
|
readonly sortable?: boolean;
|
|
@@ -138,6 +144,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
|
|
|
138
144
|
readonly type: "text";
|
|
139
145
|
readonly description?: string;
|
|
140
146
|
readonly maxLength?: number;
|
|
147
|
+
readonly minLength?: number;
|
|
141
148
|
readonly required?: boolean;
|
|
142
149
|
readonly searchable?: boolean;
|
|
143
150
|
readonly sortable?: boolean;
|
|
@@ -159,6 +166,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
|
|
|
159
166
|
readonly type: "text";
|
|
160
167
|
readonly description?: string;
|
|
161
168
|
readonly maxLength?: number;
|
|
169
|
+
readonly minLength?: number;
|
|
162
170
|
readonly required?: boolean;
|
|
163
171
|
readonly searchable?: boolean;
|
|
164
172
|
readonly sortable?: boolean;
|
|
@@ -185,6 +193,7 @@ export declare const sharedItemEntity: import("@cosmicdrift/kumiko-types/fields"
|
|
|
185
193
|
readonly type: "text";
|
|
186
194
|
readonly description?: string;
|
|
187
195
|
readonly maxLength?: number;
|
|
196
|
+
readonly minLength?: number;
|
|
188
197
|
readonly required?: boolean;
|
|
189
198
|
readonly searchable?: boolean;
|
|
190
199
|
readonly sortable?: boolean;
|
|
@@ -227,6 +236,7 @@ export declare const sharedItemTable: import("../db/table-builder.js").EntityTab
|
|
|
227
236
|
readonly type: "text";
|
|
228
237
|
readonly description?: string;
|
|
229
238
|
readonly maxLength?: number;
|
|
239
|
+
readonly minLength?: number;
|
|
230
240
|
readonly required?: boolean;
|
|
231
241
|
readonly searchable?: boolean;
|
|
232
242
|
readonly sortable?: boolean;
|