@cosmicdrift/kumiko-bundled-features 0.195.0 → 0.196.1
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 -7
- package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +39 -0
- package/src/file-derivatives/feature.ts +15 -3
- package/src/form-draft/__tests__/cleanup.integration.test.ts +14 -2
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +30 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +22 -3
- package/src/form-draft/handlers/cleanup.job.ts +10 -2
- package/src/form-draft/handlers/discard.write.ts +28 -3
- package/src/ledger/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.196.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -125,12 +125,12 @@
|
|
|
125
125
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.196.1",
|
|
129
|
+
"@cosmicdrift/kumiko-framework": "0.196.1",
|
|
130
|
+
"@cosmicdrift/kumiko-headless": "0.196.1",
|
|
131
|
+
"@cosmicdrift/kumiko-renderer": "0.196.1",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer-web": "0.196.1",
|
|
133
|
+
"@cosmicdrift/kumiko-types": "0.196.1",
|
|
134
134
|
"@mollie/api-client": "^4.5.0",
|
|
135
135
|
"@node-rs/argon2": "^2.0.2",
|
|
136
136
|
"@types/mailparser": "^3.4.6",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Proves #1977/2: createFileDerivativesFeature() mounted WITHOUT
|
|
2
|
+
// resolveApexTenant must not expose `publicVariant` via ANY path — not just
|
|
3
|
+
// "the httpRoute isn't mounted" (already true before the fix), but also not
|
|
4
|
+
// via the generic `/api/query` dispatch an anonymous, host-only-scoped
|
|
5
|
+
// consumer can reach. Before the fix, `r.queryHandler(publicVariantQuery)`
|
|
6
|
+
// ran unconditionally, registering the handler into the feature's dispatch
|
|
7
|
+
// table as a side effect regardless of what its return value was used for
|
|
8
|
+
// — so `PUBLIC_VARIANT_QN` was dispatchable even with no resolveApexTenant,
|
|
9
|
+
// and `ctx.user.tenantId` then came from anonymousAccess resolution instead
|
|
10
|
+
// of the host, bypassing the "tenantId only from the host" invariant.
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { createConfigFeature } from "../../config";
|
|
16
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
17
|
+
import { createFileDerivativesFeature } from "../feature";
|
|
18
|
+
import { PUBLIC_VARIANT_QN } from "../handlers/public-variant.query";
|
|
19
|
+
|
|
20
|
+
describe("file-derivatives :: publicVariant query gating without resolveApexTenant", () => {
|
|
21
|
+
test("PUBLIC_VARIANT_QN is not dispatchable via the generic /api/query path — 404, same as any unknown query", async () => {
|
|
22
|
+
const stack: TestStack = await setupTestStack({
|
|
23
|
+
features: [createConfigFeature(), fileFoundationFeature, createFileDerivativesFeature()],
|
|
24
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
29
|
+
type: PUBLIC_VARIANT_QN,
|
|
30
|
+
payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(res.status).toBe(404);
|
|
34
|
+
expect(stack.registry.getQueryHandler(PUBLIC_VARIANT_QN)).toBeUndefined();
|
|
35
|
+
} finally {
|
|
36
|
+
await stack.cleanup();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -44,8 +44,10 @@ export type PublicVariantResolveApexTenant = (
|
|
|
44
44
|
|
|
45
45
|
export type FileDerivativesOptions = {
|
|
46
46
|
/** Host → tenantId for the anonymous `/media/:fileRefId/:variant` route.
|
|
47
|
-
* Without this option the
|
|
48
|
-
*
|
|
47
|
+
* Without this option, neither the httpRoute NOR the `publicVariant`
|
|
48
|
+
* query is registered — it is unreachable via the httpRoute path and via
|
|
49
|
+
* the generic `/api` query dispatch. Existing consumers that only need
|
|
50
|
+
* `ctx.derivatives` are unaffected. */
|
|
49
51
|
readonly resolveApexTenant?: PublicVariantResolveApexTenant;
|
|
50
52
|
/** Base path of the public variant route. Default "/media". */
|
|
51
53
|
readonly basePath?: string;
|
|
@@ -102,11 +104,21 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
102
104
|
},
|
|
103
105
|
});
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
// Registered ONLY when resolveApexTenant is supplied — r.queryHandler
|
|
108
|
+
// registers publicVariantQuery into the feature's dispatch table as a
|
|
109
|
+
// side effect of being called, independent of what's done with its
|
|
110
|
+
// return value. Calling it unconditionally would make `publicVariant`
|
|
111
|
+
// (access: ["anonymous", ...]) reachable via the generic `/api` query
|
|
112
|
+
// dispatch even when the httpRoute below isn't mounted, bypassing the
|
|
113
|
+
// "tenantId comes from the host, never the payload" invariant that only
|
|
114
|
+
// `resolveApexTenant` enforces.
|
|
115
|
+
const queries: { publicVariant?: ReturnType<typeof r.queryHandler> } = {};
|
|
106
116
|
|
|
107
117
|
if (opts.resolveApexTenant) {
|
|
108
118
|
const resolveApexTenant = opts.resolveApexTenant;
|
|
109
119
|
|
|
120
|
+
queries.publicVariant = r.queryHandler(publicVariantQuery);
|
|
121
|
+
|
|
110
122
|
r.httpRoute({
|
|
111
123
|
method: "GET",
|
|
112
124
|
path: `${basePath}/:fileRefId/:variant`,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
-
import type
|
|
13
|
+
import { qn, type TenantId, toKebab } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
15
|
import {
|
|
16
16
|
createInMemoryFileProvider,
|
|
@@ -30,7 +30,7 @@ import { ConfigHandlers } from "../../config/constants";
|
|
|
30
30
|
import { createConfigAccessorFactory, createConfigFeature } from "../../config/feature";
|
|
31
31
|
import { createConfigResolver } from "../../config/resolver";
|
|
32
32
|
import { configValuesTable } from "../../config/table";
|
|
33
|
-
import { FormDraftHandlers } from "../constants";
|
|
33
|
+
import { FORM_DRAFT_FEATURE_NAME, FormDraftHandlers } from "../constants";
|
|
34
34
|
import { formDraftEntity } from "../entity";
|
|
35
35
|
import { formDraftFeature } from "../feature";
|
|
36
36
|
import { FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY } from "../handlers/cleanup.job";
|
|
@@ -164,6 +164,18 @@ async function dispatchCleanup(): Promise<void> {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
describe("form-draft cleanup job", () => {
|
|
167
|
+
// FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY (cleanup.job.ts) is a hardcoded
|
|
168
|
+
// literal that must match the qualified name r.config({ keys: {
|
|
169
|
+
// retentionDays: ... } }) derives internally in feature.ts — nothing
|
|
170
|
+
// else ties the two together, so a rename of the `retentionDays` config
|
|
171
|
+
// key there would silently desync them. This recomputes the derivation
|
|
172
|
+
// and fails loudly if that ever happens.
|
|
173
|
+
test("FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY matches the qualified name feature.ts derives for retentionDays", () => {
|
|
174
|
+
expect(FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY).toBe(
|
|
175
|
+
qn(toKebab(FORM_DRAFT_FEATURE_NAME), "config", toKebab("retentionDays")),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
167
179
|
test("deletes a draft older than the default retention window, leaves a fresh one alone", async () => {
|
|
168
180
|
await saveDraft("wizard:old");
|
|
169
181
|
await backdate("wizard:old", 31);
|
|
@@ -187,4 +187,34 @@ describe("form-draft discard — FileRef release", () => {
|
|
|
187
187
|
|
|
188
188
|
expect(provider.keys()).toContain(key);
|
|
189
189
|
});
|
|
190
|
+
|
|
191
|
+
// fw#1922: a released FileRef must go through the fileRef executor
|
|
192
|
+
// (fileRef.forgotten event), not a raw provider.delete() that bypasses
|
|
193
|
+
// the event store; the file_refs row itself must be purged, not just
|
|
194
|
+
// the storage binary.
|
|
195
|
+
test("discarding with releaseFiles: true hard-purges the file_refs row through the event store, not just the storage binary", async () => {
|
|
196
|
+
const key = "tenant/vehicle/photo/row-purge.jpg";
|
|
197
|
+
await saveDraft("wizard:row-purge", {});
|
|
198
|
+
await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
199
|
+
await seedFileRef(key);
|
|
200
|
+
await saveDraft("wizard:row-purge", { photo: fileRefPointer(key) });
|
|
201
|
+
|
|
202
|
+
const before = await asRawClient(stack.db).unsafe(
|
|
203
|
+
`SELECT "id" FROM "file_refs" WHERE "storage_key" = $1`,
|
|
204
|
+
[key],
|
|
205
|
+
);
|
|
206
|
+
expect(before).toHaveLength(1);
|
|
207
|
+
|
|
208
|
+
await discardDraft("wizard:row-purge", true);
|
|
209
|
+
|
|
210
|
+
const after = await asRawClient(stack.db).unsafe(
|
|
211
|
+
`SELECT "id" FROM "file_refs" WHERE "storage_key" = $1`,
|
|
212
|
+
[key],
|
|
213
|
+
);
|
|
214
|
+
expect(after).toHaveLength(0);
|
|
215
|
+
const forgottenEvents = await asRawClient(stack.db).unsafe(
|
|
216
|
+
`SELECT "type" FROM "kumiko_events" WHERE "aggregate_type" = 'fileRef' AND "type" = 'fileRef.forgotten'`,
|
|
217
|
+
);
|
|
218
|
+
expect(forgottenEvents.length).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
190
220
|
});
|
|
@@ -25,13 +25,32 @@ export async function filterOwnedStorageKeys(
|
|
|
25
25
|
candidateKeys: readonly string[],
|
|
26
26
|
draftInsertedAt: Temporal.Instant,
|
|
27
27
|
): Promise<readonly string[]> {
|
|
28
|
+
const rows = await filterOwnedFileRefs(db, tenantId, ownerId, candidateKeys, draftInsertedAt);
|
|
29
|
+
return rows.map((row) => row.storageKey);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Same ownership boundary as filterOwnedStorageKeys, but also returns the
|
|
33
|
+
// file_refs row id — a hard-erasure call (executor.forget) addresses the
|
|
34
|
+
// row by id, not by storageKey.
|
|
35
|
+
export type OwnedFileRef = {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly storageKey: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export async function filterOwnedFileRefs(
|
|
41
|
+
db: DbRunner,
|
|
42
|
+
tenantId: TenantId,
|
|
43
|
+
ownerId: string,
|
|
44
|
+
candidateKeys: readonly string[],
|
|
45
|
+
draftInsertedAt: Temporal.Instant,
|
|
46
|
+
): Promise<readonly OwnedFileRef[]> {
|
|
28
47
|
if (candidateKeys.length === 0) return [];
|
|
29
48
|
const rows = (await asRawClient(db).unsafe(
|
|
30
|
-
`SELECT "storage_key" FROM "file_refs"
|
|
49
|
+
`SELECT "id", "storage_key" FROM "file_refs"
|
|
31
50
|
WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
|
|
32
51
|
AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
|
|
33
52
|
AND "inserted_at" > $4::timestamptz`,
|
|
34
53
|
[tenantId, ownerId, candidateKeys, draftInsertedAt.toString()],
|
|
35
|
-
)) as readonly { storage_key: string }[];
|
|
36
|
-
return rows.map((row) => row.storage_key);
|
|
54
|
+
)) as readonly { id: string; storage_key: string }[];
|
|
55
|
+
return rows.map((row) => ({ id: row.id, storageKey: row.storage_key }));
|
|
37
56
|
}
|
|
@@ -142,8 +142,16 @@ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
|
|
|
142
142
|
db,
|
|
143
143
|
)
|
|
144
144
|
: undefined;
|
|
145
|
-
const
|
|
146
|
-
|
|
145
|
+
const isValidRetention = typeof resolved === "number" && resolved >= 1;
|
|
146
|
+
// configResolver missing is the legitimate no-config case (r.optionalRequires,
|
|
147
|
+
// see feature.ts) — nothing to warn about there. A resolver that IS wired but
|
|
148
|
+
// returns something unusable means an admin set an invalid value.
|
|
149
|
+
if (ctx.configResolver && !isValidRetention) {
|
|
150
|
+
ctx.log?.warn?.(
|
|
151
|
+
`[form-draft:cleanup] configResolver returned invalid retention-days value=${String(resolved)} — falling back to default=${FORM_DRAFT_DEFAULT_RETENTION_DAYS}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const retentionDays = isValidRetention ? resolved : FORM_DRAFT_DEFAULT_RETENTION_DAYS;
|
|
147
155
|
|
|
148
156
|
const fileProviderResolver = ctx._fileProviderResolver;
|
|
149
157
|
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
2
4
|
import { FORM_DRAFT_ACCESS } from "../constants";
|
|
3
|
-
import {
|
|
5
|
+
import { filterOwnedFileRefs } from "../db/queries/owned-file-refs";
|
|
4
6
|
import { formDraftExecutor } from "../executor";
|
|
5
7
|
import { lookupDraft } from "../lookup";
|
|
6
8
|
import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
|
|
7
9
|
import { discardDraftPayloadSchema } from "../schemas";
|
|
8
10
|
|
|
11
|
+
// Same construction as file-routes.ts/user-data-rights-defaults' fileRef
|
|
12
|
+
// hook — self-contained (table + entity), no registry needed. `.forget()`
|
|
13
|
+
// hard-deletes the file_refs row via a `fileRef.forgotten` event (rebuild-
|
|
14
|
+
// safe), unlike a raw storage-provider delete which never touches the row
|
|
15
|
+
// at all (see the ES-bypass this replaces below).
|
|
16
|
+
const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
17
|
+
entityName: "fileRef",
|
|
18
|
+
});
|
|
19
|
+
|
|
9
20
|
// discard — the caller's own draft only. Ownership is enforced by the
|
|
10
21
|
// lookup predicate (tenantId + ownerId + draftKey), not by a separate
|
|
11
22
|
// permission check: a foreign user's discard call for someone else's
|
|
@@ -42,14 +53,28 @@ export const discardDraftWrite = defineWriteHandler({
|
|
|
42
53
|
// releasable — `values` is free-form JSON the caller controls, so an
|
|
43
54
|
// unverified key could target someone else's file (see
|
|
44
55
|
// db/queries/owned-file-refs.ts).
|
|
45
|
-
const
|
|
56
|
+
const ownedRefs = await filterOwnedFileRefs(
|
|
46
57
|
ctx.db.raw,
|
|
47
58
|
event.user.tenantId,
|
|
48
59
|
ownerId,
|
|
49
60
|
candidateKeys,
|
|
50
61
|
existing.insertedAt,
|
|
51
62
|
);
|
|
52
|
-
|
|
63
|
+
const refsByStorageKey = new Map(ownedRefs.map((ref) => [ref.storageKey, ref]));
|
|
64
|
+
await releaseDraftFileRefs(
|
|
65
|
+
ownedRefs.map((ref) => ref.storageKey),
|
|
66
|
+
async (key) => {
|
|
67
|
+
// Hard-erase via the executor first (fileRef.forgotten — row +
|
|
68
|
+
// event, rebuild-safe), then the binary. A raw provider.delete()
|
|
69
|
+
// alone left the file_refs row behind forever (kumiko-framework
|
|
70
|
+
// review #1922): no event, no projection update, the row just
|
|
71
|
+
// pointed at a storage key that no longer existed.
|
|
72
|
+
const ref = refsByStorageKey.get(key);
|
|
73
|
+
if (ref !== undefined) await fileRefExecutor.forget({ id: ref.id }, event.user, ctx.db);
|
|
74
|
+
await files.ref(key).delete();
|
|
75
|
+
},
|
|
76
|
+
ctx.log,
|
|
77
|
+
);
|
|
53
78
|
}
|
|
54
79
|
return { isSuccess: true as const, data: { discarded: true } };
|
|
55
80
|
},
|