@cosmicdrift/kumiko-framework 0.221.0 → 0.223.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/package.json +7 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
- package/src/api/auth-routes.ts +3 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/arg-parser.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/derivatives-context.ts +4 -0
- package/src/derivatives/index.ts +9 -1
- package/src/derivatives/variant-key.ts +68 -0
- package/src/engine/__tests__/boot-validator.test.ts +113 -0
- package/src/engine/__tests__/schema-builder.test.ts +4 -4
- package/src/engine/boot-validator/entity-handler.ts +34 -1
- package/src/engine/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/factories.ts +13 -6
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
- package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
- package/src/engine/feature-ast/extractors/index.ts +2 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -1
- package/src/engine/feature-ast/parse.ts +10 -25
- package/src/engine/feature-ast/patch.ts +14 -16
- package/src/engine/feature-ast/render.ts +3 -3
- package/src/engine/field-helpers.ts +1 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/mixed-schemas.ts +6 -0
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +16 -2
- package/src/errors/__tests__/write-failures.test.ts +10 -0
- package/src/errors/classes.ts +14 -13
- package/src/errors/write-error-info.ts +1 -1
- package/src/files/__tests__/local-provider.contract.test.ts +14 -0
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- package/src/jobs/job-runner.ts +22 -10
- package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
- package/src/pipeline/dispatch-shared.ts +39 -2
- package/src/pipeline/dispatch-write.ts +48 -0
- package/src/pipeline/dispatcher.ts +5 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +30 -44
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- package/src/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/upgrade-cli.ts +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.223.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -163,6 +163,10 @@
|
|
|
163
163
|
"types": "./src/upgrade-cli.ts",
|
|
164
164
|
"default": "./src/upgrade-cli.ts"
|
|
165
165
|
},
|
|
166
|
+
"./arg-parser": {
|
|
167
|
+
"types": "./src/arg-parser.ts",
|
|
168
|
+
"default": "./src/arg-parser.ts"
|
|
169
|
+
},
|
|
166
170
|
"./stack": {
|
|
167
171
|
"types": "./src/stack/index.ts",
|
|
168
172
|
"default": "./src/stack/index.ts"
|
|
@@ -194,7 +198,7 @@
|
|
|
194
198
|
"./package.json": "./package.json"
|
|
195
199
|
},
|
|
196
200
|
"dependencies": {
|
|
197
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.223.0",
|
|
198
202
|
"bullmq": "^5.76.7",
|
|
199
203
|
"bun-types": "^1.3.13",
|
|
200
204
|
"hono": "^4.13.1",
|
|
@@ -210,7 +214,7 @@
|
|
|
210
214
|
"zod": "^4.4.3"
|
|
211
215
|
},
|
|
212
216
|
"devDependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.223.0",
|
|
214
218
|
"bun-types": "^1.3.13",
|
|
215
219
|
"pino-pretty": "^13.1.3"
|
|
216
220
|
},
|
|
@@ -162,9 +162,7 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
162
162
|
async write(): Promise<WriteResult> {
|
|
163
163
|
return {
|
|
164
164
|
isSuccess: false,
|
|
165
|
-
error: new UnprocessableError("invalid_totp_code",
|
|
166
|
-
details: { reason: "invalid_totp_code" },
|
|
167
|
-
}),
|
|
165
|
+
error: new UnprocessableError("invalid_totp_code"),
|
|
168
166
|
};
|
|
169
167
|
},
|
|
170
168
|
});
|
|
@@ -157,9 +157,7 @@ describe("POST /auth/mfa/preauth-enable-start", () => {
|
|
|
157
157
|
async write(): Promise<WriteResult> {
|
|
158
158
|
return {
|
|
159
159
|
isSuccess: false,
|
|
160
|
-
error: new UnprocessableError("invalid_challenge_token",
|
|
161
|
-
details: { reason: "invalid_challenge_token" },
|
|
162
|
-
}),
|
|
160
|
+
error: new UnprocessableError("invalid_challenge_token"),
|
|
163
161
|
};
|
|
164
162
|
},
|
|
165
163
|
});
|
|
@@ -139,9 +139,7 @@ describe("POST /auth/mfa/verify", () => {
|
|
|
139
139
|
async write(): Promise<WriteResult> {
|
|
140
140
|
return {
|
|
141
141
|
isSuccess: false,
|
|
142
|
-
error: new UnprocessableError("invalid_totp_code",
|
|
143
|
-
details: { reason: "invalid_totp_code" },
|
|
144
|
-
}),
|
|
142
|
+
error: new UnprocessableError("invalid_totp_code"),
|
|
145
143
|
};
|
|
146
144
|
},
|
|
147
145
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Response
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Response tripwire (#820): a kumiko-pii: ciphertext in a JSON response is
|
|
2
|
+
// always a bug (raw read past the decrypt). Dev/test → 500 (test goes red),
|
|
3
|
+
// prod → redact + error log. The scan runs regardless of whether a subject
|
|
4
|
+
// KMS is configured — a leaked ciphertext can outlive the KMS config (#2467).
|
|
4
5
|
|
|
5
6
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
6
7
|
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -59,10 +60,21 @@ async function callLeakyQuery(): Promise<Response> {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
describe("piiCiphertextResponseGuard", () => {
|
|
62
|
-
test("no KMS configured
|
|
63
|
+
test("no KMS configured, dev: leaking response is still caught as a loud 500", async () => {
|
|
64
|
+
const res = await callLeakyQuery();
|
|
65
|
+
expect(res.status).toBe(500);
|
|
66
|
+
const body = (await res.json()) as { error?: { code?: string; message?: string } };
|
|
67
|
+
expect(body.error?.code).toBe("pii_ciphertext_leak");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("no KMS configured, production: leak is still redacted, request succeeds", async () => {
|
|
71
|
+
process.env["NODE_ENV"] = "production";
|
|
63
72
|
const res = await callLeakyQuery();
|
|
64
73
|
expect(res.status).toBe(200);
|
|
65
|
-
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
expect(text).not.toContain("kumiko-pii:");
|
|
76
|
+
expect(text).toContain("[pii-redacted]");
|
|
77
|
+
expect(text).toContain("plain");
|
|
66
78
|
});
|
|
67
79
|
|
|
68
80
|
test("KMS active, dev: leaking response becomes a loud 500", async () => {
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -1391,6 +1391,9 @@ export function createAuthRoutes(
|
|
|
1391
1391
|
id: user.id,
|
|
1392
1392
|
tenantId: targetTenantId,
|
|
1393
1393
|
roles: mergedRoles,
|
|
1394
|
+
// Tenant-independent prefs must survive the switch (fw#2343).
|
|
1395
|
+
...(user.timezone ? { timezone: user.timezone } : {}),
|
|
1396
|
+
...(user.locale ? { locale: user.locale } : {}),
|
|
1394
1397
|
};
|
|
1395
1398
|
const claims = await dispatcher.resolveAuthClaims(targetSession);
|
|
1396
1399
|
const sessionForJwt: SessionUser =
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import { PII_CIPHERTEXT_PREFIX } from "../crypto";
|
|
3
3
|
|
|
4
4
|
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
5
5
|
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
@@ -10,13 +10,12 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
|
10
10
|
// A PII subject ciphertext never belongs in an API response — its presence
|
|
11
11
|
// means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
|
|
12
12
|
// fail loud (500) so a forgotten decrypt turns the first integration test
|
|
13
|
-
// red; prod redacts + logs instead of shipping the blob.
|
|
14
|
-
//
|
|
13
|
+
// red; prod redacts + logs instead of shipping the blob. Scans unconditionally:
|
|
14
|
+
// legacy ciphertext rows can outlive a subject KMS that later became
|
|
15
|
+
// unconfigured, and the marker check itself needs no KMS access to run.
|
|
15
16
|
export function piiCiphertextResponseGuard(): MiddlewareHandler {
|
|
16
17
|
return async (c, next) => {
|
|
17
18
|
await next();
|
|
18
|
-
// skip: no subject KMS configured — no ciphertexts can exist, nothing to scan
|
|
19
|
-
if (configuredPiiSubjectKms() === undefined) return;
|
|
20
19
|
const contentType = c.res.headers.get("content-type") ?? "";
|
|
21
20
|
// skip: only JSON bodies carry handler data — streams/zips stay untouched
|
|
22
21
|
if (!contentType.includes("application/json")) return;
|
package/src/arg-parser.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// - Flags: --flag (boolean) / --key value
|
|
5
5
|
// - Negatable: --no-flag
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Shared arg parser (also re-exported from bin/commands/arg-parser.ts). upgrade-cli.ts
|
|
8
8
|
// lives in the published @cosmicdrift/kumiko-framework package and can't
|
|
9
9
|
// import from bin/commands/ (outside the package's export surface).
|
|
10
10
|
|
|
@@ -160,3 +160,32 @@ describe("lock-step — lookupable / blind-index (#818)", () => {
|
|
|
160
160
|
expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL');
|
|
161
161
|
});
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
// Fourth probe: softDelete + lookupable (#2464) — the partial bidx unique
|
|
165
|
+
// must exclude soft-deleted rows from the uniqueness scope, otherwise a
|
|
166
|
+
// soft-deleted row blocks the same value for a new/restored row.
|
|
167
|
+
const entityWithSoftDeleteAndLookupable = createEntity({
|
|
168
|
+
table: "read_lockstep_probe_bidx_sd",
|
|
169
|
+
fields: {
|
|
170
|
+
email: { type: "text", required: true, pii: true, lookupable: true },
|
|
171
|
+
tenantSlug: { type: "text", required: true },
|
|
172
|
+
},
|
|
173
|
+
softDelete: true,
|
|
174
|
+
indexes: [{ columns: ["tenantSlug", "email"], unique: true }],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("lock-step — softDelete + lookupable/blind-index (#2464)", () => {
|
|
178
|
+
const fromBuilder = asEntityTableMeta(
|
|
179
|
+
buildEntityTable("lockstepProbeBidxSd", entityWithSoftDeleteAndLookupable),
|
|
180
|
+
);
|
|
181
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbeBidxSd", entityWithSoftDeleteAndLookupable);
|
|
182
|
+
|
|
183
|
+
test("identical indexes incl. soft-delete-aware bidx partial where", () => {
|
|
184
|
+
expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
|
|
185
|
+
byName<IndexMeta>(fromMeta.indexes),
|
|
186
|
+
);
|
|
187
|
+
const partial = fromMeta.indexes.find((i) => i.name.endsWith("_tenant_slug_email_unique_bidx"));
|
|
188
|
+
expect(partial).toBeDefined();
|
|
189
|
+
expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL AND "is_deleted" = false');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -356,11 +356,16 @@ export function deriveEntityTableMeta(
|
|
|
356
356
|
const notNullParts = bidxCols
|
|
357
357
|
.filter((c, i) => c !== cols[i])
|
|
358
358
|
.map((c) => `"${c}" IS NOT NULL`);
|
|
359
|
+
// Soft-deleted rows keep their bidx hash — without this, a restored
|
|
360
|
+
// row can't reuse a value a soft-deleted sibling still holds
|
|
361
|
+
// (framework#2464). Kept in lock-step with table-builder.ts.
|
|
362
|
+
const whereParts =
|
|
363
|
+
entity.softDelete === true ? [...notNullParts, `"is_deleted" = false`] : notNullParts;
|
|
359
364
|
indexes.push({
|
|
360
365
|
name: `${indexName}_bidx`,
|
|
361
366
|
columns: bidxCols,
|
|
362
367
|
unique: true,
|
|
363
|
-
whereSql:
|
|
368
|
+
whereSql: whereParts.join(" AND "),
|
|
364
369
|
});
|
|
365
370
|
}
|
|
366
371
|
}
|
package/src/db/table-builder.ts
CHANGED
|
@@ -583,10 +583,17 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
583
583
|
.map((c) => tHandle[c])
|
|
584
584
|
.filter((col): col is ColumnHandle => col !== undefined);
|
|
585
585
|
if (bidxCols.length === bidxFieldNames.length) {
|
|
586
|
-
const
|
|
586
|
+
const notNullParts = bidxFieldNames
|
|
587
587
|
.filter((c, i) => c !== def.columns[i])
|
|
588
|
-
.map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
|
|
589
|
-
|
|
588
|
+
.map((c) => `"${toSnakeCase(c)}" IS NOT NULL`);
|
|
589
|
+
// Soft-deleted rows keep their bidx hash — without this, a
|
|
590
|
+
// restored row can't reuse a value a soft-deleted sibling still
|
|
591
|
+
// holds (framework#2464).
|
|
592
|
+
const whereText = (
|
|
593
|
+
entity.softDelete === true
|
|
594
|
+
? [...notNullParts, `"is_deleted" = false`]
|
|
595
|
+
: notNullParts
|
|
596
|
+
).join(" AND ");
|
|
590
597
|
const partialWhere: SqlExpression = {
|
|
591
598
|
kind: "sql-expr",
|
|
592
599
|
text: whereText,
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { deriveKey } from "../../files/file-handle";
|
|
3
|
+
import { buildStorageKey } from "../../files/types";
|
|
4
|
+
import {
|
|
5
|
+
derivativeListPrefix,
|
|
6
|
+
isDerivativeKeyOf,
|
|
7
|
+
parseDerivativeKey,
|
|
8
|
+
specHash,
|
|
9
|
+
variantSuffix,
|
|
10
|
+
} from "../variant-key";
|
|
3
11
|
|
|
4
12
|
describe("specHash — key stability", () => {
|
|
5
13
|
test("key order doesn't matter", () => {
|
|
@@ -59,3 +67,117 @@ describe("variantSuffix", () => {
|
|
|
59
67
|
expect(() => variantSuffix("Hero", {})).not.toThrow();
|
|
60
68
|
});
|
|
61
69
|
});
|
|
70
|
+
|
|
71
|
+
// These pin the grammar `isDerivativeKeyOf`/`derivativeListPrefix` use to
|
|
72
|
+
// recognize forget/tenant-destroy erasure targets — real deriveKey() output,
|
|
73
|
+
// not a hand-rolled key shape, so a drift in deriveKey's own splitting logic
|
|
74
|
+
// would show up here too.
|
|
75
|
+
describe("derivativeListPrefix + isDerivativeKeyOf — real deriveKey() output", () => {
|
|
76
|
+
const original = "tenant/photo.jpg";
|
|
77
|
+
const suffix = variantSuffix("thumb", { maxEdge: 512 });
|
|
78
|
+
const derived = deriveKey(original, suffix);
|
|
79
|
+
|
|
80
|
+
test("deriveKey's own output matches isDerivativeKeyOf for its original", () => {
|
|
81
|
+
expect(isDerivativeKeyOf(original, derived)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("derivativeListPrefix is a prefix of every derivative deriveKey() produces", () => {
|
|
85
|
+
expect(derived.startsWith(derivativeListPrefix(original))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the original key is not its own derivative", () => {
|
|
89
|
+
expect(isDerivativeKeyOf(original, original)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a same-directory sibling original with a DIFFERENT extension is not a derivative", () => {
|
|
93
|
+
// Same base ("tenant/photo"), so it shares derivativeListPrefix — the
|
|
94
|
+
// extension anchor is what must reject it, or a forget/tenant-destroy
|
|
95
|
+
// prefix-delete would sweep up a different file (possibly another
|
|
96
|
+
// user's) alongside the intended derivatives.
|
|
97
|
+
expect(isDerivativeKeyOf(original, "tenant/photo.png")).toBe(false);
|
|
98
|
+
expect(isDerivativeKeyOf(original, "tenant/photo.other-1234567890abcdef.png")).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("an unrelated key under the same directory is not a derivative", () => {
|
|
102
|
+
expect(isDerivativeKeyOf(original, "tenant/other-file.jpg")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a key with the right prefix/ext but no valid <name>-<hash> middle is not a derivative", () => {
|
|
106
|
+
expect(isDerivativeKeyOf(original, "tenant/photo.old.jpg")).toBe(false);
|
|
107
|
+
expect(isDerivativeKeyOf(original, "tenant/photo.jpg")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("extension-less original: derivatives have no trailing extension either", () => {
|
|
111
|
+
const noExtOriginal = "tenant/document";
|
|
112
|
+
const noExtSuffix = variantSuffix("preview", { maxEdge: 256 });
|
|
113
|
+
const noExtDerived = deriveKey(noExtOriginal, noExtSuffix);
|
|
114
|
+
expect(isDerivativeKeyOf(noExtOriginal, noExtDerived)).toBe(true);
|
|
115
|
+
expect(derivativeListPrefix(noExtOriginal)).toBe("tenant/document.");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("multiple variants of the same original all match", () => {
|
|
119
|
+
const cardSuffix = variantSuffix("card", { maxEdge: 1024 });
|
|
120
|
+
const cardDerived = deriveKey(original, cardSuffix);
|
|
121
|
+
expect(isDerivativeKeyOf(original, cardDerived)).toBe(true);
|
|
122
|
+
expect(isDerivativeKeyOf(original, derived)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// parseDerivativeKey powers the orphaned-derivative backfill/GC sweep
|
|
127
|
+
// (#2474), which has no audit trail of past forgets to work from — it can
|
|
128
|
+
// only trust the deterministic key grammar itself. Real buildStorageKey() +
|
|
129
|
+
// deriveKey() output, not a hand-rolled key shape.
|
|
130
|
+
describe("parseDerivativeKey — reverse of deriveKey", () => {
|
|
131
|
+
const original = buildStorageKey(
|
|
132
|
+
"tenant-1" as never,
|
|
133
|
+
"fileRef",
|
|
134
|
+
"42",
|
|
135
|
+
"attachment",
|
|
136
|
+
"photo.jpg",
|
|
137
|
+
"uid123",
|
|
138
|
+
);
|
|
139
|
+
const suffix = variantSuffix("thumb", { maxEdge: 512 });
|
|
140
|
+
const derived = deriveKey(original, suffix);
|
|
141
|
+
|
|
142
|
+
test("reconstructs the exact original a real derivative was built from", () => {
|
|
143
|
+
expect(parseDerivativeKey(derived)).toEqual({ originalKey: original });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a plain original (no derivative suffix) is not derivative-shaped", () => {
|
|
147
|
+
expect(parseDerivativeKey(original)).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a key with no dot at all is not derivative-shaped", () => {
|
|
151
|
+
expect(parseDerivativeKey("tenant-1/fileRef/42/attachment/uid123")).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a multi-dot ORIGINAL filename still round-trips", () => {
|
|
155
|
+
const multiDotOriginal = buildStorageKey(
|
|
156
|
+
"tenant-1" as never,
|
|
157
|
+
"fileRef",
|
|
158
|
+
"42",
|
|
159
|
+
"attachment",
|
|
160
|
+
"my.file.name.jpg",
|
|
161
|
+
"uid456",
|
|
162
|
+
);
|
|
163
|
+
const multiDotDerived = deriveKey(multiDotOriginal, suffix);
|
|
164
|
+
expect(parseDerivativeKey(multiDotDerived)).toEqual({ originalKey: multiDotOriginal });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Blind spot the reverse-parse must guard against on its own (no DB
|
|
168
|
+
// cross-check happens inside parseDerivativeKey itself): a lookalike key
|
|
169
|
+
// that was never written by buildStorageKey — e.g. a GDPR export bundle —
|
|
170
|
+
// can still have a last segment shaped like "<name>-<16hex>.<ext>" if its
|
|
171
|
+
// filename happens to contain a hyphen+16-hex-char run. Anchoring the
|
|
172
|
+
// reconstructed original to buildStorageKey's own 5-segment/single-dot
|
|
173
|
+
// shape rejects it even though the suffix pattern alone would match.
|
|
174
|
+
test("a non-buildStorageKey lookalike with a derivative-shaped suffix is rejected", () => {
|
|
175
|
+
const lookalike = "tenant-1/gdpr-export-bundle.snapshot-0123456789abcdef.zip";
|
|
176
|
+
expect(parseDerivativeKey(lookalike)).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a suffix that doesn't match <name>-<16hex> is rejected", () => {
|
|
180
|
+
const notAHash = deriveKey(original, "thumb-notahexvalue");
|
|
181
|
+
expect(parseDerivativeKey(notAHash)).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -155,6 +155,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
|
|
|
155
155
|
assertSafeStorageKey(target.key);
|
|
156
156
|
const mimeType = outputMimeType(spec, row.mimeType);
|
|
157
157
|
|
|
158
|
+
// ponytail: exists→render→write is a TOCTOU window under concurrent
|
|
159
|
+
// requests for the same variant (duplicate render + write). Ceiling:
|
|
160
|
+
// non-atomic providers can briefly expose a half-written object as a
|
|
161
|
+
// cache hit. Upgrade: write to a temp key then atomic rename/copy.
|
|
158
162
|
if (await target.exists()) {
|
|
159
163
|
return {
|
|
160
164
|
storageKey: target.key,
|
package/src/derivatives/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export type { DerivativesContextDeps } from "./derivatives-context";
|
|
2
2
|
export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
|
|
3
3
|
export { resolveFieldVariant } from "./field-variants";
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
canonicalJson,
|
|
6
|
+
derivativeListPrefix,
|
|
7
|
+
isDerivativeKeyOf,
|
|
8
|
+
parseDerivativeKey,
|
|
9
|
+
specHash,
|
|
10
|
+
VARIANT_NAME_PATTERN,
|
|
11
|
+
variantSuffix,
|
|
12
|
+
} from "./variant-key";
|
|
@@ -38,3 +38,71 @@ export function variantSuffix(name: string, spec: VariantSpec): string {
|
|
|
38
38
|
}
|
|
39
39
|
return `${name}-${specHash(spec)}`;
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// A full derivative-suffix segment is `<name>-<16 hex chars>` — mirrors
|
|
43
|
+
// VARIANT_NAME_PATTERN (name grammar) + specHash's fixed 16-char slice.
|
|
44
|
+
// Keep in sync with both if either changes.
|
|
45
|
+
const DERIVATIVE_SUFFIX_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}-[0-9a-f]{16}$/i;
|
|
46
|
+
|
|
47
|
+
// Mirrors deriveKey's own split so callers get the exact same base/ext this
|
|
48
|
+
// key's derivatives were built from.
|
|
49
|
+
function splitKey(key: string): { readonly base: string; readonly ext: string } {
|
|
50
|
+
const lastSlash = key.lastIndexOf("/");
|
|
51
|
+
const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
|
|
52
|
+
const lastDot = lastSegment.lastIndexOf(".");
|
|
53
|
+
if (lastDot === -1) return { base: key, ext: "" };
|
|
54
|
+
const base = key.slice(0, key.length - lastSegment.length + lastDot);
|
|
55
|
+
return { base, ext: lastSegment.slice(lastDot) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// List-prefix that covers every derivative deriveKey() can produce for
|
|
59
|
+
// `originalKey` — pass to FileStorageProvider.list() to enumerate candidates,
|
|
60
|
+
// then filter with isDerivativeKeyOf before deleting any of them.
|
|
61
|
+
export function derivativeListPrefix(originalKey: string): string {
|
|
62
|
+
return `${splitKey(originalKey).base}.`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// True when `candidateKey` is a derivative deriveKey() could have produced
|
|
66
|
+
// for `originalKey` — anchored to originalKey's own basename AND extension.
|
|
67
|
+
// Without the extension anchor, a same-directory sibling original with a
|
|
68
|
+
// different extension (a different file, possibly another user's) would
|
|
69
|
+
// match on prefix alone and get swept into a forget/tenant-destroy delete.
|
|
70
|
+
export function isDerivativeKeyOf(originalKey: string, candidateKey: string): boolean {
|
|
71
|
+
const { base, ext } = splitKey(originalKey);
|
|
72
|
+
const prefix = `${base}.`;
|
|
73
|
+
if (!candidateKey.startsWith(prefix) || !candidateKey.endsWith(ext)) return false;
|
|
74
|
+
const middle = candidateKey.slice(prefix.length, candidateKey.length - ext.length);
|
|
75
|
+
return DERIVATIVE_SUFFIX_PATTERN.test(middle);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// buildStorageKey (files/types.ts) always produces exactly 5 "/"-segments
|
|
79
|
+
// ending in "<uniqueId>.<ext>" — a single dot, non-empty on both sides.
|
|
80
|
+
// Anchoring parseDerivativeKey's reconstructed candidate to this shape
|
|
81
|
+
// rejects lookalikes that were never written by buildStorageKey (a GDPR
|
|
82
|
+
// export bundle, a local-provider `*.tmp` write-in-progress file) even when
|
|
83
|
+
// their last segment happens to contain a "name-16hex"-shaped middle token.
|
|
84
|
+
const BUILT_STORAGE_KEY_LAST_SEGMENT_PATTERN = /^[^./]+\.[^./]+$/;
|
|
85
|
+
|
|
86
|
+
function looksLikeBuiltStorageKey(key: string): boolean {
|
|
87
|
+
const segments = key.split("/");
|
|
88
|
+
if (segments.length !== 5) return false;
|
|
89
|
+
return BUILT_STORAGE_KEY_LAST_SEGMENT_PATTERN.test(segments[4] ?? "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Reverse of deriveKey: given a key found on a full storage listing, guesses
|
|
93
|
+
// the original key it would be a derivative of. Used by the orphaned-
|
|
94
|
+
// derivative backfill/GC sweep, which has no audit trail of past forgets to
|
|
95
|
+
// work from — only the deterministic key grammar itself. Returns null for
|
|
96
|
+
// anything that isn't derivative-shaped (including a plain original, which
|
|
97
|
+
// splits with no second dot) or whose reconstructed original doesn't match
|
|
98
|
+
// buildStorageKey's own shape.
|
|
99
|
+
export function parseDerivativeKey(candidateKey: string): { readonly originalKey: string } | null {
|
|
100
|
+
const { base: withSuffix, ext } = splitKey(candidateKey);
|
|
101
|
+
const { base: outerBase, ext: suffixExt } = splitKey(withSuffix);
|
|
102
|
+
if (suffixExt === "") return null;
|
|
103
|
+
const suffixCandidate = suffixExt.slice(1);
|
|
104
|
+
if (!DERIVATIVE_SUFFIX_PATTERN.test(suffixCandidate)) return null;
|
|
105
|
+
const originalKey = `${outerBase}${ext}`;
|
|
106
|
+
if (!looksLikeBuiltStorageKey(originalKey)) return null;
|
|
107
|
+
return { originalKey };
|
|
108
|
+
}
|
|
@@ -1355,6 +1355,119 @@ describe("boot-validator", () => {
|
|
|
1355
1355
|
];
|
|
1356
1356
|
expect(() => validateBoot(features)).not.toThrow();
|
|
1357
1357
|
});
|
|
1358
|
+
|
|
1359
|
+
test("accepts display: checkboxes with columns and maxRows", () => {
|
|
1360
|
+
const features = [
|
|
1361
|
+
defineFeature("driver", (r) => {
|
|
1362
|
+
r.entity(
|
|
1363
|
+
"profile",
|
|
1364
|
+
createEntity({
|
|
1365
|
+
fields: {
|
|
1366
|
+
tags: createMultiSelectField({
|
|
1367
|
+
options: ["a", "b", "c"] as const,
|
|
1368
|
+
display: "checkboxes",
|
|
1369
|
+
columns: 2,
|
|
1370
|
+
maxRows: 3,
|
|
1371
|
+
}),
|
|
1372
|
+
},
|
|
1373
|
+
}),
|
|
1374
|
+
);
|
|
1375
|
+
}),
|
|
1376
|
+
];
|
|
1377
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
test("rejects columns without display: checkboxes", () => {
|
|
1381
|
+
const features = [
|
|
1382
|
+
defineFeature("driver", (r) => {
|
|
1383
|
+
r.entity(
|
|
1384
|
+
"profile",
|
|
1385
|
+
createEntity({
|
|
1386
|
+
fields: {
|
|
1387
|
+
tags: createMultiSelectField({ options: ["a", "b"] as const, columns: 2 }),
|
|
1388
|
+
},
|
|
1389
|
+
}),
|
|
1390
|
+
);
|
|
1391
|
+
}),
|
|
1392
|
+
];
|
|
1393
|
+
expect(() => validateBoot(features)).toThrow(/columns.*display.*checkboxes/);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test("rejects columns when display is explicitly dropdown", () => {
|
|
1397
|
+
const features = [
|
|
1398
|
+
defineFeature("driver", (r) => {
|
|
1399
|
+
r.entity(
|
|
1400
|
+
"profile",
|
|
1401
|
+
createEntity({
|
|
1402
|
+
fields: {
|
|
1403
|
+
tags: createMultiSelectField({
|
|
1404
|
+
options: ["a", "b"] as const,
|
|
1405
|
+
display: "dropdown",
|
|
1406
|
+
columns: 2,
|
|
1407
|
+
}),
|
|
1408
|
+
},
|
|
1409
|
+
}),
|
|
1410
|
+
);
|
|
1411
|
+
}),
|
|
1412
|
+
];
|
|
1413
|
+
expect(() => validateBoot(features)).toThrow(/columns.*display.*checkboxes/);
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
test("rejects maxRows without display: checkboxes", () => {
|
|
1417
|
+
const features = [
|
|
1418
|
+
defineFeature("driver", (r) => {
|
|
1419
|
+
r.entity(
|
|
1420
|
+
"profile",
|
|
1421
|
+
createEntity({
|
|
1422
|
+
fields: {
|
|
1423
|
+
tags: createMultiSelectField({ options: ["a", "b"] as const, maxRows: 3 }),
|
|
1424
|
+
},
|
|
1425
|
+
}),
|
|
1426
|
+
);
|
|
1427
|
+
}),
|
|
1428
|
+
];
|
|
1429
|
+
expect(() => validateBoot(features)).toThrow(/maxRows.*display.*checkboxes/);
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
test("rejects maxRows below 1", () => {
|
|
1433
|
+
const features = [
|
|
1434
|
+
defineFeature("driver", (r) => {
|
|
1435
|
+
r.entity(
|
|
1436
|
+
"profile",
|
|
1437
|
+
createEntity({
|
|
1438
|
+
fields: {
|
|
1439
|
+
tags: createMultiSelectField({
|
|
1440
|
+
options: ["a", "b"] as const,
|
|
1441
|
+
display: "checkboxes",
|
|
1442
|
+
maxRows: 0,
|
|
1443
|
+
}),
|
|
1444
|
+
},
|
|
1445
|
+
}),
|
|
1446
|
+
);
|
|
1447
|
+
}),
|
|
1448
|
+
];
|
|
1449
|
+
expect(() => validateBoot(features)).toThrow(/invalid maxRows/);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
test("rejects non-integer maxRows", () => {
|
|
1453
|
+
const features = [
|
|
1454
|
+
defineFeature("driver", (r) => {
|
|
1455
|
+
r.entity(
|
|
1456
|
+
"profile",
|
|
1457
|
+
createEntity({
|
|
1458
|
+
fields: {
|
|
1459
|
+
tags: createMultiSelectField({
|
|
1460
|
+
options: ["a", "b"] as const,
|
|
1461
|
+
display: "checkboxes",
|
|
1462
|
+
maxRows: 1.5,
|
|
1463
|
+
}),
|
|
1464
|
+
},
|
|
1465
|
+
}),
|
|
1466
|
+
);
|
|
1467
|
+
}),
|
|
1468
|
+
];
|
|
1469
|
+
expect(() => validateBoot(features)).toThrow(/invalid maxRows/);
|
|
1470
|
+
});
|
|
1358
1471
|
});
|
|
1359
1472
|
|
|
1360
1473
|
describe("image variants", () => {
|
|
@@ -719,9 +719,9 @@ describe("totalsMatch (fw#1839)", () => {
|
|
|
719
719
|
}
|
|
720
720
|
});
|
|
721
721
|
|
|
722
|
-
test("update payload omitting the embedded list is
|
|
722
|
+
test("update payload omitting the embedded list is rejected (totalsMatch pair incomplete)", () => {
|
|
723
723
|
const schema = buildUpdateSchema(invoiceEntity());
|
|
724
|
-
expect(schema.safeParse({ total: { amount: 30, currency: "EUR" } }).success).toBe(
|
|
724
|
+
expect(schema.safeParse({ total: { amount: 30, currency: "EUR" } }).success).toBe(false);
|
|
725
725
|
});
|
|
726
726
|
|
|
727
727
|
// kumiko-framework#1972: a round total (30 EUR = 3000 minor) still passes
|
|
@@ -753,9 +753,9 @@ describe("totalsMatch (fw#1839)", () => {
|
|
|
753
753
|
}
|
|
754
754
|
});
|
|
755
755
|
|
|
756
|
-
test("update payload omitting the sibling total is
|
|
756
|
+
test("update payload omitting the sibling total is rejected (totalsMatch pair incomplete)", () => {
|
|
757
757
|
const schema = buildUpdateSchema(invoiceEntity());
|
|
758
|
-
expect(schema.safeParse({ lines: [{ amount: 1000 }, { amount: 1500 }] }).success).toBe(
|
|
758
|
+
expect(schema.safeParse({ lines: [{ amount: 1000 }, { amount: 1500 }] }).success).toBe(false);
|
|
759
759
|
});
|
|
760
760
|
|
|
761
761
|
test("rejects a sibling total tagged with a currency other than the entity's default, even when the raw minor-unit amounts match", () => {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { BlurRegion, VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
2
|
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
|
|
3
3
|
import { parseRefTarget } from "../parse-ref-target";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
EmbeddedFieldDef,
|
|
6
|
+
EntityDefinition,
|
|
7
|
+
FeatureDefinition,
|
|
8
|
+
MultiSelectFieldDef,
|
|
9
|
+
} from "../types";
|
|
5
10
|
|
|
6
11
|
export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
|
|
7
12
|
|
|
@@ -676,6 +681,32 @@ function validateEmbeddedListMetadata(
|
|
|
676
681
|
// default — wenn gesetzt — ist eine Teilmenge der options. Beides würde
|
|
677
682
|
// auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur
|
|
678
683
|
// die früheste Stelle für klare Fehlermeldungen.
|
|
684
|
+
function validateCheckboxDisplayOptions(
|
|
685
|
+
field: MultiSelectFieldDef,
|
|
686
|
+
fieldName: string,
|
|
687
|
+
entityName: string,
|
|
688
|
+
): void {
|
|
689
|
+
if (field.display === "checkboxes") {
|
|
690
|
+
if (field.maxRows !== undefined && (!Number.isInteger(field.maxRows) || field.maxRows < 1)) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
`MultiSelect field "${fieldName}" on entity "${entityName}" has invalid maxRows "${field.maxRows}" — must be a positive integer.`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
// skip: checkboxes is the valid display for these options — the checks below only apply to other displays
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (field.columns !== undefined) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
`MultiSelect field "${fieldName}" on entity "${entityName}" sets columns, which is only valid with display: "checkboxes".`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (field.maxRows !== undefined) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`MultiSelect field "${fieldName}" on entity "${entityName}" sets maxRows, which is only valid with display: "checkboxes".`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
679
710
|
export function validateMultiSelectFields(feature: FeatureDefinition): void {
|
|
680
711
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
681
712
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -697,6 +728,8 @@ export function validateMultiSelectFields(feature: FeatureDefinition): void {
|
|
|
697
728
|
}
|
|
698
729
|
}
|
|
699
730
|
}
|
|
731
|
+
|
|
732
|
+
validateCheckboxDisplayOptions(field, fieldName, entityName);
|
|
700
733
|
}
|
|
701
734
|
}
|
|
702
735
|
}
|