@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.195.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.195.0",
129
- "@cosmicdrift/kumiko-framework": "0.195.0",
130
- "@cosmicdrift/kumiko-headless": "0.195.0",
131
- "@cosmicdrift/kumiko-renderer": "0.195.0",
132
- "@cosmicdrift/kumiko-renderer-web": "0.195.0",
133
- "@cosmicdrift/kumiko-types": "0.195.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 route is NOT mounted — existing consumers that
48
- * only need `ctx.derivatives` are unaffected. */
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
- const queries = { publicVariant: r.queryHandler(publicVariantQuery) };
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 { TenantId } from "@cosmicdrift/kumiko-framework/engine";
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 retentionDays =
146
- typeof resolved === "number" && resolved >= 1 ? resolved : FORM_DRAFT_DEFAULT_RETENTION_DAYS;
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 { filterOwnedStorageKeys } from "../db/queries/owned-file-refs";
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 ownedKeys = await filterOwnedStorageKeys(
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
- await releaseDraftFileRefs(ownedKeys, (key) => files.ref(key).delete(), ctx.log);
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
  },
@@ -35,6 +35,7 @@ export {
35
35
  reverseTransactionHandler,
36
36
  } from "./handlers/reverse-transaction.write";
37
37
  export {
38
+ findReversedIds,
38
39
  type LedgerTxRow,
39
40
  mergeScheduleActuals,
40
41
  type ProjectedPeriod,