@cosmicdrift/kumiko-framework 0.360.0 → 0.362.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/server.js +14 -1
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.d.ts +2 -0
- package/dist/crypto/owner-transition-rekey.js +30 -0
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +5 -0
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +14 -1
- package/dist/db/apply-entity-event.js +0 -5
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-field-encryption.js +3 -2
- package/dist/db/entity-table-meta.js +3 -3
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +9 -2
- package/dist/db/event-store-executor-write.js +31 -5
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/event-store-executor.js +1 -0
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.js +2 -2
- package/dist/db/migrate-generator.d.ts +1 -0
- package/dist/db/migrate-generator.js +12 -0
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +120 -15
- package/dist/db/table-builder.d.ts +3 -1
- package/dist/db/table-builder.js +3 -6
- package/dist/engine/boot-validator/access-declarations.js +29 -18
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +38 -1
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +3 -0
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +89 -14
- package/dist/engine/build-app-schema.js +1 -0
- package/dist/engine/define-handler.js +1 -3
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
- package/dist/engine/feature-ast/extractors/round3.js +4 -4
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/schema-builder.js +2 -2
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +9 -0
- package/dist/engine/soft-delete-cleanup.js +12 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/classes.d.ts +7 -0
- package/dist/errors/classes.js +12 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
- package/dist/files/bind-pending-file-refs.d.ts +1 -0
- package/dist/files/bind-pending-file-refs.js +13 -13
- package/dist/files/bind-referenced-files.write.d.ts +16 -0
- package/dist/files/bind-referenced-files.write.js +109 -0
- package/dist/files/dispose-removed-file-refs.d.ts +8 -0
- package/dist/files/dispose-removed-file-refs.js +51 -0
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-entity.js +1 -1
- package/dist/files/file-routes.d.ts +4 -1
- package/dist/files/file-routes.js +37 -6
- package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
- package/dist/files/purge-expired-file-ref-bytes.js +34 -0
- package/dist/files/record-write-check.d.ts +22 -0
- package/dist/files/record-write-check.js +64 -0
- package/dist/files/types.d.ts +1 -0
- package/dist/files/types.js +3 -0
- package/dist/i18n/required-surface-keys.js +10 -0
- package/dist/migrations/pii-event-backfill.d.ts +1 -0
- package/dist/migrations/pii-event-backfill.js +16 -2
- package/dist/pipeline/cascade-handler.js +17 -2
- package/dist/pipeline/dispatch-write.js +6 -6
- package/dist/pipeline/write-origin.js +2 -2
- package/dist/schema-cli.js +12 -6
- package/dist/search/reindex-entity.js +3 -4
- package/dist/stack/test-stack.d.ts +3 -1
- package/dist/stack/test-stack.js +1 -0
- package/dist/ui-types/card-column-roles.d.ts +5 -0
- package/dist/ui-types/card-column-roles.js +22 -4
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/package.json +4 -4
- package/src/changes.json +134 -0
package/dist/api/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { ROLES } from "../auth/roles.js";
|
|
3
|
+
import { createTenantDb } from "../db/tenant-db.js";
|
|
3
4
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
4
5
|
import { buildAppSchema } from "../engine/build-app-schema.js";
|
|
5
6
|
import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
|
|
@@ -12,6 +13,7 @@ import { createFileContext } from "../files/file-handle.js";
|
|
|
12
13
|
import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
|
|
13
14
|
import { makeFileProviderResolver } from "../files/provider-resolver.js";
|
|
14
15
|
import { createRecordReadCheck } from "../files/record-read-check.js";
|
|
16
|
+
import { createRecordWriteCheck } from "../files/record-write-check.js";
|
|
15
17
|
import { createFallbackLogger } from "../logging/index.js";
|
|
16
18
|
import { createNoopProvider, DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
|
|
17
19
|
import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
|
|
@@ -687,13 +689,24 @@ export function buildServer(options) {
|
|
|
687
689
|
if (!fileDb)
|
|
688
690
|
throw new Error("file routes require db in context");
|
|
689
691
|
const routeOptions = readFilesRouteOptions(options.registry);
|
|
692
|
+
const canReadRecord = createRecordReadCheck(options.registry, dispatcher.query);
|
|
690
693
|
app.route("/api", createFileRoutes({
|
|
691
694
|
db: fileDb,
|
|
692
695
|
registry: options.registry,
|
|
693
696
|
resolveProvider: fileProviderResolver,
|
|
694
|
-
canReadRecord
|
|
697
|
+
canReadRecord,
|
|
698
|
+
canWriteRecord: createRecordWriteCheck({
|
|
699
|
+
registry: options.registry,
|
|
700
|
+
canReadRecord,
|
|
701
|
+
tenantDbFor: (tenantId) => createTenantDb(fileDb, tenantId, "tenant", undefined, undefined, undefined, {
|
|
702
|
+
projectionRegistry: options.registry,
|
|
703
|
+
}),
|
|
704
|
+
}),
|
|
695
705
|
...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
|
|
696
706
|
...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
|
|
707
|
+
...(routeOptions.unclaimedUploadReaders
|
|
708
|
+
? { unclaimedUploadReaders: routeOptions.unclaimedUploadReaders }
|
|
709
|
+
: {}),
|
|
697
710
|
...(routeOptions.maxUploadSize ? { maxUploadSize: routeOptions.maxUploadSize } : {}),
|
|
698
711
|
}));
|
|
699
712
|
}
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -8,4 +8,4 @@ export { type ActiveKmsWiring, buildPgKmsOptions, type KmsWiring, type KmsWiring
|
|
|
8
8
|
export { createPgKmsAdapter, PgKmsAdapter, type PgKmsAdapterOptions, type RewrapOptions, type RewrapResult, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
|
|
9
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
|
-
export { collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
|
11
|
+
export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
package/dist/crypto/index.js
CHANGED
|
@@ -8,4 +8,4 @@ export { buildPgKmsOptions, requireKmsWiring, requireKmsWiringAsync, resolveKmsW
|
|
|
8
8
|
export { createPgKmsAdapter, PgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
|
|
9
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
|
-
export { collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
|
11
|
+
export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
2
|
+
export declare function personalFieldsRekeyedOnOwnerTransition(entity: EntityDefinition, previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PII_ERASED_SENTINEL } from "./kms-adapter.js";
|
|
2
|
+
import { isPiiCiphertext } from "./pii-ciphertext-format.js";
|
|
3
|
+
import { nonEmptyString } from "./subject-resolver.js";
|
|
4
|
+
// Personal fields keep their stored value when an update moves the row to a
|
|
5
|
+
// new owner, so without re-keying they would stay shreddable only by the old
|
|
6
|
+
// owner. Returns the unchanged-by-the-caller values that must be written again
|
|
7
|
+
// so they encrypt under the new owner. Owner → absent is not a transition:
|
|
8
|
+
// the value stays with the last owner so that owner's forget still erases it.
|
|
9
|
+
export function personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges) {
|
|
10
|
+
const rekeyed = {};
|
|
11
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
12
|
+
if (!("userOwned" in field) || field.userOwned === undefined)
|
|
13
|
+
continue;
|
|
14
|
+
if ("recordOwned" in field && field.recordOwned === true)
|
|
15
|
+
continue;
|
|
16
|
+
if (name in submittedChanges)
|
|
17
|
+
continue;
|
|
18
|
+
const { ownerField } = field.userOwned;
|
|
19
|
+
const nextOwner = nonEmptyString(mergedNew[ownerField]);
|
|
20
|
+
if (nextOwner === null || nextOwner === nonEmptyString(previous[ownerField]))
|
|
21
|
+
continue;
|
|
22
|
+
const value = previous[name];
|
|
23
|
+
if (value === null || value === undefined || value === PII_ERASED_SENTINEL)
|
|
24
|
+
continue;
|
|
25
|
+
if (typeof value === "string" && isPiiCiphertext(value))
|
|
26
|
+
continue;
|
|
27
|
+
rekeyed[name] = value;
|
|
28
|
+
}
|
|
29
|
+
return rekeyed;
|
|
30
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { TenantId } from "../engine/types/identifiers.js";
|
|
|
3
3
|
import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, PII_ERASED_SENTINEL, type SubjectId } from "./kms-adapter.js";
|
|
4
4
|
import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON } from "./pii-ciphertext-format.js";
|
|
5
5
|
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
|
|
6
|
+
export declare function piiCiphertextSubject(value: string): SubjectId;
|
|
6
7
|
export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
7
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>;
|
|
@@ -87,6 +87,11 @@ async function getOrCreateDek(kms, subject, ctx) {
|
|
|
87
87
|
}
|
|
88
88
|
return kms.getKey(subject, ctx);
|
|
89
89
|
}
|
|
90
|
+
// The subject a ciphertext is keyed under, read from its inline prefix without
|
|
91
|
+
// touching the KMS.
|
|
92
|
+
export function piiCiphertextSubject(value) {
|
|
93
|
+
return parseCiphertext(value).subject;
|
|
94
|
+
}
|
|
90
95
|
// Single-value encrypt for callers that resolve the subject themselves
|
|
91
96
|
// (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
|
|
92
97
|
// idempotent like the field-map variant. `field` names the value's storage
|
|
@@ -12,6 +12,7 @@ export interface ResolveSubjectOptions {
|
|
|
12
12
|
readonly ownerFallback?: Record<string, unknown>;
|
|
13
13
|
readonly ignoreWhenAbsent?: boolean;
|
|
14
14
|
}
|
|
15
|
+
export declare function nonEmptyString(value: unknown): string | null;
|
|
15
16
|
/**
|
|
16
17
|
* Maps a pii-annotated field to the subject whose key encrypts it.
|
|
17
18
|
* Returns null for fields without any PII annotation (stored plaintext).
|
|
@@ -28,5 +29,6 @@ export interface EventSubjectEnvelope {
|
|
|
28
29
|
}
|
|
29
30
|
export declare function resolveEventSubject(fieldName: string, spec: EventPiiSubject, payload: Record<string, unknown>, envelope: EventSubjectEnvelope): SubjectId | null;
|
|
30
31
|
export declare function collectPiiSubjectFields(entity: EntityDefinition): readonly string[];
|
|
32
|
+
export declare function collectPiiEncryptedFields(entity: EntityDefinition): readonly string[];
|
|
31
33
|
/** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
|
|
32
34
|
export declare function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[];
|
|
@@ -11,7 +11,7 @@ export class SubjectResolutionError extends Error {
|
|
|
11
11
|
this.name = "SubjectResolutionError";
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
function nonEmptyString(value) {
|
|
14
|
+
export function nonEmptyString(value) {
|
|
15
15
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
16
16
|
}
|
|
17
17
|
function rowIdAsString(row) {
|
|
@@ -142,6 +142,19 @@ export function collectPiiSubjectFields(entity) {
|
|
|
142
142
|
.filter(([, field]) => hasSubjectAnnotation(field))
|
|
143
143
|
.map(([name]) => name);
|
|
144
144
|
}
|
|
145
|
+
function isFileReferenceField(field) {
|
|
146
|
+
return (field.type === "file" ||
|
|
147
|
+
field.type === "image" ||
|
|
148
|
+
field.type === "files" ||
|
|
149
|
+
field.type === "images");
|
|
150
|
+
}
|
|
151
|
+
// File columns hold only the fileRef uuid; the binary is erased by the file-ref
|
|
152
|
+
// user-data hook, so these stay annotated (forget) but are never ciphertext.
|
|
153
|
+
export function collectPiiEncryptedFields(entity) {
|
|
154
|
+
return Object.entries(entity.fields)
|
|
155
|
+
.filter(([, field]) => hasSubjectAnnotation(field) && !isFileReferenceField(field))
|
|
156
|
+
.map(([name]) => name);
|
|
157
|
+
}
|
|
145
158
|
/** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
|
|
146
159
|
export function collectSearchableSubjectFields(entity) {
|
|
147
160
|
return Object.entries(entity.fields)
|
|
@@ -109,9 +109,6 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
109
109
|
case "updated": {
|
|
110
110
|
// payload-Shape: { changes, previous } — siehe event-store-executor.ts.
|
|
111
111
|
const rawChanges = (event.payload["changes"] ?? {}); // @cast-boundary engine-payload
|
|
112
|
-
// Plural file fields (files/images) have no entity-table column — the
|
|
113
|
-
// array of UUIDs lives in the event payload only. Strip them from the
|
|
114
|
-
// UPDATE so Postgres doesn't error with "column X does not exist".
|
|
115
112
|
const changes = {};
|
|
116
113
|
for (const [key, value] of Object.entries(rawChanges)) {
|
|
117
114
|
const field = entity.fields[key];
|
|
@@ -121,8 +118,6 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
121
118
|
// columns without an entity field and must still be applied.
|
|
122
119
|
if (field === undefined && !(key in table))
|
|
123
120
|
continue;
|
|
124
|
-
if (field?.type === "files" || field?.type === "images")
|
|
125
|
-
continue;
|
|
126
121
|
changes[key] = value;
|
|
127
122
|
}
|
|
128
123
|
const rows = await updateMany(tx, table, {
|
package/dist/db/eagerload.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// Custom handlers must pass the viewer explicitly so the parentRef gate is
|
|
19
19
|
// never skipped silently.
|
|
20
20
|
import { requestContext } from "../api/request-context.js";
|
|
21
|
-
import {
|
|
21
|
+
import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
|
|
22
22
|
import { selectMany } from "../db/query.js";
|
|
23
23
|
import { filterReadFields } from "../engine/field-access.js";
|
|
24
24
|
import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
|
|
@@ -162,7 +162,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
162
162
|
const rawRefRows = scope.kind === "restricted"
|
|
163
163
|
? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
|
|
164
164
|
: lookedUpRows;
|
|
165
|
-
const piiFields =
|
|
165
|
+
const piiFields = collectPiiEncryptedFields(refEntity);
|
|
166
166
|
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
167
167
|
const kms = configuredPiiSubjectKms();
|
|
168
168
|
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// StoredEnvelope in the TEXT column, kekVersion per value, legacy
|
|
4
4
|
// single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
|
|
5
5
|
// the cipher's legacy fallback until re-encrypted.
|
|
6
|
-
import {
|
|
6
|
+
import { collectPiiEncryptedFields } from "../crypto/index.js";
|
|
7
7
|
import { createLogger } from "../logging/index.js";
|
|
8
8
|
import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider.js";
|
|
9
9
|
export function collectEncryptedFieldNames(entity) {
|
|
@@ -19,7 +19,8 @@ export function collectEncryptedFieldNames(entity) {
|
|
|
19
19
|
// depending on runtime KMS wiring (PII) — either way it has no plaintext
|
|
20
20
|
// column to compare against directly.
|
|
21
21
|
export function isSensitiveLabelField(entity, field) {
|
|
22
|
-
return (collectEncryptedFieldNames(entity).has(field) ||
|
|
22
|
+
return (collectEncryptedFieldNames(entity).has(field) ||
|
|
23
|
+
collectPiiEncryptedFields(entity).includes(field));
|
|
23
24
|
}
|
|
24
25
|
// `searchable: true` on a text field means it is indexed plaintext
|
|
25
26
|
// somewhere findable by term — a plain text field's own column, or (for a
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// entity-shaped base columns. Escape hatch: no forced audit trail for the
|
|
16
16
|
// hand-built path; app author owns tenant scoping / versioning. Prefer a
|
|
17
17
|
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
18
|
-
import {
|
|
18
|
+
import { collectPiiEncryptedFields } from "../crypto/index.js";
|
|
19
19
|
import { SQL_EXPR_BRAND } from "./dialect.js";
|
|
20
20
|
import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
21
21
|
// Standard base columns for event-sourced read-model tables. Mirrors
|
|
@@ -192,7 +192,7 @@ function fieldToColumnMeta(name, field, entity) {
|
|
|
192
192
|
return [{ name: snake, pgType: "uuid", notNull: field.required === true }];
|
|
193
193
|
case "files":
|
|
194
194
|
case "images":
|
|
195
|
-
return [];
|
|
195
|
+
return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'[]'::jsonb" }];
|
|
196
196
|
default:
|
|
197
197
|
return [];
|
|
198
198
|
}
|
|
@@ -345,7 +345,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
}
|
|
348
|
-
const piiSubjectFields =
|
|
348
|
+
const piiSubjectFields = collectPiiEncryptedFields(entity);
|
|
349
349
|
return {
|
|
350
350
|
tableName,
|
|
351
351
|
columns,
|
|
@@ -65,6 +65,7 @@ export type ExecutorContext = {
|
|
|
65
65
|
ownerFallback?: Record<string, unknown>;
|
|
66
66
|
}) => Promise<Record<string, unknown>>;
|
|
67
67
|
readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
|
|
68
|
+
readonly ownerTransitionRekeys: (previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>) => Record<string, unknown>;
|
|
68
69
|
readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
|
|
69
70
|
readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
|
|
70
71
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import { requestContext } from "../api/request-context.js";
|
|
3
|
-
import {
|
|
3
|
+
import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
4
|
+
import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
|
|
4
5
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
6
|
import { shiftParams } from "../engine/ownership.js";
|
|
6
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
@@ -170,7 +171,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
170
171
|
}
|
|
171
172
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
172
173
|
const hasEncryptedFields = encryptedFields.size > 0;
|
|
173
|
-
const piiSubjectFields =
|
|
174
|
+
const piiSubjectFields = collectPiiEncryptedFields(entity);
|
|
174
175
|
const hasPiiFields = piiSubjectFields.length > 0;
|
|
175
176
|
function fieldCipher() {
|
|
176
177
|
if (options.encryption)
|
|
@@ -244,6 +245,11 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
244
245
|
}
|
|
245
246
|
return out;
|
|
246
247
|
}
|
|
248
|
+
function ownerTransitionRekeys(previous, mergedNew, submittedChanges) {
|
|
249
|
+
if (!hasPiiFields || !piiKms())
|
|
250
|
+
return {};
|
|
251
|
+
return personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges);
|
|
252
|
+
}
|
|
247
253
|
function applyDefaults(payload) {
|
|
248
254
|
if (Object.keys(fieldDefaults).length === 0)
|
|
249
255
|
return payload;
|
|
@@ -430,6 +436,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
430
436
|
loadExpectSnapshot,
|
|
431
437
|
encryptForStorage,
|
|
432
438
|
decryptForRead,
|
|
439
|
+
ownerTransitionRekeys,
|
|
433
440
|
applyDefaults,
|
|
434
441
|
stripSensitive,
|
|
435
442
|
};
|
|
@@ -2,9 +2,9 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
|
|
|
2
2
|
import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
3
3
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
4
4
|
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
5
|
-
import { findUnavailableSelectOptions, } from "../engine/screen-helpers.js";
|
|
5
|
+
import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
|
|
6
6
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
7
|
-
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
7
|
+
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
8
8
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
9
9
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
10
10
|
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
@@ -118,8 +118,21 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
export function createWriteVerbs(ctx) {
|
|
121
|
-
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
121
|
+
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
122
122
|
const entityInstructionFieldNames = instructionFieldNames(entity);
|
|
123
|
+
// update and delete funnel through here, so custom handlers and nested writes cannot
|
|
124
|
+
// bypass the lock whatever they return. Setting the lock (unlocked row) passes.
|
|
125
|
+
// Framework system writes that name a writePurpose (retention, erasure) are exempt:
|
|
126
|
+
// statutory deletion must reach locked rows, and forget (Art. 17) never checks the lock
|
|
127
|
+
// at all. The options are server-code only; no request field is ever copied into them.
|
|
128
|
+
function recordLockedFailure(row, id, user, options) {
|
|
129
|
+
if (options?.unsafeSkipLockGuard === true ||
|
|
130
|
+
(options?.writePurpose !== undefined && isFrameworkSystemUser(user)) ||
|
|
131
|
+
!isRecordLocked(entity.lockedWhen, row)) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return writeFailure(new RecordLockedError({ entityName, entityId: id }));
|
|
135
|
+
}
|
|
123
136
|
const eventVersion = entity.eventVersion ?? 1;
|
|
124
137
|
let insertSchema;
|
|
125
138
|
let updateSchema;
|
|
@@ -407,6 +420,9 @@ export function createWriteVerbs(ctx) {
|
|
|
407
420
|
},
|
|
408
421
|
}));
|
|
409
422
|
}
|
|
423
|
+
const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
|
|
424
|
+
if (lockedUpdate)
|
|
425
|
+
return lockedUpdate;
|
|
410
426
|
const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
|
|
411
427
|
if (unavailableOnUpdate)
|
|
412
428
|
return unavailableOnUpdate;
|
|
@@ -449,9 +465,16 @@ export function createWriteVerbs(ctx) {
|
|
|
449
465
|
const submittedChanges = updateOptions?.skipUnchanged
|
|
450
466
|
? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
|
|
451
467
|
: changes;
|
|
452
|
-
|
|
468
|
+
// fw#3670: crypto-shredding follows the current owner, so personal
|
|
469
|
+
// fields not in this update are re-written under the new owner's key.
|
|
470
|
+
// Owner -> absent is not re-keyed; the value stays under the old owner.
|
|
471
|
+
const changesToPersist = {
|
|
472
|
+
...submittedChanges,
|
|
473
|
+
...ownerTransitionRekeys(previous, mergedNew, submittedChanges),
|
|
474
|
+
};
|
|
475
|
+
const flatChangesPlain = flattenCompoundTypes(changesToPersist, entity);
|
|
453
476
|
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
454
|
-
onlyKeys: Object.keys(
|
|
477
|
+
onlyKeys: Object.keys(changesToPersist),
|
|
455
478
|
subjectSource: mergedNew,
|
|
456
479
|
keyTenantId: streamTenantId,
|
|
457
480
|
});
|
|
@@ -583,6 +606,9 @@ export function createWriteVerbs(ctx) {
|
|
|
583
606
|
},
|
|
584
607
|
}));
|
|
585
608
|
}
|
|
609
|
+
const lockedDelete = recordLockedFailure(existing, payload.id, user, options);
|
|
610
|
+
if (lockedDelete)
|
|
611
|
+
return lockedDelete;
|
|
586
612
|
await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
|
|
587
613
|
// Stream-version authoritative (see update() for rationale).
|
|
588
614
|
const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
|
|
@@ -5,7 +5,8 @@ import type { EntityCache } from "../pipeline/entity-cache.js";
|
|
|
5
5
|
import type { SearchAdapter } from "../search/types.js";
|
|
6
6
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
|
|
7
7
|
import { type Table } from "./event-store-executor-context.js";
|
|
8
|
-
export type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
8
|
+
export type { EventStoreExecutor, LockGuardOption, WritePurpose, } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
9
|
+
export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
9
10
|
export type { EntityLifecycleVerb } from "./event-store-executor-context.js";
|
|
10
11
|
export { entityEventName } from "./event-store-executor-context.js";
|
|
11
12
|
export type EventStoreExecutorOptions = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildExecutorContext } from "./event-store-executor-context.js";
|
|
2
2
|
import { createReadVerbs } from "./event-store-executor-read.js";
|
|
3
3
|
import { createWriteVerbs } from "./event-store-executor-write.js";
|
|
4
|
+
export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
4
5
|
export { entityEventName } from "./event-store-executor-context.js";
|
|
5
6
|
export function createEventStoreExecutor(table, entity, options) {
|
|
6
7
|
const ctx = buildExecutorContext(table, entity, options);
|
package/dist/db/index.d.ts
CHANGED
|
@@ -15,11 +15,11 @@ export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureE
|
|
|
15
15
|
export { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
16
16
|
export type { BuildEntityTableMetaOptions, ColumnMeta, CompositePrimaryKeyMeta, EntityTableMeta, IndexMeta, PgType, UnmanagedTableInput, } from "./entity-table-meta.js";
|
|
17
17
|
export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
|
|
18
|
-
export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, } from "./event-store-executor.js";
|
|
19
|
-
export { createEventStoreExecutor, entityEventName } from "./event-store-executor.js";
|
|
18
|
+
export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, LockGuardOption, WritePurpose, } from "./event-store-executor.js";
|
|
19
|
+
export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
|
|
20
20
|
export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.js";
|
|
21
21
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
|
|
22
|
-
export { assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
22
|
+
export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
23
23
|
export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
24
24
|
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
25
25
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
|
package/dist/db/index.js
CHANGED
|
@@ -10,10 +10,10 @@ export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences,
|
|
|
10
10
|
export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.js";
|
|
11
11
|
export { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
12
12
|
export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
|
|
13
|
-
export { createEventStoreExecutor, entityEventName } from "./event-store-executor.js";
|
|
13
|
+
export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
|
|
14
14
|
export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
|
|
15
15
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
|
|
16
|
-
export { assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
16
|
+
export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
17
17
|
export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
18
18
|
export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
19
19
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
|
|
@@ -83,5 +83,6 @@ export type GenerateMigrationOutput = {
|
|
|
83
83
|
};
|
|
84
84
|
export declare const MIGRATION_NAME_RE: RegExp;
|
|
85
85
|
export declare function assertValidMigrationName(name: string): void;
|
|
86
|
+
export declare function assertUniqueTableNames(metas: readonly EntityTableMeta[]): void;
|
|
86
87
|
export declare function generateMigration(input: GenerateMigrationInput): GenerateMigrationOutput;
|
|
87
88
|
export {};
|
|
@@ -428,8 +428,20 @@ export function assertValidMigrationName(name) {
|
|
|
428
428
|
throw new Error(`Invalid migration name "${name}" — must start with a letter or digit, then letters/digits/"-"/"_" only (max 64 chars).`);
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
|
+
export function assertUniqueTableNames(metas) {
|
|
432
|
+
const counts = new Map();
|
|
433
|
+
for (const { tableName } of metas)
|
|
434
|
+
counts.set(tableName, (counts.get(tableName) ?? 0) + 1);
|
|
435
|
+
const duplicates = [...counts].filter(([, count]) => count > 1);
|
|
436
|
+
if (duplicates.length > 0) {
|
|
437
|
+
throw new Error(duplicates
|
|
438
|
+
.map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the hand-added meta for "${tableName}".`)
|
|
439
|
+
.join("\n"));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
431
442
|
export function generateMigration(input) {
|
|
432
443
|
assertValidMigrationName(input.name);
|
|
444
|
+
assertUniqueTableNames(input.metas);
|
|
433
445
|
const nextSnapshot = snapshotFromMetas(input.metas);
|
|
434
446
|
const diff = diffSnapshots(input.prevSnapshot, nextSnapshot);
|
|
435
447
|
const retiredDrops = findRetiredTableDrops(input.priorMigrationsSql ?? [], nextSnapshot);
|
|
@@ -9,6 +9,7 @@ export type PiiBackfillResult = {
|
|
|
9
9
|
readonly updatedEvents: number;
|
|
10
10
|
readonly encryptedFields: number;
|
|
11
11
|
readonly erasedFields: number;
|
|
12
|
+
readonly rekeyedFields: number;
|
|
12
13
|
readonly ownerFromProjection: number;
|
|
13
14
|
readonly erasedUnresolvable: number;
|
|
14
15
|
readonly deletedSnapshots: number;
|
|
@@ -30,6 +31,7 @@ export type PiiBackfillOptions = {
|
|
|
30
31
|
readonly dryRun?: boolean;
|
|
31
32
|
readonly resolveOwnerFromProjection?: boolean;
|
|
32
33
|
readonly eraseUnresolvableSubjects?: boolean;
|
|
34
|
+
readonly rekeyTenantKeyedOwnerFields?: boolean;
|
|
33
35
|
};
|
|
34
36
|
export type PiiBackfillBatchOptions = PiiBackfillOptions & {
|
|
35
37
|
readonly afterEventId?: bigint | string;
|