@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
  3. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
  4. package/src/auth-email-password/feature.ts +9 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
  6. package/src/auth-email-password/handlers/login.write.ts +10 -5
  7. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
  8. package/src/auth-mfa/feature.ts +3 -0
  9. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  10. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
  11. package/src/compliance-profiles/feature.ts +18 -5
  12. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  13. package/src/compliance-profiles/index.ts +1 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  15. package/src/config/__tests__/require-system-db.test.ts +46 -0
  16. package/src/config/feature.ts +28 -0
  17. package/src/config/handlers/cascade.query.ts +2 -2
  18. package/src/config/handlers/readiness.query.ts +19 -6
  19. package/src/config/handlers/reencrypt.job.ts +79 -21
  20. package/src/config/handlers/values.query.ts +2 -2
  21. package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
  22. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  23. package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
  24. package/src/delivery/feature.ts +13 -3
  25. package/src/delivery/handlers/log.query.ts +10 -4
  26. package/src/delivery/handlers/preferences.query.ts +14 -1
  27. package/src/delivery/handlers/set-preference.write.ts +12 -1
  28. package/src/delivery/index.ts +1 -1
  29. package/src/delivery/jobs.ts +20 -10
  30. package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
  31. package/src/derivatives-sharp/render.ts +55 -15
  32. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  33. package/src/feature-toggles/constants.ts +6 -0
  34. package/src/feature-toggles/handlers/list.query.ts +12 -1
  35. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  36. package/src/feature-toggles/handlers/set.write.ts +18 -3
  37. package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
  38. package/src/file-derivatives/feature.ts +12 -13
  39. package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
  40. package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
  41. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
  42. package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
  43. package/src/form-draft/constants.ts +13 -0
  44. package/src/form-draft/db/queries/cleanup.ts +4 -1
  45. package/src/form-draft/db/queries/draft-count.ts +20 -0
  46. package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
  47. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
  48. package/src/form-draft/handlers/cleanup.job.ts +6 -0
  49. package/src/form-draft/handlers/discard.write.ts +6 -0
  50. package/src/form-draft/handlers/save.write.ts +15 -2
  51. package/src/form-draft/schemas.ts +16 -2
  52. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  53. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  54. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
  55. package/src/jobs/handlers/detail.query.ts +7 -1
  56. package/src/jobs/handlers/list.query.ts +8 -1
  57. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  58. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  59. package/src/jobs/handlers/retry.write.ts +7 -1
  60. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
  61. package/src/personal-access-tokens/feature.ts +4 -1
  62. package/src/personal-access-tokens/roles.ts +11 -3
  63. package/src/readiness/handlers/status.query.ts +2 -1
  64. package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
  65. package/src/sessions/feature.ts +18 -10
  66. package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
  67. package/src/sessions/index.ts +1 -1
  68. package/src/sessions/session-callbacks.ts +7 -1
  69. package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
  70. package/src/tenant/handlers/remove-member.write.ts +23 -1
  71. package/src/tenant/handlers/update-member-roles.write.ts +23 -1
  72. package/src/tenant/handlers/update.write.ts +35 -2
  73. package/src/tenant/invitation-table.ts +8 -1
  74. package/src/user/__tests__/user.integration.test.ts +21 -0
  75. package/src/user/handlers/create.write.ts +10 -3
  76. package/src/user/handlers/find-for-auth.query.ts +9 -1
  77. package/src/user/handlers/me.query.ts +10 -1
  78. package/src/user/handlers/update.write.ts +12 -2
  79. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
  80. package/src/user-data-rights/feature.ts +10 -7
  81. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
@@ -7,11 +7,12 @@
7
7
 
8
8
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
9
  import type {
10
- AppContext,
10
+ JobContext,
11
11
  JobHandlerFn,
12
12
  Registry,
13
13
  TenantId,
14
14
  } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
15
16
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
16
17
  import { z } from "zod";
17
18
  import { appendAttemptEvent } from "./attempt-log";
@@ -44,13 +45,22 @@ const sendJobPayloadSchema = renderJobPayloadSchema.extend({
44
45
 
45
46
  type RenderJobPayload = z.infer<typeof renderJobPayloadSchema>;
46
47
 
47
- function requireDeps(ctx: AppContext): { db: DbConnection; registry: Registry } {
48
+ function requireTenantScopedDeps(
49
+ ctx: JobContext,
50
+ tenantId: TenantId,
51
+ ): { db: DbConnection; registry: Registry } {
48
52
  const registry = ctx.registry;
49
53
  if (!registry) throw new Error("delivery job: missing registry in job context");
50
- // Job context provides the raw system connection (not tenant-scoped); the
51
- // append + projection layer scopes per event payload.
52
- const db = ctx.db as DbConnection; // @cast-boundary engine-bridge — job ctx db is the system connection
53
- if (!db) throw new Error("delivery job: missing db in job context");
54
+ // delivery is r.systemScope()'d (feature.ts), so JobContext.systemDb is
55
+ // guaranteed here. assertTenantMatch(tenantId).raw hands appendAttemptEvent/
56
+ // buildChannelContext the underlying DbConnection — safe because both
57
+ // filter by the same tenantId themselves (see JobContext.systemDb doc).
58
+ if (!ctx.systemDb) {
59
+ throw new InternalError({
60
+ message: "delivery job: ctx.systemDb missing on a system-scoped job",
61
+ });
62
+ }
63
+ const db = ctx.systemDb.assertTenantMatch(tenantId).raw as DbConnection; // @cast-boundary db-operator — TenantDb.raw is DbRunner, jobs never run inside a DbTx
54
64
  return { db, registry };
55
65
  }
56
66
 
@@ -96,9 +106,9 @@ function messageOf(err: unknown): string {
96
106
  // the send step is never reached, so a stuck render can't half-send.
97
107
  export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
98
108
  const p = renderJobPayloadSchema.parse(payload);
99
- const { db, registry } = requireDeps(ctx);
100
- const channel = resolveChannel(registry, p.channelName);
101
109
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
110
+ const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
111
+ const channel = resolveChannel(registry, p.channelName);
102
112
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
103
113
 
104
114
  try {
@@ -128,9 +138,9 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
128
138
  // the same already-rendered HTML — no re-render needed.
129
139
  export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
130
140
  const p = sendJobPayloadSchema.parse(payload);
131
- const { db, registry } = requireDeps(ctx);
132
- const channel = resolveChannel(registry, p.channelName);
133
141
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
142
+ const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
143
+ const channel = resolveChannel(registry, p.channelName);
134
144
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
135
145
 
136
146
  try {
@@ -225,6 +225,54 @@ describe("renderImage — validation", () => {
225
225
  await expect(renderImage(input, {}, "constructor")).rejects.toThrow(/no output encoder/);
226
226
  });
227
227
 
228
+ test("blurRegions beyond the cap throws instead of re-decoding the image once per region", async () => {
229
+ const input = await jpegFixture(50, 50);
230
+ const tooManyRegions = Array.from({ length: 65 }, () => ({
231
+ x: 0.1,
232
+ y: 0.1,
233
+ width: 0.1,
234
+ height: 0.1,
235
+ }));
236
+ await expect(renderImage(input, { blurRegions: tooManyRegions }, "image/jpeg")).rejects.toThrow(
237
+ /blurRegions/,
238
+ );
239
+ });
240
+
241
+ test("64 blurRegions (at the cap) is accepted", async () => {
242
+ const input = await jpegFixture(200, 200);
243
+ const regions = Array.from({ length: 64 }, (_, i) => ({
244
+ x: 0.01 * i,
245
+ y: 0.01 * i,
246
+ width: 0.01,
247
+ height: 0.01,
248
+ }));
249
+ await expect(renderImage(input, { blurRegions: regions }, "image/jpeg")).resolves.toBeTruthy();
250
+ });
251
+
252
+ test("size.width/size.height beyond MAX_OUTPUT_EDGE throws instead of allocating an oversized buffer", async () => {
253
+ const input = await jpegFixture(50, 50);
254
+ await expect(
255
+ renderImage(input, { size: { width: 50000, height: 50000 } }, "image/jpeg"),
256
+ ).rejects.toThrow(/size\.width/);
257
+ });
258
+
259
+ test("maxEdge beyond MAX_OUTPUT_EDGE throws", async () => {
260
+ const input = await jpegFixture(50, 50);
261
+ await expect(renderImage(input, { maxEdge: 50000 }, "image/jpeg")).rejects.toThrow(/maxEdge/);
262
+ });
263
+
264
+ test("maxEdge: 0 is rejected, not silently ignored by the resize branch's truthiness check", async () => {
265
+ const input = await jpegFixture(50, 50);
266
+ await expect(renderImage(input, { maxEdge: 0 }, "image/jpeg")).rejects.toThrow(/maxEdge/);
267
+ });
268
+
269
+ test("a non-integer size.width throws", async () => {
270
+ const input = await jpegFixture(50, 50);
271
+ await expect(
272
+ renderImage(input, { size: { width: 100.5, height: 100 } }, "image/jpeg"),
273
+ ).rejects.toThrow(/size\.width/);
274
+ });
275
+
228
276
  test("image/png ignores spec.quality instead of switching into lossy palette quantization", async () => {
229
277
  const input = await pngFixture(50, 50);
230
278
  const withQuality = await renderImage(input, { quality: 10 }, "image/png");
@@ -13,6 +13,23 @@ import sharp from "sharp";
13
13
 
14
14
  const MIN_SHARP_SIGMA = 0.3;
15
15
  const MAX_SHARP_SIGMA = 1000;
16
+ // Above this, a decoded pixel buffer runs into hundreds of MB before any
17
+ // resize even happens — sharp's own default (~268MP) is still large enough
18
+ // to be an OOM primitive for a single request.
19
+ const MAX_INPUT_PIXELS = 100_000_000;
20
+ // Longest output edge a caller may request via `size`/`maxEdge`. Without a
21
+ // cap, `{ maxEdge: 50000 }` allocates a multi-gigabyte buffer per encoder
22
+ // step before any client sees a byte back.
23
+ const MAX_OUTPUT_EDGE = 8192;
24
+ // applyBlurRegions decodes the full image fresh for every region — an
25
+ // unbounded region list is a CPU/RAM-multiplying DoS knob independent of
26
+ // output size.
27
+ const MAX_BLUR_REGIONS = 64;
28
+ // sequentialRead trades random pixel access for lower peak memory on
29
+ // streamed formats — applied at every decode entry point below since none
30
+ // of them need non-sequential access to the *original* bytes (extract runs
31
+ // against a single already-buffered region per call).
32
+ const SHARP_INPUT_OPTIONS = { limitInputPixels: MAX_INPUT_PIXELS, sequentialRead: true } as const;
16
33
 
17
34
  function normalizeMimeType(mimeType: string): string {
18
35
  return mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
@@ -58,7 +75,7 @@ async function applyBlurRegions(
58
75
  // input) — good enough until a caller needs to tune blur strength itself.
59
76
  const sigma = clampSigma(Math.max(4, Math.min(rectWidth, rectHeight) / 6));
60
77
 
61
- const patch = await sharp(data)
78
+ const patch = await sharp(data, SHARP_INPUT_OPTIONS)
62
79
  .extract({ left, top, width: rectWidth, height: rectHeight })
63
80
  .blur(sigma)
64
81
  .toBuffer();
@@ -66,7 +83,7 @@ async function applyBlurRegions(
66
83
  }
67
84
 
68
85
  if (overlays.length === 0) return data;
69
- return sharp(data).composite(overlays).toBuffer();
86
+ return sharp(data, SHARP_INPUT_OPTIONS).composite(overlays).toBuffer();
70
87
  }
71
88
 
72
89
  function buildResizeOptions(width: number, height: number, fit: VariantFit): ResizeOptions {
@@ -136,6 +153,36 @@ function applyEncoder(pipeline: Sharp, spec: VariantSpec, sourceMimeType: string
136
153
  return encode(pipeline, quality);
137
154
  }
138
155
 
156
+ // Input-validation for the DoS caps above — kept out of renderImage so the
157
+ // complexity hotspot stays under the AST-guard budget.
158
+ function assertRenderSpecBounds(spec: VariantSpec): void {
159
+ if (spec.quality !== undefined && (spec.quality < 1 || spec.quality > 100)) {
160
+ throw new Error(`derivatives-sharp: quality ${spec.quality} is out of range (1–100).`);
161
+ }
162
+ if (spec.blur !== undefined && (spec.blur < MIN_SHARP_SIGMA || spec.blur > MAX_SHARP_SIGMA)) {
163
+ throw new Error(
164
+ `derivatives-sharp: blur sigma ${spec.blur} is out of sharp's supported range (${MIN_SHARP_SIGMA}–${MAX_SHARP_SIGMA}).`,
165
+ );
166
+ }
167
+ if (spec.blurRegions && spec.blurRegions.length > MAX_BLUR_REGIONS) {
168
+ throw new Error(
169
+ `derivatives-sharp: blurRegions has ${spec.blurRegions.length} entries, exceeding the limit of ${MAX_BLUR_REGIONS} — each region re-decodes the full image.`,
170
+ );
171
+ }
172
+ for (const [dimension, value] of [
173
+ ["size.width", spec.size?.width],
174
+ ["size.height", spec.size?.height],
175
+ ["maxEdge", spec.maxEdge],
176
+ ] as const) {
177
+ if (value === undefined) continue;
178
+ if (!Number.isInteger(value) || value < 1 || value > MAX_OUTPUT_EDGE) {
179
+ throw new Error(
180
+ `derivatives-sharp: ${dimension} ${value} is out of range — must be an integer between 1 and ${MAX_OUTPUT_EDGE}.`,
181
+ );
182
+ }
183
+ }
184
+ }
185
+
139
186
  export const renderImage: DerivativeRendererPlugin["render"] = async (
140
187
  input,
141
188
  spec,
@@ -146,38 +193,31 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
146
193
  // sourceMimeType is a client-declared upload label, not sniffed — decide
147
194
  // the SVG rejection from the actual bytes so a mislabeled upload can't
148
195
  // reach libvips as SVG.
149
- const sniffed = await sharp(input).metadata();
196
+ const sniffed = await sharp(input, SHARP_INPUT_OPTIONS).metadata();
150
197
  if (sniffed.format === "svg") {
151
198
  throw new Error(
152
199
  "derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
153
200
  );
154
201
  }
155
202
 
156
- if (spec.quality !== undefined && (spec.quality < 1 || spec.quality > 100)) {
157
- throw new Error(`derivatives-sharp: quality ${spec.quality} is out of range (1–100).`);
158
- }
159
- if (spec.blur !== undefined && (spec.blur < MIN_SHARP_SIGMA || spec.blur > MAX_SHARP_SIGMA)) {
160
- throw new Error(
161
- `derivatives-sharp: blur sigma ${spec.blur} is out of sharp's supported range (${MIN_SHARP_SIGMA}–${MAX_SHARP_SIGMA}).`,
162
- );
163
- }
203
+ assertRenderSpecBounds(spec);
164
204
 
165
205
  // rotate() with no argument applies the EXIF orientation and normalizes it
166
206
  // away. sharp drops all other metadata by default — we never call
167
207
  // .withMetadata()/.keepExif(), which is exactly what strips GPS/EXIF from
168
208
  // the output.
169
- let pipeline = sharp(input).rotate();
209
+ let pipeline = sharp(input, SHARP_INPUT_OPTIONS).rotate();
170
210
 
171
211
  if (spec.blurRegions && spec.blurRegions.length > 0) {
172
212
  const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
173
213
  const blurred = await applyBlurRegions(data, info.width, info.height, spec.blurRegions);
174
- pipeline = sharp(blurred);
214
+ pipeline = sharp(blurred, SHARP_INPUT_OPTIONS);
175
215
  }
176
216
 
177
217
  const fit = spec.fit ?? "inside";
178
218
  if (spec.size) {
179
219
  pipeline = pipeline.resize(buildResizeOptions(spec.size.width, spec.size.height, fit));
180
- } else if (spec.maxEdge) {
220
+ } else if (spec.maxEdge !== undefined) {
181
221
  pipeline = pipeline.resize(buildResizeOptions(spec.maxEdge, spec.maxEdge, fit));
182
222
  }
183
223
 
@@ -194,7 +234,7 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
194
234
  export async function imageMetadata(
195
235
  input: Uint8Array,
196
236
  ): Promise<{ width: number; height: number; format: string }> {
197
- const metadata = await sharp(input).metadata();
237
+ const metadata = await sharp(input, SHARP_INPUT_OPTIONS).metadata();
198
238
  const width = metadata.autoOrient.width;
199
239
  const height = metadata.autoOrient.height;
200
240
  if (!Number.isFinite(width) || !Number.isFinite(height) || !metadata.format) {
@@ -0,0 +1,23 @@
1
+ // framework#2076: set.write / registered.query / list.query all read/write
2
+ // globalFeatureStateTable via ctx.systemDb.acknowledgeCrossTenant(...) instead
3
+ // of ctx.db directly. That only resolves correctly as long as the
4
+ // feature-toggles feature stays r.systemScope() (feature.ts) — buildHandlerContext
5
+ // only populates ctx.systemDb for system-scoped handlers (dispatch-shared.ts).
6
+ // This test pins that coupling so a future refactor that drops r.systemScope()
7
+ // fails loudly here instead of surfacing as "undefined.acknowledgeCrossTenant"
8
+ // deep inside a handler.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { FeatureToggleHandlers, FeatureToggleQueries } from "../constants";
13
+ import { createFeatureTogglesFeature } from "../feature";
14
+
15
+ describe("feature-toggles handlers stay r.systemScope()-coupled", () => {
16
+ const registry = createRegistry([createFeatureTogglesFeature()]);
17
+
18
+ test("set/list/registered are all system-scoped", () => {
19
+ expect(registry.isHandlerSystemScoped(FeatureToggleHandlers.set)).toBe(true);
20
+ expect(registry.isHandlerSystemScoped(FeatureToggleQueries.list)).toBe(true);
21
+ expect(registry.isHandlerSystemScoped(FeatureToggleQueries.registered)).toBe(true);
22
+ });
23
+ });
@@ -10,6 +10,12 @@ export const FEATURE_TOGGLE_SET_EVENT_NAME = "feature-toggles:event:toggle-set";
10
10
  // "feature-toggles" in its aggregate_type column.
11
11
  export const FEATURE_TOGGLE_AGGREGATE_TYPE = "feature-toggles";
12
12
 
13
+ // Shared acknowledgeCrossTenant() reason across all three handlers that read
14
+ // or write globalFeatureStateTable — one string so the three call sites
15
+ // can't drift (framework#2076).
16
+ export const FEATURE_TOGGLE_CROSS_TENANT_REASON =
17
+ "global feature-toggle state, no tenant scope by design";
18
+
13
19
  // Error reasons surfaced from feature-toggle handlers. Scoped to the
14
20
  // feature's namespace per the framework's reason-convention.
15
21
  export const FeatureToggleErrors = {
@@ -1,6 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
4
5
  import { globalFeatureStateTable } from "../global-feature-state-table";
5
6
 
6
7
  // List every row in the global_feature_state table — i.e. every feature
@@ -12,6 +13,16 @@ export const listQuery = defineQueryHandler({
12
13
  schema: z.object({}),
13
14
  access: { roles: ["SystemAdmin"] },
14
15
  handler: async (_event, ctx) => {
16
+ // ctx.systemDb is populated for r.systemScope() handlers only (feature.ts
17
+ // declares it) — guarded instead of asserted, see set.write.ts Guard 0.5.
18
+ if (!ctx.systemDb) {
19
+ throw new Error(
20
+ "[feature-toggles] list-query requires ctx.systemDb — the feature-toggles " +
21
+ "feature must stay r.systemScope() (see feature.ts).",
22
+ );
23
+ }
24
+ const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
25
+
15
26
  type Row = {
16
27
  featureName: string;
17
28
  enabled: boolean;
@@ -19,7 +30,7 @@ export const listQuery = defineQueryHandler({
19
30
  updatedAt: Temporal.Instant;
20
31
  updatedBy: string;
21
32
  };
22
- const rows = await selectMany<Row>(ctx.db.raw, globalFeatureStateTable);
33
+ const rows = await selectMany<Row>(db.raw, globalFeatureStateTable);
23
34
  return {
24
35
  items: rows.map((r) => ({
25
36
  featureName: r.featureName,
@@ -1,6 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
4
5
  import { globalFeatureStateTable } from "../global-feature-state-table";
5
6
 
6
7
  // Inventory of every registered feature, annotated with toggle metadata
@@ -16,8 +17,18 @@ export const registeredQuery = defineQueryHandler({
16
17
  schema: z.object({}),
17
18
  access: { roles: ["SystemAdmin"] },
18
19
  handler: async (_event, ctx) => {
20
+ // ctx.systemDb is populated for r.systemScope() handlers only (feature.ts
21
+ // declares it) — guarded instead of asserted, see set.write.ts Guard 0.5.
22
+ if (!ctx.systemDb) {
23
+ throw new Error(
24
+ "[feature-toggles] registered-query requires ctx.systemDb — the feature-toggles " +
25
+ "feature must stay r.systemScope() (see feature.ts).",
26
+ );
27
+ }
28
+ const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
29
+
19
30
  type OverrideRow = { featureName: string; enabled: boolean };
20
- const overrideRows = await selectMany<OverrideRow>(ctx.db.raw, globalFeatureStateTable);
31
+ const overrideRows = await selectMany<OverrideRow>(db.raw, globalFeatureStateTable);
21
32
  const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
22
33
 
23
34
  // SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
@@ -9,6 +9,7 @@ import { Temporal } from "temporal-polyfill";
9
9
  import { z } from "zod";
10
10
  import {
11
11
  FEATURE_TOGGLE_AGGREGATE_TYPE,
12
+ FEATURE_TOGGLE_CROSS_TENANT_REASON,
12
13
  FEATURE_TOGGLE_SET_EVENT_NAME,
13
14
  FeatureToggleErrors,
14
15
  } from "../constants";
@@ -53,6 +54,20 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
53
54
  );
54
55
  }
55
56
 
57
+ // Guard 0.5: feature-toggles is r.systemScope() (feature.ts), so
58
+ // ctx.systemDb is always populated by buildHandlerContext for this
59
+ // handler — the `?` on the type is a HandlerContext-wide optionality,
60
+ // not a real runtime possibility here. Guard anyway instead of a
61
+ // non-null assertion: an app that ever un-declares r.systemScope()
62
+ // gets an actionable error instead of a silent `undefined.acknowledgeCrossTenant`.
63
+ if (!ctx.systemDb) {
64
+ throw new Error(
65
+ "[feature-toggles] set-handler requires ctx.systemDb — the feature-toggles " +
66
+ "feature must stay r.systemScope() (see feature.ts).",
67
+ );
68
+ }
69
+ const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
70
+
56
71
  // Guard 1: featureName must be a registered feature. Otherwise we'd
57
72
  // pile up orphan rows from typos that the gate would silently apply
58
73
  // (if someone ever added a feature with that name later).
@@ -90,7 +105,7 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
90
105
  updatedBy: string;
91
106
  };
92
107
  const [existing] = await selectMany<StateRow>(
93
- ctx.db,
108
+ db,
94
109
  globalFeatureStateTable,
95
110
  { featureName },
96
111
  { limit: 1 },
@@ -100,7 +115,7 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
100
115
 
101
116
  if (!existing) {
102
117
  // First-time override: insert.
103
- await insertOne(ctx.db, globalFeatureStateTable, {
118
+ await insertOne(db, globalFeatureStateTable, {
104
119
  featureName,
105
120
  enabled,
106
121
  version: 1,
@@ -111,7 +126,7 @@ export function createSetWriteHandler(getRuntime: (() => GlobalFeatureToggleRunt
111
126
  // Upsert with optimistic lock. Two operators flipping the same
112
127
  // toggle simultaneously is rare but possible — the version-WHERE
113
128
  // ensures only one wins; the loser sees VersionConflictError.
114
- const updated = await ctx.db.updateMany(
129
+ const updated = await db.updateMany(
115
130
  globalFeatureStateTable,
116
131
  {
117
132
  enabled,
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { describe, expect, test } from "bun:test";
13
13
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
14
15
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
15
16
  import { createConfigFeature } from "../../config";
16
17
  import { fileFoundationFeature } from "../../file-foundation";
@@ -20,7 +21,12 @@ import { PUBLIC_VARIANT_QN } from "../handlers/public-variant.query";
20
21
  describe("file-derivatives :: publicVariant query gating without resolveApexTenant", () => {
21
22
  test("PUBLIC_VARIANT_QN is not dispatchable via the generic /api/query path — 404, same as any unknown query", async () => {
22
23
  const stack: TestStack = await setupTestStack({
23
- features: [createConfigFeature(), fileFoundationFeature, createFileDerivativesFeature()],
24
+ features: [
25
+ createConfigFeature(),
26
+ fileFoundationFeature,
27
+ createFilesFeature(),
28
+ createFileDerivativesFeature(),
29
+ ],
24
30
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
25
31
  });
26
32
 
@@ -12,6 +12,7 @@ import {
12
12
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
13
13
  EXT_DERIVATIVE_RENDERER,
14
14
  type FeatureDefinition,
15
+ isUuid,
15
16
  type TenantId,
16
17
  } from "@cosmicdrift/kumiko-framework/engine";
17
18
  import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
@@ -19,12 +20,6 @@ import { PUBLIC_VARIANT_QN, publicVariantQuery } from "./handlers/public-variant
19
20
 
20
21
  const FEATURE_NAME = "file-derivatives";
21
22
 
22
- // fileRefsTable.id is a UUID column — a malformed id must fail here, not at
23
- // the DB. Without this, an anonymous, internet-facing caller can throw a
24
- // Postgres 22P02 on every request (Bun.SQL pools the failed connection),
25
- // a DoS primitive with no auth required.
26
- const FILE_REF_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27
-
28
23
  // Syntactic only, no registry/name-list lookup: a valid preset name plus a
29
24
  // random UUID reaches the same DB read anyway, so an allow-list of known
30
25
  // names defends nothing a determined caller can't route around — it only
@@ -88,6 +83,11 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
88
83
  // Needs a storage provider to read the original + write the variant —
89
84
  // without file-foundation there's nothing to derive from or write to.
90
85
  r.requires("file-foundation");
86
+ // variant() reads fileRefsTable, which "files" (not file-foundation)
87
+ // owns — without this, mounting file-derivatives + file-foundation
88
+ // alone boots fine but fails at request-time with a SQL error the first
89
+ // time variant() runs.
90
+ r.requires("files");
91
91
 
92
92
  r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
93
93
  onRegister: () => {
@@ -129,13 +129,12 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
129
129
  const fileRefId = c.req.param("fileRefId");
130
130
  const variant = c.req.param("variant");
131
131
  // 404, not 400 — a syntactically bad name and "doesn't exist" must
132
- // answer identically, no name-oracle.
133
- if (
134
- !fileRefId ||
135
- !variant ||
136
- !VARIANT_NAME_RE.test(variant) ||
137
- !FILE_REF_ID_RE.test(fileRefId)
138
- ) {
132
+ // answer identically, no name-oracle. fileRefsTable.id is a UUID
133
+ // column — a malformed id must fail here, not at the DB. Without
134
+ // this, an anonymous, internet-facing caller can throw a Postgres
135
+ // 22P02 on every request (Bun.SQL pools the failed connection), a
136
+ // DoS primitive with no auth required.
137
+ if (!fileRefId || !variant || !VARIANT_NAME_RE.test(variant) || !isUuid(fileRefId)) {
139
138
  return c.text("not found", 404);
140
139
  }
141
140
 
@@ -23,6 +23,7 @@ import {
23
23
  defineQueryHandler,
24
24
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
25
25
  type HandlerContext,
26
+ isUuid,
26
27
  type TenantId,
27
28
  } from "@cosmicdrift/kumiko-framework/engine";
28
29
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
@@ -69,7 +70,10 @@ export const PUBLIC_VARIANT_QN = "file-derivatives:query:public-variant";
69
70
  export const publicVariantQuery = defineQueryHandler({
70
71
  name: "public-variant",
71
72
  schema: z.object({
72
- fileRefId: z.string().uuid(),
73
+ // Loose, version-agnostic UUID shape (same as isUuid/TENANT_ID_REGEX in
74
+ // packages/types) — not zod's `.uuid()`, which is stricter than this
75
+ // repo's convention and would reject valid v7/nil ids the DB accepts.
76
+ fileRefId: z.string().refine(isUuid, "invalid fileRefId"),
73
77
  variant: z.string().min(1).max(64),
74
78
  }),
75
79
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
@@ -320,4 +320,26 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
320
320
  // The live domain entity this draft was editing still references `key`.
321
321
  expect(providerA.keys()).toContain(key);
322
322
  });
323
+
324
+ test("releases a storageKey whose file_refs row predates the draft when the draftKey is create-mode (:new:)", async () => {
325
+ // Create-mode mints draftId lazily on the first step-change — the very
326
+ // first save already carries the step-0 photo, so the file_refs row
327
+ // predates the draft row by construction. Unlike the edit-mode case
328
+ // above there is no pre-existing domain entity, so the file must still
329
+ // be releasable on cleanup, not leaked as an orphaned storage object.
330
+ const key = "tenant-a/vehicle/create-mode.jpg";
331
+ await providerA.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
332
+ await seedFileRef(key);
333
+ await backdateFileRef(key, 60);
334
+
335
+ await saveDraft("wizard:new:create-mode-stale", { photo: fileRefPointer(key) });
336
+ await backdate("wizard:new:create-mode-stale", 31);
337
+
338
+ await dispatchCleanup();
339
+
340
+ await waitFor(async () => {
341
+ expect(await draftExists("wizard:new:create-mode-stale")).toBe(false);
342
+ });
343
+ expect(providerA.keys()).not.toContain(key);
344
+ });
323
345
  });
@@ -146,6 +146,23 @@ describe("form-draft discard — FileRef release", () => {
146
146
  expect(provider.keys()).toContain(key);
147
147
  });
148
148
 
149
+ test("discarding a create-mode draft (draftKey contains :new:) releases a storageKey whose file_refs row predates the draft row", async () => {
150
+ // Create-mode mints draftId lazily on the first step-change (see
151
+ // form-draft/lookup.ts) — the very first save already carries the
152
+ // step-0 photo, so the file_refs row for that upload is always older
153
+ // than (or equal to) the draft row's own insertedAt. Unlike edit-mode,
154
+ // there is no pre-existing domain entity here — every FileRef the
155
+ // draft references is draft-owned and must remain releasable.
156
+ const key = "tenant/vehicle/photo/create-mode.jpg";
157
+ await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
158
+ await seedFileRef(key); // file_refs row inserted before the draft row below
159
+ await saveDraft("wizard:new:abc-123", { photo: fileRefPointer(key) });
160
+
161
+ await discardDraft("wizard:new:abc-123", true);
162
+
163
+ expect(provider.keys()).not.toContain(key);
164
+ });
165
+
149
166
  test("discarding without releaseFiles leaves the storage binary intact (successful-submit default)", async () => {
150
167
  const key = "tenant/vehicle/photo/kept.jpg";
151
168
  await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");