@cosmicdrift/kumiko-bundled-features 0.308.0 → 0.310.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 +9 -9
- package/src/auth-email-password/__tests__/seed-admin-guarded.integration.test.ts +31 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/seeding.ts +16 -3
- package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +454 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/feature.ts +1 -0
- package/src/data-retention/run-retention-cleanup.ts +312 -5
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +224 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +38 -4
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +40 -0
- package/src/file-derivatives/handlers/public-variant.query.ts +16 -7
- package/src/file-derivatives/index.ts +5 -1
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +8 -24
- package/src/user/seeding.ts +29 -20
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +8 -61
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
19
|
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
20
20
|
import { PUBLIC_VARIANT_QN, publicVariantQuery } from "./handlers/public-variant.query";
|
|
21
|
+
import {
|
|
22
|
+
PUBLIC_VARIANT_BY_FILE_REF_QN,
|
|
23
|
+
publicVariantByFileRefQuery,
|
|
24
|
+
} from "./handlers/public-variant-by-file-ref.query";
|
|
21
25
|
|
|
22
26
|
const FEATURE_NAME = "file-derivatives";
|
|
23
27
|
|
|
@@ -42,6 +46,8 @@ export type PublicVariantResolveApexTenant = (
|
|
|
42
46
|
host: string,
|
|
43
47
|
) => Promise<TenantId | null> | TenantId | null;
|
|
44
48
|
|
|
49
|
+
export type PublicVariantTenantResolution = "host" | "fileRef";
|
|
50
|
+
|
|
45
51
|
export type FileDerivativesOptions = {
|
|
46
52
|
/** Host → tenantId for the anonymous `/media/:fileRefId/:variant` route.
|
|
47
53
|
* Without this option, neither the httpRoute NOR the `publicVariant`
|
|
@@ -52,6 +58,14 @@ export type FileDerivativesOptions = {
|
|
|
52
58
|
readonly resolveApexTenant?: PublicVariantResolveApexTenant;
|
|
53
59
|
/** Base path of the public variant route. Default "/media". */
|
|
54
60
|
readonly basePath?: string;
|
|
61
|
+
/** How the route picks the tenant a variant is read from. "host" (default):
|
|
62
|
+
* tenant = resolveApexTenant(host). "fileRef": resolveApexTenant still
|
|
63
|
+
* gates the host (an unknown host still 404s), but the variant itself is
|
|
64
|
+
* read from the FileRef row's own tenant — a single shared platform host
|
|
65
|
+
* then serves every tenant's public variants. The FileRef-tenant's own
|
|
66
|
+
* `isPublic` predicate remains the default-deny gate either way; requires
|
|
67
|
+
* `resolveApexTenant`. */
|
|
68
|
+
readonly publicTenantResolution?: PublicVariantTenantResolution;
|
|
55
69
|
};
|
|
56
70
|
|
|
57
71
|
// Raw handler-return of publicVariantQuery — systemQuery dispatches
|
|
@@ -73,13 +87,22 @@ type PublicVariantQueryResult = {
|
|
|
73
87
|
// — only a name, resolved against the field's own `variants` declaration —
|
|
74
88
|
// and only after the app's registered `isPublic` predicate for the FileRef's
|
|
75
89
|
// entityType says yes. tenantId is resolved from the request Host via
|
|
76
|
-
// `resolveApexTenant`, NEVER read from the request payload
|
|
90
|
+
// `resolveApexTenant`, NEVER read from the request payload — in
|
|
91
|
+
// `publicTenantResolution: "fileRef"` mode the tenant instead comes from the
|
|
92
|
+
// FileRef row matching `fileRefId` (never from the payload either), and the
|
|
93
|
+
// by-file-ref handler is reachable over `/api` independently of any Host,
|
|
94
|
+
// but still only returns a variant its FileRef-tenant's `isPublic` allows.
|
|
77
95
|
export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}): FeatureDefinition {
|
|
78
96
|
const basePath = opts.basePath ?? "/media";
|
|
97
|
+
if (opts.publicTenantResolution !== undefined && !opts.resolveApexTenant) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"createFileDerivativesFeature: publicTenantResolution requires resolveApexTenant — it only changes which tenant a variant is read from, resolveApexTenant still gates the host.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
79
102
|
|
|
80
103
|
return defineFeature(FEATURE_NAME, (r) => {
|
|
81
104
|
r.describe(
|
|
82
|
-
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
|
|
105
|
+
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. `publicTenantResolution: \"fileRef\"` keeps resolveApexTenant as the host gate but reads the variant from the FileRef row's own tenant instead of the host's, so one shared platform host serves every tenant's public variants — each FileRef-tenant's own `isPublic` predicate still default-denies. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
|
|
83
106
|
);
|
|
84
107
|
r.uiHints({
|
|
85
108
|
displayLabel: "File Derivatives",
|
|
@@ -127,8 +150,18 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
127
150
|
// `resolveApexTenant` enforces.
|
|
128
151
|
if (opts.resolveApexTenant) {
|
|
129
152
|
const resolveApexTenant = opts.resolveApexTenant;
|
|
153
|
+
const byFileRef = opts.publicTenantResolution === "fileRef";
|
|
154
|
+
const routeQn = byFileRef ? PUBLIC_VARIANT_BY_FILE_REF_QN : PUBLIC_VARIANT_QN;
|
|
130
155
|
|
|
131
156
|
r.queryHandler(publicVariantQuery);
|
|
157
|
+
// publicVariantByFileRefQuery is the ONLY way "fileRef" mode is
|
|
158
|
+
// reachable — registered ONLY in that mode for the same reason
|
|
159
|
+
// publicVariantQuery is gated above: registering it unconditionally
|
|
160
|
+
// would expose its unsafeRaw, cross-tenant FileRef lookup via the
|
|
161
|
+
// generic `/api` query dispatch even in default "host" mode.
|
|
162
|
+
if (byFileRef) {
|
|
163
|
+
r.queryHandler(publicVariantByFileRefQuery);
|
|
164
|
+
}
|
|
132
165
|
|
|
133
166
|
r.httpRoute({
|
|
134
167
|
method: "GET",
|
|
@@ -156,9 +189,10 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
156
189
|
let result: PublicVariantQueryResult;
|
|
157
190
|
try {
|
|
158
191
|
// @cast-boundary engine-payload — shape comes from
|
|
159
|
-
// publicVariantQuery's return type
|
|
192
|
+
// publicVariantQuery's return type (publicVariantByFileRefQuery
|
|
193
|
+
// returns exactly that, or null, never its own shape).
|
|
160
194
|
result = (await systemQuery(
|
|
161
|
-
|
|
195
|
+
routeQn,
|
|
162
196
|
{ fileRefId, variant },
|
|
163
197
|
tenantId,
|
|
164
198
|
)) as PublicVariantQueryResult;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createAnonymousUser,
|
|
4
|
+
defineQueryHandler,
|
|
5
|
+
isUuid,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
9
|
+
import { PUBLIC_VARIANT_QN, publicVariantPayloadSchema } from "./public-variant.query";
|
|
10
|
+
|
|
11
|
+
type FileRefTenantRow = {
|
|
12
|
+
readonly tenantId: TenantId;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const BY_FILE_REF_ESCAPE_HATCH_REASON =
|
|
16
|
+
"shared public host serves every tenant's public variants; the FileRef row names the tenant, and publicVariantQuery's isPublic gate still runs inside that tenant via queryAs";
|
|
17
|
+
|
|
18
|
+
// Full QN this handler is registered under, mirrors PUBLIC_VARIANT_QN.
|
|
19
|
+
export const PUBLIC_VARIANT_BY_FILE_REF_QN = "file-derivatives:query:public-variant-by-file-ref";
|
|
20
|
+
|
|
21
|
+
export const publicVariantByFileRefQuery = defineQueryHandler({
|
|
22
|
+
name: "public-variant-by-file-ref",
|
|
23
|
+
schema: publicVariantPayloadSchema,
|
|
24
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
25
|
+
agent: { expose: false },
|
|
26
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
27
|
+
escapeHatch: {
|
|
28
|
+
reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
|
|
29
|
+
},
|
|
30
|
+
handler: async (query, ctx) => {
|
|
31
|
+
const row = await fetchOne<FileRefTenantRow>(
|
|
32
|
+
ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON),
|
|
33
|
+
fileRefsTable,
|
|
34
|
+
{ id: query.payload.fileRefId, isDeleted: false },
|
|
35
|
+
);
|
|
36
|
+
if (!row || !isUuid(row.tenantId)) return null;
|
|
37
|
+
|
|
38
|
+
return ctx.queryAs(createAnonymousUser(row.tenantId), PUBLIC_VARIANT_QN, query.payload);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// feature.ts), `ctx.user.tenantId` instead comes from that dispatcher's own
|
|
17
17
|
// `anonymousAccess` resolution — tenant provenance there depends on the
|
|
18
18
|
// consumer's `resolverTrust`/anonymousAccess setup, not the host.
|
|
19
|
+
//
|
|
20
|
+
// In `publicTenantResolution: "fileRef"` mode, this handler is also the
|
|
21
|
+
// `ctx.queryAs(createAnonymousUser(fileRefTenantId), ...)` target of
|
|
22
|
+
// publicVariantByFileRefQuery — tenantId there comes from the FileRef row,
|
|
23
|
+
// but this handler's own isPublic gate runs exactly the same either way.
|
|
19
24
|
|
|
20
25
|
import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
|
|
21
26
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
@@ -72,15 +77,19 @@ declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
|
72
77
|
// def itself — mirrors managed-pages' BY_SLUG_QN.
|
|
73
78
|
export const PUBLIC_VARIANT_QN = "file-derivatives:query:public-variant";
|
|
74
79
|
|
|
80
|
+
// Shared with publicVariantByFileRefQuery — same payload shape, both routes
|
|
81
|
+
// forward `{ fileRefId, variant }` unchanged.
|
|
82
|
+
export const publicVariantPayloadSchema = z.object({
|
|
83
|
+
// Loose, version-agnostic UUID shape (same as isUuid/TENANT_ID_REGEX in
|
|
84
|
+
// packages/types) — not zod's `.uuid()`, which is stricter than this
|
|
85
|
+
// repo's convention and would reject valid v7/nil ids the DB accepts.
|
|
86
|
+
fileRefId: z.string().refine(isUuid, "invalid fileRefId"),
|
|
87
|
+
variant: z.string().min(1).max(64),
|
|
88
|
+
});
|
|
89
|
+
|
|
75
90
|
export const publicVariantQuery = defineQueryHandler({
|
|
76
91
|
name: "public-variant",
|
|
77
|
-
schema:
|
|
78
|
-
// Loose, version-agnostic UUID shape (same as isUuid/TENANT_ID_REGEX in
|
|
79
|
-
// packages/types) — not zod's `.uuid()`, which is stricter than this
|
|
80
|
-
// repo's convention and would reject valid v7/nil ids the DB accepts.
|
|
81
|
-
fileRefId: z.string().refine(isUuid, "invalid fileRefId"),
|
|
82
|
-
variant: z.string().min(1).max(64),
|
|
83
|
-
}),
|
|
92
|
+
schema: publicVariantPayloadSchema,
|
|
84
93
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
85
94
|
agent: { expose: false },
|
|
86
95
|
// ponytail: "ip" trusts the first x-forwarded-for hop (buildRequestContextData
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export type {
|
|
1
|
+
export type {
|
|
2
|
+
FileDerivativesOptions,
|
|
3
|
+
PublicVariantResolveApexTenant,
|
|
4
|
+
PublicVariantTenantResolution,
|
|
5
|
+
} from "./feature";
|
|
2
6
|
export { createFileDerivativesFeature, fileDerivativesFeature } from "./feature";
|
|
3
7
|
export type {
|
|
4
8
|
DerivativePublicPredicateArgs,
|
|
@@ -16,8 +16,6 @@ import {
|
|
|
16
16
|
type TestStack,
|
|
17
17
|
TestUsers,
|
|
18
18
|
testTenantId,
|
|
19
|
-
unsafeCreateEntityTable,
|
|
20
|
-
unsafePushTables,
|
|
21
19
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
20
|
import {
|
|
23
21
|
createTestEnvelopeCipher,
|
|
@@ -27,18 +25,16 @@ import {
|
|
|
27
25
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
28
26
|
import {
|
|
29
27
|
createComplianceProfilesFeature,
|
|
30
|
-
tenantComplianceProfileEntity,
|
|
31
28
|
tenantComplianceProfileTable,
|
|
32
29
|
} from "../../compliance-profiles";
|
|
33
30
|
import { createConfigFeature } from "../../config";
|
|
34
31
|
import { createConfigResolver } from "../../config/resolver";
|
|
35
|
-
import { configValuesTable } from "../../config/table";
|
|
36
32
|
import { createSessionsFeature } from "../../sessions";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
33
|
+
import { userSessionTable } from "../../sessions/schema/user-session";
|
|
34
|
+
import { tenantMembershipsTable } from "../../tenant";
|
|
39
35
|
import { TenantHandlers, TenantQueries } from "../../tenant/constants";
|
|
40
36
|
import { createTenantFeature } from "../../tenant/feature";
|
|
41
|
-
import {
|
|
37
|
+
import { tenantTable } from "../../tenant/schema/tenant";
|
|
42
38
|
import { seedTenantMembership } from "../../tenant/testing";
|
|
43
39
|
import { createUserFeature } from "../../user/feature";
|
|
44
40
|
import {
|
|
@@ -85,12 +81,6 @@ beforeAll(async () => {
|
|
|
85
81
|
} as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
|
|
86
82
|
});
|
|
87
83
|
db = stack.db;
|
|
88
|
-
|
|
89
|
-
await unsafeCreateEntityTable(db, tenantEntity);
|
|
90
|
-
await unsafeCreateEntityTable(db, userSessionEntity);
|
|
91
|
-
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
92
|
-
await unsafeCreateEntityTable(db, tenantMembershipEntity);
|
|
93
|
-
await unsafePushTables(db, { configValuesTable });
|
|
94
84
|
});
|
|
95
85
|
|
|
96
86
|
afterAll(async () => {
|
|
@@ -175,6 +165,11 @@ describe("tenant-lifecycle :: request / cancel / 410 gate", () => {
|
|
|
175
165
|
const err = await stack.http.writeErr(REQUEST, {}, member);
|
|
176
166
|
expect(err.httpStatus).toBe(403);
|
|
177
167
|
});
|
|
168
|
+
|
|
169
|
+
test("authenticated read against an unseeded tenant doesn't 500 on a missing read_tenants table (fw#3102)", async () => {
|
|
170
|
+
const res = await stack.http.query(TenantQueries.me, {}, tenantAdmin);
|
|
171
|
+
expect(res.status).toBe(200);
|
|
172
|
+
});
|
|
178
173
|
});
|
|
179
174
|
|
|
180
175
|
function createPoisonTenantDataFeature() {
|
|
@@ -210,9 +205,6 @@ describe("tenant-lifecycle :: pipeline abandon / destroyFailed", () => {
|
|
|
210
205
|
},
|
|
211
206
|
} as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
|
|
212
207
|
});
|
|
213
|
-
await unsafeCreateEntityTable(poisonStack.db, tenantEntity);
|
|
214
|
-
await unsafeCreateEntityTable(poisonStack.db, tenantComplianceProfileEntity);
|
|
215
|
-
await unsafePushTables(poisonStack.db, { configValuesTable });
|
|
216
208
|
});
|
|
217
209
|
|
|
218
210
|
afterAll(async () => {
|
|
@@ -416,9 +408,6 @@ describe("tenant-lifecycle :: sweep isolates one tenant's failure from another's
|
|
|
416
408
|
],
|
|
417
409
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
418
410
|
});
|
|
419
|
-
await unsafeCreateEntityTable(isolationStack.db, tenantEntity);
|
|
420
|
-
await unsafeCreateEntityTable(isolationStack.db, tenantComplianceProfileEntity);
|
|
421
|
-
await unsafePushTables(isolationStack.db, { configValuesTable });
|
|
422
411
|
});
|
|
423
412
|
|
|
424
413
|
afterAll(async () => {
|
|
@@ -483,11 +472,6 @@ describe("tenant-lifecycle :: 410 gate derived from the mounted provider", () =>
|
|
|
483
472
|
],
|
|
484
473
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
485
474
|
});
|
|
486
|
-
await unsafeCreateEntityTable(derivedStack.db, tenantEntity);
|
|
487
|
-
await unsafeCreateEntityTable(derivedStack.db, userSessionEntity);
|
|
488
|
-
await unsafeCreateEntityTable(derivedStack.db, tenantComplianceProfileEntity);
|
|
489
|
-
await unsafeCreateEntityTable(derivedStack.db, tenantMembershipEntity);
|
|
490
|
-
await unsafePushTables(derivedStack.db, { configValuesTable });
|
|
491
475
|
});
|
|
492
476
|
|
|
493
477
|
afterAll(async () => {
|
package/src/user/seeding.ts
CHANGED
|
@@ -71,26 +71,8 @@ export async function seedUser(
|
|
|
71
71
|
// liefert die Projection-Row als Record<string, unknown>.
|
|
72
72
|
if (existing) {
|
|
73
73
|
const id = existing["id"] as string;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// existed or before it flipped to true — without this, "seed with
|
|
77
|
-
// emailVerified: true" only ever takes effect on the very first insert
|
|
78
|
-
// and the flag silently does nothing on every re-run after (#1687).
|
|
79
|
-
// Goes through the executor (a real `.updated` event), never a direct
|
|
80
|
-
// write.
|
|
81
|
-
if (options.emailVerified === true && existing["emailVerified"] !== true) {
|
|
82
|
-
const result = await userExecutor.update(
|
|
83
|
-
{ id, version: existing["version"] as number, changes: { emailVerified: true } },
|
|
84
|
-
by,
|
|
85
|
-
tdb,
|
|
86
|
-
);
|
|
87
|
-
// version_conflict = a concurrent write already changed the row —
|
|
88
|
-
// fine for a seed helper, don't fail the whole seed run over it.
|
|
89
|
-
if (!result.isSuccess && result.error.code !== "version_conflict") {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`seedUser emailVerified reconcile failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
74
|
+
if (options.emailVerified === true) {
|
|
75
|
+
await reconcileSeededUserEmailVerified(db, existing, by);
|
|
94
76
|
}
|
|
95
77
|
return { id };
|
|
96
78
|
}
|
|
@@ -116,6 +98,33 @@ export async function seedUser(
|
|
|
116
98
|
return { id: extractId(result.data, "seedUser") };
|
|
117
99
|
}
|
|
118
100
|
|
|
101
|
+
// A re-run seed onto a row created before emailVerified flipped to true must
|
|
102
|
+
// still set it (#1687) — via the executor, so it lands as a real `.updated` event.
|
|
103
|
+
export async function reconcileSeededUserEmailVerified(
|
|
104
|
+
db: DbConnection,
|
|
105
|
+
existingRow: Record<string, unknown>,
|
|
106
|
+
by: SessionUser,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
// skip: already verified, nothing to reconcile
|
|
109
|
+
if (existingRow["emailVerified"] === true) return;
|
|
110
|
+
// @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
|
|
111
|
+
// liefert die Projection-Row als Record<string, unknown>.
|
|
112
|
+
const id = existingRow["id"] as string;
|
|
113
|
+
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
114
|
+
const result = await userExecutor.update(
|
|
115
|
+
{ id, version: existingRow["version"] as number, changes: { emailVerified: true } },
|
|
116
|
+
by,
|
|
117
|
+
tdb,
|
|
118
|
+
);
|
|
119
|
+
// version_conflict = a concurrent write already changed the row —
|
|
120
|
+
// fine for a seed helper, don't fail the whole seed run over it.
|
|
121
|
+
if (!result.isSuccess && result.error.code !== "version_conflict") {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`reconcileSeededUserEmailVerified failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
// Extrahiert die `id`-Spalte aus dem executor.create-Result. Der
|
|
120
129
|
// Executor liefert ein Record<string, unknown> (die Projection-Row), in
|
|
121
130
|
// das die DB die Aggregat-id reinschreibt — wir prüfen runtime statt
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import {
|
|
2
|
+
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
3
3
|
import {
|
|
4
4
|
createSystemUser,
|
|
5
5
|
type FieldDefinition,
|
|
@@ -10,13 +10,11 @@ import {
|
|
|
10
10
|
type UserDataHookCtx,
|
|
11
11
|
type UserDataStorageProvider,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
-
import {
|
|
14
|
-
assertSafeStorageKey,
|
|
15
|
-
fileRefEntity,
|
|
16
|
-
fileRefsTable,
|
|
17
|
-
} from "@cosmicdrift/kumiko-framework/files";
|
|
13
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
18
14
|
import { assertErased } from "../../shared";
|
|
19
15
|
|
|
16
|
+
const ERROR_CONTEXT = "user-data-rights-defaults:fileRef";
|
|
17
|
+
|
|
20
18
|
// Forget writes go through the executor (events), not deleteMany/updateMany:
|
|
21
19
|
// a projection rebuild replays the events, so the erasure survives. Eventless
|
|
22
20
|
// writes are wiped/resurrected on rebuild — the Art.17 hole this fixes. Bounded:
|
|
@@ -174,52 +172,12 @@ function isPersonalFileRow(registry: Registry, row: Record<string, unknown>): bo
|
|
|
174
172
|
return isPersonalPiiField(fieldDef);
|
|
175
173
|
}
|
|
176
174
|
|
|
177
|
-
// Derivatives (thumbnails/resized variants — see derivatives-context.ts) are
|
|
178
|
-
// never tracked anywhere but the storage layer: they're rendered under a
|
|
179
|
-
// deterministic key (deriveKey + variantSuffix) computed from the original's
|
|
180
|
-
// key + a render spec, on demand, and nothing writes that key back onto the
|
|
181
|
-
// FileRef row. List the original's key-prefix and keep only candidates whose
|
|
182
|
-
// suffix matches the derivative grammar, anchored to the original's own
|
|
183
|
-
// basename AND extension — a same-directory sibling original (a different
|
|
184
|
-
// file, possibly another user's) shares the same list prefix but fails the
|
|
185
|
-
// extension/suffix check and is never touched.
|
|
186
|
-
async function listDerivativeKeys(
|
|
187
|
-
originalKey: string,
|
|
188
|
-
provider: UserDataStorageProvider,
|
|
189
|
-
): Promise<readonly string[]> {
|
|
190
|
-
let candidates: readonly string[];
|
|
191
|
-
try {
|
|
192
|
-
candidates = await provider.list(derivativeListPrefix(originalKey));
|
|
193
|
-
} catch (err) {
|
|
194
|
-
// Wrap rather than let a raw provider error (e.g. S3 AccessDenied) surface
|
|
195
|
-
// unexplained — list() is a NEW requirement this hook added on top of
|
|
196
|
-
// delete(); a bucket policy granting only s3:DeleteObject now fails forget
|
|
197
|
-
// every run instead of just leaving derivatives behind. Name the likely
|
|
198
|
-
// cause so an operator doesn't have to guess.
|
|
199
|
-
throw new Error(
|
|
200
|
-
`[user-data-rights-defaults:fileRef] provider.list() failed while looking up derivatives of key=${originalKey} — forget/erasure requires list permission on the storage bucket (e.g. s3:ListBucket), not just delete. Original error: ${err instanceof Error ? err.message : String(err)}`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
const derivativeKeys: string[] = [];
|
|
204
|
-
for (const candidate of candidates) {
|
|
205
|
-
if (!isDerivativeKeyOf(originalKey, candidate)) continue;
|
|
206
|
-
try {
|
|
207
|
-
assertSafeStorageKey(candidate);
|
|
208
|
-
} catch {
|
|
209
|
-
// ponytail: a listing hit that fails the storage-key grammar can't be
|
|
210
|
-
// a real derivative (every derivative is written via a validated
|
|
211
|
-
// write()) — skip rather than pass a malformed key to delete().
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
derivativeKeys.push(candidate);
|
|
215
|
-
}
|
|
216
|
-
return derivativeKeys;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
175
|
// Delete every row's binary (original + any derivatives) via the provider.
|
|
220
176
|
// Returns the keys whose delete threw — the caller fails closed on a
|
|
221
177
|
// non-empty list so the sub-tx rolls back and the next forget run retries
|
|
222
|
-
// (delete is idempotent → converges).
|
|
178
|
+
// (delete is idempotent → converges). Derivative-listing + per-key delete
|
|
179
|
+
// lives in the shared derivatives helper (also used by data-retention's
|
|
180
|
+
// hardDelete cleanup) so this hook no longer carries its own copy.
|
|
223
181
|
async function deleteBinaries(
|
|
224
182
|
rows: readonly Record<string, unknown>[],
|
|
225
183
|
provider: UserDataStorageProvider,
|
|
@@ -228,18 +186,7 @@ async function deleteBinaries(
|
|
|
228
186
|
for (const row of rows) {
|
|
229
187
|
const key = row["storageKey"]; // @cast-boundary db-row
|
|
230
188
|
if (typeof key !== "string" || key.length === 0) continue;
|
|
231
|
-
|
|
232
|
-
for (const k of keysToDelete) {
|
|
233
|
-
try {
|
|
234
|
-
await provider.delete(k);
|
|
235
|
-
} catch (err) {
|
|
236
|
-
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
237
|
-
console.warn(
|
|
238
|
-
`[user-data-rights-defaults:fileRef] storage delete failed key=${k} err=${err instanceof Error ? err.message : String(err)}`,
|
|
239
|
-
);
|
|
240
|
-
failedKeys.push(k);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
189
|
+
failedKeys.push(...(await deleteStoredFileAndDerivatives(key, provider, ERROR_CONTEXT)));
|
|
243
190
|
}
|
|
244
191
|
return failedKeys;
|
|
245
192
|
}
|