@cosmicdrift/kumiko-framework 0.338.0 → 0.340.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/auth-middleware.d.ts +1 -0
- package/dist/api/auth-middleware.js +54 -10
- package/dist/api/routes.d.ts +2 -1
- package/dist/api/routes.js +29 -6
- package/dist/api/server.js +9 -1
- package/dist/bun-db/index.d.ts +2 -2
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +25 -1
- package/dist/bun-db/query.js +144 -17
- package/dist/crypto/event-pii.js +13 -4
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
- package/dist/crypto/pii-ciphertext-format.js +11 -0
- package/dist/crypto/pii-field-encryption.d.ts +4 -2
- package/dist/crypto/pii-field-encryption.js +50 -21
- package/dist/crypto/system-event-pii.js +2 -2
- package/dist/db/queries/backfill-pii.js +4 -0
- package/dist/db/query-api.d.ts +1 -1
- package/dist/db/query.d.ts +1 -1
- package/dist/db/tenant-db.js +16 -12
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +19 -0
- package/dist/engine/boot-validator/pii-retention.js +30 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/extensions/user-data.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/feature-manifest.d.ts +7 -0
- package/dist/engine/feature-manifest.js +8 -0
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +9 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/steps/retry.js +0 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +1 -1
- package/dist/engine/steps/wait-for-event.js +0 -1
- package/dist/engine/steps/wait.js +0 -4
- package/dist/engine/steps/workflow-system-events.d.ts +2 -0
- package/dist/engine/steps/workflow-system-events.js +89 -0
- package/dist/pipeline/append-event-core.d.ts +3 -1
- package/dist/pipeline/append-event-core.js +8 -4
- package/dist/pipeline/dispatch-shared.js +5 -2
- package/package.json +4 -4
- package/src/changes.json +48 -0
package/dist/crypto/index.d.ts
CHANGED
|
@@ -6,6 +6,6 @@ export { type KekSourceEnv, type KekSourceOptions, resolvePlatformKeks } from ".
|
|
|
6
6
|
export { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, type KmsAdapter, type KmsContext, type KmsHealth, type LocalKeyKmsAdapter, RECORD_ENTITY_PATTERN, type RemoteCryptoKmsAdapter, type SubjectDek, type SubjectId, type SubjectKey, subjectIdFromKey, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForTenant, subjectKeyForUser, } from "./kms-adapter.js";
|
|
7
7
|
export { type ActiveKmsWiring, buildPgKmsOptions, type KmsWiring, type KmsWiringEnv, type KmsWiringOptions, type KmsWiringRelease, type PgKmsRotationEnv, type PlaintextPiiWiring, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
|
|
8
8
|
export { createPgKmsAdapter, PgKmsAdapter, type PgKmsAdapterOptions, type RewrapOptions, type RewrapResult, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
|
|
9
|
-
export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
|
|
9
|
+
export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
|
|
10
10
|
export { createRequestKmsCache, type RequestKmsCache, } from "./request-kms-cache.js";
|
|
11
11
|
export { collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
package/dist/crypto/index.js
CHANGED
|
@@ -6,6 +6,6 @@ export { resolvePlatformKeks } from "./kek-source.js";
|
|
|
6
6
|
export { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, RECORD_ENTITY_PATTERN, subjectIdFromKey, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForTenant, subjectKeyForUser, } from "./kms-adapter.js";
|
|
7
7
|
export { buildPgKmsOptions, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
|
|
8
8
|
export { createPgKmsAdapter, PgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
|
|
9
|
-
export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
|
|
9
|
+
export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
|
|
10
10
|
export { createRequestKmsCache, } from "./request-kms-cache.js";
|
|
11
11
|
export { collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
|
|
2
|
+
export declare const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
|
|
3
|
+
export declare const PII_CIPHERTEXT_PREFIX_JSON = "kumiko-pii:v3:";
|
|
4
|
+
export declare function isPiiCiphertext(value: unknown): value is string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Leaf module (no engine imports) so bun-db and the executors can sniff PII
|
|
2
|
+
// ciphertext without pulling the KMS/pipeline graph into an import cycle.
|
|
3
|
+
export const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
|
|
4
|
+
export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
|
|
5
|
+
export const PII_CIPHERTEXT_PREFIX_JSON = "kumiko-pii:v3:";
|
|
6
|
+
export function isPiiCiphertext(value) {
|
|
7
|
+
return (typeof value === "string" &&
|
|
8
|
+
(value.startsWith(PII_CIPHERTEXT_PREFIX) ||
|
|
9
|
+
value.startsWith(PII_CIPHERTEXT_PREFIX_JSON) ||
|
|
10
|
+
value.startsWith(PII_CIPHERTEXT_PREFIX_V1)));
|
|
11
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
2
2
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
3
3
|
import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId } from "./kms-adapter.js";
|
|
4
|
+
import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON } from "./pii-ciphertext-format.js";
|
|
5
|
+
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
|
|
4
6
|
export declare const PII_ERASED_SENTINEL = "[[erased]]";
|
|
5
|
-
export declare const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
|
|
6
|
-
export declare function isPiiCiphertext(value: unknown): value is string;
|
|
7
7
|
export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
8
|
+
export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
8
9
|
export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
9
10
|
export interface EncryptPiiOptions {
|
|
10
11
|
readonly onlyKeys?: Iterable<string>;
|
|
@@ -13,6 +14,7 @@ export interface EncryptPiiOptions {
|
|
|
13
14
|
readonly entityName: string;
|
|
14
15
|
}
|
|
15
16
|
export declare function encryptPiiFieldValues(row: Record<string, unknown>, entity: EntityDefinition, piiFields: readonly string[], kms: LocalKeyKmsAdapter, kmsCtx: KmsContext, opts: EncryptPiiOptions): Promise<Record<string, unknown>>;
|
|
17
|
+
export declare function serializeJsonbValue(field: string, value: unknown): string;
|
|
16
18
|
export declare function decryptPiiFieldValues(row: Record<string, unknown>, piiFields: readonly string[], kms: LocalKeyKmsAdapter, kmsCtx: KmsContext): Promise<Record<string, unknown>>;
|
|
17
19
|
export declare function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void;
|
|
18
20
|
export declare function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined;
|
|
@@ -9,34 +9,41 @@
|
|
|
9
9
|
// SAME subject (same DEK, so key selection alone can't catch that) from
|
|
10
10
|
// decrypting silently. v1 (no AAD) stays decrypt-only for pre-#1263 rows;
|
|
11
11
|
// every new write emits v2.
|
|
12
|
+
// v3 = v2 for jsonb fields: the plaintext is JSON.stringify(value) and decrypt
|
|
13
|
+
// JSON.parses it back, so a jsonb string/number/object round-trips by type.
|
|
14
|
+
// The version digit (not a suffix) keeps `kumiko-pii:v\d+:` leak guards and
|
|
15
|
+
// the `kumiko-pii:v%:` LIKE sweeps matching. The AAD carries a "json" tag so
|
|
16
|
+
// a v2<->v3 prefix swap fails GCM instead of silently changing the type.
|
|
12
17
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
13
18
|
import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
|
|
14
19
|
import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
|
|
20
|
+
import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
|
|
15
21
|
import { resolveSubjectForField } from "./subject-resolver.js";
|
|
16
22
|
// Spec value (crypto-shredding.md) — renderers show it verbatim.
|
|
23
|
+
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
|
|
17
24
|
export const PII_ERASED_SENTINEL = "[[erased]]";
|
|
18
|
-
const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
|
|
19
|
-
export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
|
|
20
25
|
const IV_LENGTH = 12;
|
|
21
26
|
const AUTH_TAG_LENGTH = 16;
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
(value.startsWith(PII_CIPHERTEXT_PREFIX) || value.startsWith(PII_CIPHERTEXT_PREFIX_V1)));
|
|
27
|
+
function buildAad(subject, field, isJson) {
|
|
28
|
+
return Buffer.from(`${subjectIdToKey(subject)}|${field}${isJson ? "|json" : ""}`, "utf8");
|
|
25
29
|
}
|
|
26
|
-
function
|
|
27
|
-
return Buffer.from(`${subjectIdToKey(subject)}|${field}`, "utf8");
|
|
28
|
-
}
|
|
29
|
-
function encryptValue(subject, dek, plaintext, field) {
|
|
30
|
+
function encryptValue(subject, dek, plaintext, field, isJson = false) {
|
|
30
31
|
const iv = randomBytes(IV_LENGTH);
|
|
31
32
|
const cipher = createCipheriv("aes-256-gcm", dek, iv);
|
|
32
|
-
cipher.setAAD(buildAad(subject, field));
|
|
33
|
+
cipher.setAAD(buildAad(subject, field, isJson));
|
|
33
34
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
34
35
|
const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
35
|
-
|
|
36
|
+
const prefix = isJson ? PII_CIPHERTEXT_PREFIX_JSON : PII_CIPHERTEXT_PREFIX;
|
|
37
|
+
return `${prefix}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
|
|
36
38
|
}
|
|
37
39
|
function parseCiphertext(value) {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
+
const isJson = value.startsWith(PII_CIPHERTEXT_PREFIX_JSON);
|
|
41
|
+
const hasAad = isJson || value.startsWith(PII_CIPHERTEXT_PREFIX);
|
|
42
|
+
const prefix = isJson
|
|
43
|
+
? PII_CIPHERTEXT_PREFIX_JSON
|
|
44
|
+
: hasAad
|
|
45
|
+
? PII_CIPHERTEXT_PREFIX
|
|
46
|
+
: PII_CIPHERTEXT_PREFIX_V1;
|
|
40
47
|
const rest = value.slice(prefix.length);
|
|
41
48
|
// subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
|
|
42
49
|
// last ":" is always the key/blob separator.
|
|
@@ -47,15 +54,16 @@ function parseCiphertext(value) {
|
|
|
47
54
|
subject: subjectIdFromKey(rest.slice(0, sep)),
|
|
48
55
|
blob: Buffer.from(rest.slice(sep + 1), "base64"),
|
|
49
56
|
hasAad,
|
|
57
|
+
isJson,
|
|
50
58
|
};
|
|
51
59
|
}
|
|
52
|
-
function decryptValue(dek, blob, subject, field, hasAad) {
|
|
60
|
+
function decryptValue(dek, blob, subject, field, hasAad, isJson) {
|
|
53
61
|
const iv = blob.subarray(0, IV_LENGTH);
|
|
54
62
|
const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
55
63
|
const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
56
64
|
const decipher = createDecipheriv("aes-256-gcm", dek, iv);
|
|
57
65
|
if (hasAad)
|
|
58
|
-
decipher.setAAD(buildAad(subject, field));
|
|
66
|
+
decipher.setAAD(buildAad(subject, field, isJson));
|
|
59
67
|
decipher.setAuthTag(tag);
|
|
60
68
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
61
69
|
}
|
|
@@ -91,6 +99,12 @@ export async function encryptPiiValueForSubject(kms, subject, value, kmsCtx, fie
|
|
|
91
99
|
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
92
100
|
return encryptValue(subject, dek, value, field);
|
|
93
101
|
}
|
|
102
|
+
// Event-payload variant for non-string values (jsonb-typed payload fields):
|
|
103
|
+
// same DEK path, but the self-describing v3 format so decrypt restores the type.
|
|
104
|
+
export async function encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx, field) {
|
|
105
|
+
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
106
|
+
return encryptValue(subject, dek, serializeJsonbValue(field, value), field, true);
|
|
107
|
+
}
|
|
94
108
|
// Single-value decrypt for callers that don't have an entity/field-map to
|
|
95
109
|
// pass through decryptPiiFieldValues (config values). The subject lives
|
|
96
110
|
// inside the ciphertext itself, so no subject/scope resolution is needed
|
|
@@ -99,9 +113,12 @@ export async function encryptPiiValueForSubject(kms, subject, value, kmsCtx, fie
|
|
|
99
113
|
export async function decryptPiiValueForSubject(kms, value, kmsCtx, field) {
|
|
100
114
|
if (!isPiiCiphertext(value))
|
|
101
115
|
return value;
|
|
102
|
-
const { subject, blob, hasAad } = parseCiphertext(value);
|
|
116
|
+
const { subject, blob, hasAad, isJson } = parseCiphertext(value);
|
|
117
|
+
if (isJson) {
|
|
118
|
+
throw new Error(`PII field "${field}" holds a jsonb ciphertext — decrypt it via decryptPiiFieldValues`);
|
|
119
|
+
}
|
|
103
120
|
try {
|
|
104
|
-
return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad);
|
|
121
|
+
return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad, false);
|
|
105
122
|
}
|
|
106
123
|
catch (e) {
|
|
107
124
|
if (!(e instanceof KeyErasedError))
|
|
@@ -127,9 +144,11 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
|
|
|
127
144
|
// are already ciphertext or the erased sentinel — both stay as-is.
|
|
128
145
|
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL)
|
|
129
146
|
continue;
|
|
130
|
-
|
|
147
|
+
const isJsonbField = entity.fields[name]?.type === "jsonb";
|
|
148
|
+
if (!isJsonbField && typeof value !== "string") {
|
|
131
149
|
throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
|
|
132
150
|
}
|
|
151
|
+
const plaintext = isJsonbField ? serializeJsonbValue(name, value) : String(value);
|
|
133
152
|
const subject = resolveSubjectForField(entity, name, subjectSource, {
|
|
134
153
|
entityName: opts.entityName,
|
|
135
154
|
...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
|
|
@@ -138,10 +157,17 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
|
|
|
138
157
|
if (subject === null)
|
|
139
158
|
continue;
|
|
140
159
|
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
141
|
-
out[name] = encryptValue(subject, dek,
|
|
160
|
+
out[name] = encryptValue(subject, dek, plaintext, name, isJsonbField);
|
|
142
161
|
}
|
|
143
162
|
return out;
|
|
144
163
|
}
|
|
164
|
+
export function serializeJsonbValue(field, value) {
|
|
165
|
+
const json = JSON.stringify(value);
|
|
166
|
+
// undefined/function/symbol serialize to undefined — never storable as jsonb
|
|
167
|
+
if (json === undefined)
|
|
168
|
+
throw new Error(`PII jsonb field "${field}" is not JSON-serializable`);
|
|
169
|
+
return json;
|
|
170
|
+
}
|
|
145
171
|
export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
|
|
146
172
|
if (piiFields.length === 0)
|
|
147
173
|
return row;
|
|
@@ -152,9 +178,12 @@ export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
|
|
|
152
178
|
// work during rollout; backfill is tracked in kumiko-framework#799).
|
|
153
179
|
if (!isPiiCiphertext(value))
|
|
154
180
|
continue;
|
|
155
|
-
const { subject, blob, hasAad } = parseCiphertext(value);
|
|
181
|
+
const { subject, blob, hasAad, isJson } = parseCiphertext(value);
|
|
156
182
|
try {
|
|
157
|
-
|
|
183
|
+
const key = await kms.getKey(subject, kmsCtx);
|
|
184
|
+
const plain = decryptValue(key, blob, subject, name, hasAad, isJson);
|
|
185
|
+
// @cast-boundary crypto-decrypt — AEAD-authenticated plaintext that this module serialized
|
|
186
|
+
out[name] = isJson ? JSON.parse(plain) : plain;
|
|
158
187
|
}
|
|
159
188
|
catch (e) {
|
|
160
189
|
// KeyNotFound deliberately propagates: ciphertext without a key row
|
|
@@ -9,8 +9,8 @@ export const SYSTEM_EVENT_PREFIX = "kumiko:system:";
|
|
|
9
9
|
// an event-store import cycle; transfer.ts re-exports it.
|
|
10
10
|
export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
|
|
11
11
|
const SELF = { personal: "self" };
|
|
12
|
-
// Workflow payloads carry
|
|
13
|
-
//
|
|
12
|
+
// Workflow run-stream payloads carry references and step bookkeeping only,
|
|
13
|
+
// never a copy of a foreign event payload, so "none" leaves nothing to shred.
|
|
14
14
|
export const SYSTEM_EVENT_PII_STANCES = new Map([
|
|
15
15
|
[
|
|
16
16
|
STEP_DISPATCH_REQUESTED_TYPE,
|
|
@@ -300,6 +300,10 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
300
300
|
const value = section[field];
|
|
301
301
|
if (value === null || value === undefined)
|
|
302
302
|
continue;
|
|
303
|
+
// jsonb PII is not retro-encrypted (consumer wave); a legacy string
|
|
304
|
+
// value here would otherwise get the v2 text format into a jsonb column.
|
|
305
|
+
if (target.entity.fields[field]?.type === "jsonb")
|
|
306
|
+
continue;
|
|
303
307
|
if (typeof value !== "string")
|
|
304
308
|
continue;
|
|
305
309
|
// Already-ciphertext/sentinel fields must never enter owner
|
package/dist/db/query-api.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { SelectOptions, WhereObject, WhereOperator, WhereValue, } from "../db/query.js";
|
|
1
|
+
export type { JsonTextMatch, SelectOptions, WhereObject, WhereOperator, WhereValue, } from "../db/query.js";
|
|
2
2
|
export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "../db/query.js";
|
package/dist/db/query.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { type AnyDb, aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
|
|
1
|
+
export { type AnyDb, aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type JsonTextMatch, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -11,7 +11,7 @@ const declaredUnsafeRawRunners = new WeakMap();
|
|
|
11
11
|
// TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
|
|
12
12
|
// (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
|
|
13
13
|
const personalDataGates = new WeakMap();
|
|
14
|
-
// Lets createTenantDb(ctx.db.unsafeRaw(
|
|
14
|
+
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate. Keyed by a
|
|
15
15
|
// per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
|
|
16
16
|
const runnerPersonalDataGates = new WeakMap();
|
|
17
17
|
function gatedRunner(runner, gate) {
|
|
@@ -61,7 +61,7 @@ export function unsafeRawForDeclaredStep(holder, reason) {
|
|
|
61
61
|
"createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
|
-
return runner(reason
|
|
64
|
+
return runner(reason);
|
|
65
65
|
}
|
|
66
66
|
const systemDbRebinders = new WeakMap();
|
|
67
67
|
// Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
|
|
@@ -104,7 +104,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
104
104
|
message: "createSystemDbView received a TenantDb with no declared unsafeRaw runner bound.",
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
|
-
return sourceRunner(reason
|
|
107
|
+
return sourceRunner(forwardedDeclaredStep ? reason : undefined);
|
|
108
108
|
}
|
|
109
109
|
if (reason.trim().length === 0) {
|
|
110
110
|
throw new Error("unsafeRaw requires a non-empty reason");
|
|
@@ -170,7 +170,14 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
170
170
|
},
|
|
171
171
|
},
|
|
172
172
|
};
|
|
173
|
-
declaredUnsafeRawRunners.set(uncheckedSystemDb,
|
|
173
|
+
declaredUnsafeRawRunners.set(uncheckedSystemDb, (declaredStepReason) => {
|
|
174
|
+
if (declaredStepReason === undefined) {
|
|
175
|
+
throw new InternalError({
|
|
176
|
+
message: "A declared unsafeRaw runner on a system db requires the step reason.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return grantedUnsafeRawRunner(declaredStepReason, true);
|
|
180
|
+
});
|
|
174
181
|
if (gate?.kind !== "source-tenant-db") {
|
|
175
182
|
systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) => buildUncheckedSystemDb(withUnsafeRawGrant(db, grant), dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant), report, { kind: "hook-grant", grant, deniedCallerLabel }));
|
|
176
183
|
}
|
|
@@ -426,29 +433,26 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
426
433
|
// @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
|
|
427
434
|
};
|
|
428
435
|
}
|
|
429
|
-
function grantedUnsafeRawRunner(
|
|
430
|
-
if (reason.trim().length === 0) {
|
|
431
|
-
throw new Error("unsafeRaw requires a non-empty reason");
|
|
432
|
-
}
|
|
436
|
+
function grantedUnsafeRawRunner(declaredStepReason) {
|
|
433
437
|
// Ahead of the grant check: a declared escapeHatch must not buy a raw runner here either.
|
|
434
438
|
if (grants?.memberReadOnly) {
|
|
435
439
|
throw memberResolutionReadOnlyDenied();
|
|
436
440
|
}
|
|
437
441
|
if (!hasGrant(grants?.unsafeRaw)) {
|
|
438
442
|
throw new AccessDeniedError({
|
|
439
|
-
message: 'ctx.db.unsafeRaw(
|
|
443
|
+
message: 'ctx.db.unsafeRaw(): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
440
444
|
"the handler or hook to allow unsafeRaw.",
|
|
441
445
|
});
|
|
442
446
|
}
|
|
443
|
-
// Engine-forwarded steps carry their own declared reason;
|
|
444
|
-
report("unsafe-raw",
|
|
447
|
+
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
448
|
+
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
445
449
|
return personalDataGate ? gatedRunner(db, personalDataGate) : db;
|
|
446
450
|
}
|
|
447
451
|
const tenantDb = {
|
|
448
452
|
tenantId,
|
|
449
453
|
mode,
|
|
450
454
|
global: globalTable,
|
|
451
|
-
unsafeRaw: (
|
|
455
|
+
unsafeRaw: () => grantedUnsafeRawRunner(),
|
|
452
456
|
selectMany(table, where, options) {
|
|
453
457
|
const filter = readWhere(table, where);
|
|
454
458
|
return withDbSpan("select", table, async () => bunSelectMany(db, table, filter, options));
|
|
@@ -6,6 +6,7 @@ export declare const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string>;
|
|
|
6
6
|
/** Extensions with preSave must target an entity that has mapped write handlers. */
|
|
7
7
|
export declare function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void;
|
|
8
8
|
export declare function validateHandlerAccess(feature: FeatureDefinition): void;
|
|
9
|
+
export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
9
10
|
export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
10
11
|
export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
|
|
11
12
|
export declare function validateAnonymousRateLimit(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
@@ -134,6 +134,25 @@ export function validateHandlerAccess(feature) {
|
|
|
134
134
|
validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
138
|
+
validateTenantlessAnonymous(feature.name, name, handler);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
142
|
+
// skip: handler does not opt into tenantless anonymous calls
|
|
143
|
+
if (!handler.tenantlessAnonymous)
|
|
144
|
+
return;
|
|
145
|
+
const where = `write handler "${featureName}:write:${handlerName}" declares tenantlessAnonymous`;
|
|
146
|
+
const { access } = handler;
|
|
147
|
+
const isAnonymousOnly = "roles" in access && access.roles.length === 1 && access.roles[0] === "anonymous";
|
|
148
|
+
if (!isAnonymousOnly) {
|
|
149
|
+
throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
|
|
150
|
+
`carries no principal beyond the anonymous visitor.`);
|
|
151
|
+
}
|
|
152
|
+
if (!handler.rateLimit || isRateLimitDisabled(handler.rateLimit)) {
|
|
153
|
+
throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
|
|
154
|
+
`needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
|
|
155
|
+
}
|
|
137
156
|
}
|
|
138
157
|
export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
|
|
139
158
|
// skip: rate limit isn't disabled, so no reason is required
|
|
@@ -71,6 +71,33 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
// A personal jsonb field is stored as a ciphertext JSON string scalar, so
|
|
75
|
+
// anything that reads inside the column (index, filter, sort, search, jsonb
|
|
76
|
+
// operators/merges such as the custom-fields column) sees only ciphertext.
|
|
77
|
+
const JSONB_PERSONAL_FORBIDDEN_FLAGS = [
|
|
78
|
+
"sortable",
|
|
79
|
+
"filterable",
|
|
80
|
+
"searchable",
|
|
81
|
+
"lookupable",
|
|
82
|
+
];
|
|
83
|
+
const CUSTOM_FIELDS_COLUMN = "customFields";
|
|
84
|
+
function validatePersonalJsonbField(featureName, entityName, entity, fieldName, field) {
|
|
85
|
+
const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
|
|
86
|
+
const reason = "a personal jsonb field is stored as a ciphertext string, so nothing inside it can be indexed, filtered, sorted, searched or merged";
|
|
87
|
+
const flags = field; // @cast-boundary schema-walk
|
|
88
|
+
for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
|
|
89
|
+
if (flags[flag] !== undefined && flags[flag] !== false) {
|
|
90
|
+
throw new Error(`${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
|
|
94
|
+
if (index) {
|
|
95
|
+
throw new Error(`${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`);
|
|
96
|
+
}
|
|
97
|
+
if (fieldName === CUSTOM_FIELDS_COLUMN) {
|
|
98
|
+
throw new Error(`${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
74
101
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
75
102
|
//
|
|
76
103
|
// Drei Klassen von Checks:
|
|
@@ -153,6 +180,9 @@ export function validatePiiAndRetention(feature) {
|
|
|
153
180
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
|
|
154
181
|
}
|
|
155
182
|
}
|
|
183
|
+
if (field.type === "jsonb" && annotCount > 0) {
|
|
184
|
+
validatePersonalJsonbField(feature.name, entityName, entity, fieldName, field);
|
|
185
|
+
}
|
|
156
186
|
if (annot.userOwned) {
|
|
157
187
|
const ownerName = annot.userOwned.ownerField;
|
|
158
188
|
if (!ownerName || typeof ownerName !== "string") {
|
|
@@ -51,6 +51,7 @@ export function defineWriteHandler(def,
|
|
|
51
51
|
}),
|
|
52
52
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
53
53
|
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
54
|
+
...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
|
|
54
55
|
};
|
|
55
56
|
if ("perform" in def && def.perform !== undefined) {
|
|
56
57
|
const performDef = def.perform;
|
|
@@ -30,7 +30,7 @@ export type TenantUserModel = "single-user" | "multi-user";
|
|
|
30
30
|
* fw#2914 — `db` is a tenant-filtered `TenantDb`, bound to `tenantId`
|
|
31
31
|
* (or the per-user sub-tx in the forget path). Unfiltered access needs
|
|
32
32
|
* `escapeHatch: { reason }` on the `r.useExtension(...)` registration,
|
|
33
|
-
* then `ctx.db.unsafeRaw(
|
|
33
|
+
* then `ctx.db.unsafeRaw()`.
|
|
34
34
|
*/
|
|
35
35
|
/**
|
|
36
36
|
* Minimal storage surface a file-aware forget hook needs to erase binaries.
|
|
@@ -106,6 +106,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
106
106
|
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
107
107
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
108
108
|
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
109
|
+
...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
|
|
109
110
|
// Forward the pipeline-build closure so boot-validators and
|
|
110
111
|
// Designer/AI tooling can inspect the step list. Absent on
|
|
111
112
|
// free-form handlers — defineWriteHandler only sets `perform`
|
|
@@ -30,6 +30,12 @@ export type ManifestExtension = {
|
|
|
30
30
|
readonly extensionName: string;
|
|
31
31
|
readonly entityName: string;
|
|
32
32
|
};
|
|
33
|
+
/** Framework-owned registered event (`kumiko:system:*`), e.g. the workflow
|
|
34
|
+
* run-stream events a projection can apply. Not owned by any feature. */
|
|
35
|
+
export type ManifestSystemEvent = {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly version: number;
|
|
38
|
+
};
|
|
33
39
|
export type ManifestFeature = {
|
|
34
40
|
readonly name: string;
|
|
35
41
|
readonly description: string | null;
|
|
@@ -58,6 +64,7 @@ export type FeatureManifest = {
|
|
|
58
64
|
readonly source: string;
|
|
59
65
|
readonly featureCount: number;
|
|
60
66
|
readonly features: readonly ManifestFeature[];
|
|
67
|
+
readonly systemEvents?: readonly ManifestSystemEvent[];
|
|
61
68
|
readonly tier?: string;
|
|
62
69
|
};
|
|
63
70
|
export type BuildManifestOptions = {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// die Kopie still. Quelle der Wahrheit ist die GEBOOTETE Registry — der
|
|
6
6
|
// AST-Parser kann die imperativen Factory-Helper der bundled features nicht
|
|
7
7
|
// lesen.
|
|
8
|
+
import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii.js";
|
|
8
9
|
import { compareByCodepoint } from "../utils/index.js";
|
|
9
10
|
import { isEncryptedAtRest } from "./config-helpers.js";
|
|
10
11
|
import { qualifyEntityName } from "./qualified-name.js";
|
|
@@ -76,10 +77,17 @@ export function buildManifestFromRegistry(registry, options) {
|
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
manifestFeatures.sort((a, b) => compareByCodepoint(a.name, b.name));
|
|
80
|
+
const systemEvents = [];
|
|
81
|
+
for (const [name, info] of registry.getEventUpcasters()) {
|
|
82
|
+
if (name.startsWith(SYSTEM_EVENT_PREFIX))
|
|
83
|
+
systemEvents.push({ name, version: info.currentVersion });
|
|
84
|
+
}
|
|
85
|
+
systemEvents.sort((a, b) => compareByCodepoint(a.name, b.name));
|
|
79
86
|
return {
|
|
80
87
|
source: options.source,
|
|
81
88
|
featureCount: manifestFeatures.length,
|
|
82
89
|
features: manifestFeatures,
|
|
90
|
+
systemEvents,
|
|
83
91
|
...(options.tier !== undefined && { tier: options.tier }),
|
|
84
92
|
};
|
|
85
93
|
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export { createBigIntField, createBooleanField, createDateField, createDecimalFi
|
|
|
40
40
|
export type { AddEntityArgs, AddHookArgs, AddRelationArgs, AddWriteHandlerArgs, FeaturePatcher, FeaturePattern, FeaturePatternKind, FormFieldLabel, FormFieldSpec, FormInputType, HandlerHeaderUpdate, ParseError, ParseResult, PatternCategory, PatternChange, PatternChangeIssue, PatternChangesParseResult, PatternFormSchema, PatternId, RenderFeatureFileInput, SourceLocation, } from "./feature-ast/index.js";
|
|
41
41
|
export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
|
|
42
42
|
export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
|
|
43
|
-
export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, serializeManifest, } from "./feature-manifest.js";
|
|
43
|
+
export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.js";
|
|
44
44
|
export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
|
|
45
45
|
export { resolveName, withResponseData } from "./handler-helpers.js";
|
|
46
46
|
export { i18nKey } from "./i18n-key.js";
|
|
@@ -70,6 +70,7 @@ export { defineTransitions, guardTransition } from "./state-machine.js";
|
|
|
70
70
|
export { evaluateEventMatch } from "./steps/_event-match.js";
|
|
71
71
|
export { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, SUSPEND_SENTINEL, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./steps/_step-dispatch-constants.js";
|
|
72
72
|
export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.js";
|
|
73
|
+
export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
73
74
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
74
75
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
75
76
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -64,6 +64,7 @@ export { defineTransitions, guardTransition } from "./state-machine.js";
|
|
|
64
64
|
export { evaluateEventMatch } from "./steps/_event-match.js";
|
|
65
65
|
export { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, SUSPEND_SENTINEL, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./steps/_step-dispatch-constants.js";
|
|
66
66
|
export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.js";
|
|
67
|
+
export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
67
68
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
68
69
|
export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
|
|
69
70
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
@@ -5,6 +5,7 @@ export declare function populateHandlers(state: RegistryState, feature: FeatureD
|
|
|
5
5
|
export declare function populateConfigKeys(state: RegistryState, feature: FeatureDefinition): void;
|
|
6
6
|
export declare function populateJobsAndNotifications(state: RegistryState, feature: FeatureDefinition): void;
|
|
7
7
|
export declare function populateEvents(state: RegistryState, feature: FeatureDefinition): void;
|
|
8
|
+
export declare function populateSystemEvents(state: RegistryState): void;
|
|
8
9
|
export declare function populateTranslations(state: RegistryState, feature: FeatureDefinition): void;
|
|
9
10
|
export declare function populateHooks(state: RegistryState, feature: FeatureDefinition): void;
|
|
10
11
|
export declare function populateExtensionsAndSeeds(state: RegistryState, feature: FeatureDefinition): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveTableName } from "../db/entity-table-meta.js";
|
|
2
2
|
import { buildMetricName, validateMetricName } from "../observability/index.js";
|
|
3
3
|
import { mergeHookList, mergeHookListQualified, qualify } from "./registry-state.js";
|
|
4
|
+
import { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
4
5
|
// Feature registration + entities (globally-unique, physical-table-checked) + relations
|
|
5
6
|
// (additive per entity, duplicate-per-name guarded).
|
|
6
7
|
export function populateFeatureCore(state, feature) {
|
|
@@ -132,6 +133,14 @@ export function populateEvents(state, feature) {
|
|
|
132
133
|
state.eventMap.set(qualified, { ...eventDef, name: qualified });
|
|
133
134
|
}
|
|
134
135
|
}
|
|
136
|
+
// Framework-owned events outside any feature namespace (kumiko:system:workflow.*).
|
|
137
|
+
// Seeded into the same eventMap as r.defineEvent so apply-key validation, the
|
|
138
|
+
// PII catalog and the upcaster chain see them through one path.
|
|
139
|
+
export function populateSystemEvents(state) {
|
|
140
|
+
for (const [name, eventDef] of WORKFLOW_SYSTEM_EVENT_DEFS) {
|
|
141
|
+
state.eventMap.set(name, eventDef);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
135
144
|
export function populateTranslations(state, feature) {
|
|
136
145
|
// Translations prefixed with featureName: (i18next namespace convention).
|
|
137
146
|
// Keys that already carry the feature's own namespace prefix (e.g. a nav
|
package/dist/engine/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler.js";
|
|
2
2
|
import { dedupeFeatures } from "./dedupe-features.js";
|
|
3
3
|
import { buildRegistryFacade } from "./registry-facade.js";
|
|
4
|
-
import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateTranslations, } from "./registry-ingest.js";
|
|
4
|
+
import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateSystemEvents, populateTranslations, } from "./registry-ingest.js";
|
|
5
5
|
import { createInitialState } from "./registry-state.js";
|
|
6
6
|
import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
|
|
7
7
|
// This is where the magic happens. By "magic" I mean: precomputed maps.
|
|
@@ -24,6 +24,7 @@ export function createRegistry(rawFeatures) {
|
|
|
24
24
|
populateClaimsAndAuth(state, feature);
|
|
25
25
|
populateScreensNavWorkspaces(state, feature);
|
|
26
26
|
}
|
|
27
|
+
populateSystemEvents(state);
|
|
27
28
|
finalizeWorkspaceNavMembership(state);
|
|
28
29
|
populateHandlerEntityMappings(state, features);
|
|
29
30
|
validateExtensionSelectors(state);
|
|
@@ -54,7 +54,6 @@ defineStep({
|
|
|
54
54
|
workflowName: ctx.workflow.workflowName,
|
|
55
55
|
error: describeWorkflowStepError(error),
|
|
56
56
|
triggerEventType: ctx.event.type,
|
|
57
|
-
triggerPayload: ctx.event.payload,
|
|
58
57
|
...(ctx.workflow.definitionFingerprint && {
|
|
59
58
|
definitionFingerprint: ctx.workflow.definitionFingerprint,
|
|
60
59
|
}),
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// natural key + tenantId). Skips lifecycle hooks, field-access,
|
|
5
5
|
// crypto-shredding, schema-versioning, audit-trail, read-access-log.
|
|
6
6
|
// See "Was unsafeProjection.* überspringt" in
|
|
7
|
-
// docs/plans/architecture/intern/step-vocabulary.md.
|
|
7
|
+
// kumiko-platform/docs/archive/plans/architecture/intern/step-vocabulary.md.
|
|
8
8
|
//
|
|
9
9
|
// Use only on tables explicitly declared via r.requires.projection in
|
|
10
10
|
// the owning feature. Aggregate-tables (registered via r.entity) are
|
|
@@ -49,7 +49,6 @@ defineStep({
|
|
|
49
49
|
stepIndex: ctx.workflow.stepIndex,
|
|
50
50
|
workflowName: ctx.workflow.workflowName,
|
|
51
51
|
triggerEventType: ctx.event.type,
|
|
52
|
-
triggerPayload: ctx.event.payload,
|
|
53
52
|
...(ctx.workflow.definitionFingerprint && {
|
|
54
53
|
definitionFingerprint: ctx.workflow.definitionFingerprint,
|
|
55
54
|
}),
|
|
@@ -32,11 +32,7 @@ defineStep({
|
|
|
32
32
|
wakeAt,
|
|
33
33
|
stepIndex: ctx.workflow.stepIndex,
|
|
34
34
|
workflowName: ctx.workflow.workflowName,
|
|
35
|
-
// Trigger snapshot: pinned so the resume-loop re-feeds the
|
|
36
|
-
// pipeline with what the original run saw. event-sourcing across
|
|
37
|
-
// suspensions hinges on this being stable.
|
|
38
35
|
triggerEventType: ctx.event.type,
|
|
39
|
-
triggerPayload: ctx.event.payload,
|
|
40
36
|
...(ctx.workflow.definitionFingerprint && {
|
|
41
37
|
definitionFingerprint: ctx.workflow.definitionFingerprint,
|
|
42
38
|
}),
|