@cosmicdrift/kumiko-framework 0.221.0 → 0.222.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.
Files changed (48) hide show
  1. package/package.json +7 -3
  2. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  3. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  4. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  5. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  6. package/src/api/auth-routes.ts +3 -0
  7. package/src/api/pii-leak-guard.ts +4 -5
  8. package/src/arg-parser.ts +1 -1
  9. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  10. package/src/db/entity-table-meta.ts +6 -1
  11. package/src/db/table-builder.ts +10 -3
  12. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  13. package/src/derivatives/derivatives-context.ts +4 -0
  14. package/src/derivatives/index.ts +9 -1
  15. package/src/derivatives/variant-key.ts +68 -0
  16. package/src/engine/__tests__/schema-builder.test.ts +4 -4
  17. package/src/engine/extensions/storage-provider.ts +28 -0
  18. package/src/engine/extensions/user-data.ts +4 -0
  19. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  20. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  21. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  22. package/src/engine/feature-ast/extractors/index.ts +2 -0
  23. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  24. package/src/engine/feature-ast/parse.ts +10 -25
  25. package/src/engine/feature-ast/patch.ts +14 -16
  26. package/src/engine/feature-ast/render.ts +3 -3
  27. package/src/engine/field-helpers.ts +1 -1
  28. package/src/engine/index.ts +5 -0
  29. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  30. package/src/engine/schema-builder.ts +14 -2
  31. package/src/errors/__tests__/classes.test.ts +16 -2
  32. package/src/errors/__tests__/write-failures.test.ts +10 -0
  33. package/src/errors/classes.ts +14 -13
  34. package/src/errors/write-error-info.ts +1 -1
  35. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  36. package/src/files/in-memory-provider.ts +4 -0
  37. package/src/files/local-provider.ts +22 -1
  38. package/src/jobs/job-runner.ts +22 -10
  39. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  40. package/src/pipeline/dispatch-shared.ts +39 -2
  41. package/src/pipeline/dispatch-write.ts +48 -0
  42. package/src/pipeline/dispatcher.ts +5 -0
  43. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  44. package/src/schema-cli.ts +30 -44
  45. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  46. package/src/stack/__tests__/request-helper.test.ts +2 -2
  47. package/src/testing/file-provider-contract.ts +19 -0
  48. 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.221.0",
3
+ "version": "0.222.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.221.0",
201
+ "@cosmicdrift/kumiko-types": "0.222.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.221.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.222.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-Tripwire (#820): ein kumiko-pii:-Ciphertext in einer JSON-Response
2
- // ist immer ein Bug (raw-Read am Decrypt vorbei). Dev/Test → 500 (Test wird
3
- // rot), Prod → redact + Error-Log, ohne KMS kein Scan (pass-through).
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 response passes through unscanned", async () => {
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
- expect(await res.text()).toContain(CIPHERTEXT);
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 () => {
@@ -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 { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
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. Skipped entirely
14
- // when no subject KMS is configured (no ciphertexts can exist).
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
- // Copy of bin/commands/arg-parser.ts — registry-free, but upgrade-cli.ts
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: notNullParts.join(" AND "),
368
+ whereSql: whereParts.join(" AND "),
364
369
  });
365
370
  }
366
371
  }
@@ -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 whereText = bidxFieldNames
586
+ const notNullParts = bidxFieldNames
587
587
  .filter((c, i) => c !== def.columns[i])
588
- .map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
589
- .join(" AND ");
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 { specHash, variantSuffix } from "../variant-key";
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,
@@ -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 { canonicalJson, specHash, VARIANT_NAME_PATTERN, variantSuffix } from "./variant-key";
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
+ }
@@ -719,9 +719,9 @@ describe("totalsMatch (fw#1839)", () => {
719
719
  }
720
720
  });
721
721
 
722
- test("update payload omitting the embedded list is not checked (nothing to sum)", () => {
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(true);
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 not checked (nothing to compare against)", () => {
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(true);
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", () => {
@@ -0,0 +1,28 @@
1
+ // Hook signature types for EXT_STORAGE_PROVIDER (tenant-destroy binary cleanup).
2
+ //
3
+ // Mirror of tenant-data.ts, but the destroyTenant hook takes (tenantId, ctx)
4
+ // rather than just (ctx) — runExtensionDestroyHooks (tenant-lifecycle/stages.ts)
5
+ // passes tenantId as its own positional arg for every EXT_*_RESOURCE-style
6
+ // extension point, not just this one. ctx here only guarantees tenantId plus
7
+ // the optional fileProviderResolver/log; the richer stage-runner ctx
8
+ // (tenant-lifecycle's DestructionStageCtx) is a structural superset, so a hook
9
+ // typed against this minimal ctx is safely assignable wherever that richer ctx
10
+ // is passed.
11
+
12
+ import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
13
+ import type { TenantId } from "../types";
14
+
15
+ export interface StorageProviderHookCtx {
16
+ readonly tenantId: TenantId;
17
+ readonly fileProviderResolver?: FileProviderResolver;
18
+ readonly log?: (message: string) => void;
19
+ }
20
+
21
+ export type StorageProviderDestroyTenantHook = (
22
+ tenantId: TenantId,
23
+ ctx: StorageProviderHookCtx,
24
+ ) => Promise<void>;
25
+
26
+ export interface StorageProviderExtensionHooks {
27
+ readonly destroyTenant: StorageProviderDestroyTenantHook;
28
+ }
@@ -66,6 +66,10 @@ export type TenantUserModel = "single-user" | "multi-user";
66
66
  */
67
67
  export interface UserDataStorageProvider {
68
68
  delete(storageKey: string): Promise<void>;
69
+ // Needed so a forget/tenant-destroy hook can find derived/variant keys
70
+ // (thumbnails, resized variants) that are never tracked anywhere but the
71
+ // storage layer itself — see fileRefDeleteHook.
72
+ list(prefix: string): Promise<readonly string[]>;
69
73
  }
70
74
 
71
75
  export interface UserDataHookCtx {
@@ -2588,7 +2588,7 @@ describe("cross-file registrar-wrapper resolution against a real filesystem Proj
2588
2588
 
2589
2589
  test("resolves the imported wrapper and recognises the nav pattern it registers", () => {
2590
2590
  expect(result.errors).toEqual([]);
2591
- expect(result.patterns.map((p) => p.kind)).toEqual(["nav", "requires"]);
2591
+ expect(result.patterns.map((p) => p.kind)).toEqual(["requires", "nav"]);
2592
2592
  });
2593
2593
 
2594
2594
  test("the resolved pattern's source points at the imported file, not the entry file", () => {
@@ -869,9 +869,7 @@ describe("render → parse roundtrip — AI pipeline steps", () => {
869
869
 
870
870
  test("inline + const-ref ai steps round-trip structurally", () => {
871
871
  const aiPatterns = initial.patterns.filter((p) => p.kind.startsWith("ai."));
872
- const stepCalls = aiPatterns
873
- .map((p) => indent(renderPattern(p), " ").replace(/;\s*$/, ","))
874
- .join("\n");
872
+ const stepCalls = aiPatterns.map((p) => `${indent(renderPattern(p), " ")},`).join("\n");
875
873
  const wrapped = `
876
874
  import { defineFeature, defineWorkflow, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
877
875
  import { z } from "zod";