@cosmicdrift/kumiko-framework 0.220.1 → 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.
- package/package.json +7 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- 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/api-constants.ts +10 -0
- package/src/api/auth-routes.ts +3 -0
- package/src/api/index.ts +1 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/api/server.ts +17 -1
- package/src/arg-parser.ts +1 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- 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-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +7 -7
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- package/src/engine/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- 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 +23 -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/role-assignment.ts +36 -18
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +21 -2
- package/src/errors/__tests__/write-failures.test.ts +13 -3
- package/src/errors/classes.ts +14 -13
- package/src/errors/kumiko-error.ts +11 -11
- package/src/errors/write-error-info.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/local-provider.contract.test.ts +14 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/jobs/job-runner.ts +22 -10
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
- 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/distributed-lock.ts +3 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +39 -48
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +112 -15
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnyDb } from "../query";
|
|
2
|
-
import {
|
|
2
|
+
import { unsafeReadRetrying } from "../query";
|
|
3
3
|
|
|
4
4
|
export type SeedUserRow = {
|
|
5
5
|
readonly id: string;
|
|
@@ -21,7 +21,8 @@ export type SeedTenantDbRow = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export async function selectUserByEmail(db: AnyDb, email: string): Promise<SeedUserRow | null> {
|
|
24
|
-
const rows = (await
|
|
24
|
+
const rows = (await unsafeReadRetrying(
|
|
25
|
+
db,
|
|
25
26
|
`SELECT id::text AS id, email, tenant_id::text AS tenant_id
|
|
26
27
|
FROM read_users
|
|
27
28
|
WHERE email = $1
|
|
@@ -37,7 +38,8 @@ export async function selectMembershipsOfUser(
|
|
|
37
38
|
db: AnyDb,
|
|
38
39
|
userId: string,
|
|
39
40
|
): Promise<readonly SeedMembershipDbRow[]> {
|
|
40
|
-
return (await
|
|
41
|
+
return (await unsafeReadRetrying(
|
|
42
|
+
db,
|
|
41
43
|
`SELECT m.user_id::text AS user_id,
|
|
42
44
|
m.tenant_id::text AS tenant_id,
|
|
43
45
|
e.tenant_id::text AS stream_tenant_id,
|
|
@@ -50,9 +52,11 @@ export async function selectMembershipsOfUser(
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export async function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]> {
|
|
53
|
-
return (await
|
|
55
|
+
return (await unsafeReadRetrying(
|
|
56
|
+
db,
|
|
54
57
|
`SELECT id::text AS id, name, key AS tenant_key
|
|
55
58
|
FROM read_tenants
|
|
56
59
|
ORDER BY inserted_at`,
|
|
60
|
+
[],
|
|
57
61
|
)) as readonly SeedTenantDbRow[];
|
|
58
62
|
}
|
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
|
+
});
|
|
@@ -103,6 +103,9 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
103
103
|
expect(res.headers.get("Content-Type")).toBe("image/webp");
|
|
104
104
|
expect(res.headers.get("Cache-Control")).toBe("private, max-age=31536000, immutable");
|
|
105
105
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
106
|
+
// fw#2442 — variant responses never derive from fileRef.fileName, so
|
|
107
|
+
// there is no Content-Disposition here for a field-encrypted name to leak into.
|
|
108
|
+
expect(res.headers.get("Content-Disposition")).toBeNull();
|
|
106
109
|
});
|
|
107
110
|
|
|
108
111
|
// setupTestStack doesn't wrap with the app-wide security-headers default, so a pass proves the route sets its own.
|
|
@@ -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
|
+
}
|
|
@@ -700,7 +700,7 @@ describe("validateBoot — retention", () => {
|
|
|
700
700
|
expect(matchingWarn).toBeUndefined();
|
|
701
701
|
});
|
|
702
702
|
|
|
703
|
-
test("blockDelete with only a subjectRef-only field
|
|
703
|
+
test("blockDelete with only a subjectRef-only field warns about EXT_USER_DATA delete hook (#2338)", () => {
|
|
704
704
|
const feature = defineFeature("test", (r) => {
|
|
705
705
|
r.entity(
|
|
706
706
|
"lease",
|
|
@@ -715,36 +715,42 @@ describe("validateBoot — retention", () => {
|
|
|
715
715
|
);
|
|
716
716
|
});
|
|
717
717
|
validateBoot([feature]);
|
|
718
|
-
const
|
|
718
|
+
const anonymizeWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
719
719
|
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
720
720
|
);
|
|
721
|
-
expect(
|
|
721
|
+
expect(anonymizeWarn).toBeUndefined();
|
|
722
|
+
const extWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
723
|
+
String(args[0]).includes("EXT_USER_DATA delete hook for Art.17"),
|
|
724
|
+
);
|
|
725
|
+
expect(extWarn).toBeDefined();
|
|
722
726
|
});
|
|
723
727
|
|
|
724
|
-
test(
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
728
|
+
test.each([
|
|
729
|
+
["self", { personal: "self" as const, find: "none" as const }],
|
|
730
|
+
["userOwned", { personal: { of: "authorId" } as const, find: "none" as const }],
|
|
731
|
+
["tenantOwned", { personal: "tenant" as const, find: "none" as const }],
|
|
732
|
+
])(
|
|
733
|
+
"blockDelete warns when subjectRef coexists with anonymizable %s field (#2336)",
|
|
734
|
+
(_label, personal) => {
|
|
735
|
+
const feature = defineFeature("test", (r) => {
|
|
736
|
+
r.entity(
|
|
737
|
+
"lease",
|
|
738
|
+
createEntity({
|
|
739
|
+
fields: {
|
|
740
|
+
authorId: createTextField({ personal: "ref" }),
|
|
741
|
+
subjectField: createTextField(personal),
|
|
742
|
+
},
|
|
743
|
+
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
744
|
+
}),
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
validateBoot([feature]);
|
|
748
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
749
|
+
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
740
750
|
);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
745
|
-
);
|
|
746
|
-
expect(matchingWarn).toBeDefined();
|
|
747
|
-
});
|
|
751
|
+
expect(matchingWarn).toBeDefined();
|
|
752
|
+
},
|
|
753
|
+
);
|
|
748
754
|
|
|
749
755
|
test('retention.keepFor with invalid format "30days" warns', () => {
|
|
750
756
|
const feature = defineFeature("test", (r) => {
|
|
@@ -2264,6 +2264,8 @@ describe("boot-validator", () => {
|
|
|
2264
2264
|
readonly fields: readonly string[];
|
|
2265
2265
|
}>;
|
|
2266
2266
|
readonly configKeys?: Readonly<Record<string, string>>;
|
|
2267
|
+
readonly mode?: "single" | "wizard";
|
|
2268
|
+
readonly draft?: boolean;
|
|
2267
2269
|
};
|
|
2268
2270
|
|
|
2269
2271
|
function makeFeature(override: ConfigEditOverride = {}) {
|
|
@@ -2291,7 +2293,11 @@ describe("boot-validator", () => {
|
|
|
2291
2293
|
scope: "tenant",
|
|
2292
2294
|
configKeys,
|
|
2293
2295
|
fields: fields as never,
|
|
2294
|
-
layout: {
|
|
2296
|
+
layout: {
|
|
2297
|
+
sections: sections as never,
|
|
2298
|
+
...(override.mode !== undefined ? { mode: override.mode } : {}),
|
|
2299
|
+
...(override.draft !== undefined ? { draft: override.draft } : {}),
|
|
2300
|
+
},
|
|
2295
2301
|
});
|
|
2296
2302
|
});
|
|
2297
2303
|
}
|
|
@@ -2383,6 +2389,26 @@ describe("boot-validator", () => {
|
|
|
2383
2389
|
const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
|
|
2384
2390
|
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
|
|
2385
2391
|
});
|
|
2392
|
+
test("mode: wizard mit nur 1 Section → Throw", () => {
|
|
2393
|
+
expect(() =>
|
|
2394
|
+
validateBoot([
|
|
2395
|
+
makeFeature({
|
|
2396
|
+
mode: "wizard",
|
|
2397
|
+
sections: [{ title: "Basics", fields: ["siteName"] }],
|
|
2398
|
+
}),
|
|
2399
|
+
]),
|
|
2400
|
+
).toThrow(/\(configEdit\).*mode: "wizard" but only 1 section\(s\)/);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
test("draft: true ohne gemountetes form-draft-Feature → Throw", () => {
|
|
2404
|
+
const sections = [
|
|
2405
|
+
{ title: "Step 1", fields: ["siteName"] },
|
|
2406
|
+
{ title: "Step 2", fields: ["maxUploadMb"] },
|
|
2407
|
+
];
|
|
2408
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections, draft: true })])).toThrow(
|
|
2409
|
+
/"form-draft" is not mounted/,
|
|
2410
|
+
);
|
|
2411
|
+
});
|
|
2386
2412
|
});
|
|
2387
2413
|
|
|
2388
2414
|
// --- entityEdit extension section ---
|
|
@@ -3245,7 +3271,7 @@ describe("boot-validator", () => {
|
|
|
3245
3271
|
label: "actions.view",
|
|
3246
3272
|
screen: "product-detail",
|
|
3247
3273
|
entity: "product",
|
|
3248
|
-
},
|
|
3274
|
+
} as never,
|
|
3249
3275
|
],
|
|
3250
3276
|
});
|
|
3251
3277
|
r.screen({
|
|
@@ -3268,7 +3294,7 @@ describe("boot-validator", () => {
|
|
|
3268
3294
|
type: "entityList",
|
|
3269
3295
|
entity: "product",
|
|
3270
3296
|
columns: ["name"],
|
|
3271
|
-
rowActions: [{ kind: "navigate", id: "view", label: "actions.view" }],
|
|
3297
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view" } as never],
|
|
3272
3298
|
});
|
|
3273
3299
|
});
|
|
3274
3300
|
expect(() => validateBoot([feature])).toThrow(
|
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { findForbiddenRoleAssignment } from "../role-assignment";
|
|
3
3
|
|
|
4
|
+
// Mirror DEFAULT_INVITE_ROLE_OPTIONS — framework must not import bundled-features
|
|
5
|
+
// (tsc pulls source into framework's rootDir and fails the package build).
|
|
6
|
+
const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
|
|
7
|
+
|
|
4
8
|
describe("role assignment guard", () => {
|
|
5
9
|
test("rejects roles above the actor's highest role", () => {
|
|
6
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"])).toBe("TenantAdmin");
|
|
7
|
-
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"])).toBe("SystemAdmin");
|
|
10
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"], [])).toBe("TenantAdmin");
|
|
11
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"], [])).toBe("SystemAdmin");
|
|
8
12
|
});
|
|
9
13
|
|
|
10
14
|
test("allows equal or lower roles, including self-updates", () => {
|
|
11
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"])).toBeUndefined();
|
|
12
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["Editor"])).toBeUndefined();
|
|
13
|
-
expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"])).toBeUndefined();
|
|
14
|
-
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"])).toBeUndefined();
|
|
15
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["Admin"])).toBeUndefined();
|
|
16
|
-
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"])).toBeUndefined();
|
|
17
|
-
expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"])).toBeUndefined();
|
|
15
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"], [])).toBeUndefined();
|
|
16
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["Editor"], [])).toBeUndefined();
|
|
17
|
+
expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"], [])).toBeUndefined();
|
|
18
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"], [])).toBeUndefined();
|
|
19
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["Admin"], [])).toBeUndefined();
|
|
20
|
+
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"], [])).toBeUndefined();
|
|
21
|
+
expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"], [])).toBeUndefined();
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
test("rejects Editor above User, Admin above Editor", () => {
|
|
21
|
-
expect(findForbiddenRoleAssignment(["User"], ["Editor"])).toBe("Editor");
|
|
22
|
-
expect(findForbiddenRoleAssignment(["Editor"], ["Admin"])).toBe("Admin");
|
|
25
|
+
expect(findForbiddenRoleAssignment(["User"], ["Editor"], [])).toBe("Editor");
|
|
26
|
+
expect(findForbiddenRoleAssignment(["Editor"], ["Admin"], [])).toBe("Admin");
|
|
23
27
|
});
|
|
24
28
|
|
|
25
29
|
test("rejects unknown roles (fail-closed)", () => {
|
|
26
|
-
expect(findForbiddenRoleAssignment(["User"], ["Custom"])).toBe("Custom");
|
|
27
|
-
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"])).toBe("Custom");
|
|
30
|
+
expect(findForbiddenRoleAssignment(["User"], ["Custom"], [])).toBe("Custom");
|
|
31
|
+
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"], [])).toBe("Custom");
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
test("rejects assignment if actor has no roles or only unknown roles", () => {
|
|
31
|
-
expect(findForbiddenRoleAssignment([], ["User"])).toBe("User");
|
|
32
|
-
expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"])).toBe("User");
|
|
35
|
+
expect(findForbiddenRoleAssignment([], ["User"], [])).toBe("User");
|
|
36
|
+
expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"], [])).toBe("User");
|
|
33
37
|
});
|
|
34
38
|
|
|
35
39
|
test("rejects modifying target user with higher existing role", () => {
|
|
@@ -41,7 +45,27 @@ describe("role assignment guard", () => {
|
|
|
41
45
|
// Editor is ranked (invite options); use a true app-defined role here.
|
|
42
46
|
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User"], ["Billing"])).toBeUndefined();
|
|
43
47
|
expect(findForbiddenRoleAssignment(["Admin"], ["User"], ["Billing", "User"])).toBeUndefined();
|
|
44
|
-
//
|
|
45
|
-
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], [
|
|
48
|
+
// Cannot introduce a *new* unranked role (fail-closed).
|
|
49
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], [])).toBe("Billing");
|
|
50
|
+
// Round-trip: strip then re-assign an unranked role the target already had.
|
|
51
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], ["Billing"])).toBeUndefined();
|
|
52
|
+
expect(
|
|
53
|
+
findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Billing"], ["Billing"]),
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("prototype-polluting role names do not bypass the guard", () => {
|
|
58
|
+
expect(findForbiddenRoleAssignment(["constructor"], ["SystemAdmin"], [])).toBe("SystemAdmin");
|
|
59
|
+
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["toString"], [])).toBe("toString");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("empty-string role is forbidden (not truthiness-skipped)", () => {
|
|
63
|
+
expect(findForbiddenRoleAssignment(["Admin"], [""], [])).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("TenantAdmin can assign every default invite role", () => {
|
|
67
|
+
for (const role of DEFAULT_INVITE_ROLE_OPTIONS) {
|
|
68
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], [role], [])).toBeUndefined();
|
|
69
|
+
}
|
|
46
70
|
});
|
|
47
71
|
});
|
|
@@ -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", () => {
|
|
@@ -1019,7 +1019,7 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
|
|
|
1019
1019
|
const schema = buildInsertSchema(entity);
|
|
1020
1020
|
// Each source is in-scale for its own scale-3 field, but the sum
|
|
1021
1021
|
// (0.333) has 3 decimal digits — over-scale for the scale-2 target
|
|
1022
|
-
// without the rounding
|
|
1022
|
+
// without the rounding from #1867 (roundDerivedCellValue).
|
|
1023
1023
|
const result = schema.safeParse({ lines: [{ a: 0.111, b: 0.111, c: 0.111 }] });
|
|
1024
1024
|
expect(result.success).toBe(true);
|
|
1025
1025
|
if (result.success) {
|
|
@@ -1043,8 +1043,8 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
|
|
|
1043
1043
|
},
|
|
1044
1044
|
});
|
|
1045
1045
|
const schema = buildInsertSchema(entity);
|
|
1046
|
-
// 2400.58 - 93 = 2307.58
|
|
1047
|
-
// integer constraint without rounding.
|
|
1046
|
+
// 2400.58 - 93 = 2307.58 major units — not representable by money's
|
|
1047
|
+
// integer minor-unit constraint without rounding (#1867).
|
|
1048
1048
|
const result = schema.safeParse({ lines: [{ gross: 2400.58, refund: 93 }] });
|
|
1049
1049
|
expect(result.success).toBe(true);
|
|
1050
1050
|
if (result.success) {
|