@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.
- package/package.json +7 -7
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
- package/src/auth-email-password/feature.ts +9 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
- package/src/auth-email-password/handlers/login.write.ts +10 -5
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
- package/src/auth-mfa/feature.ts +3 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
- package/src/compliance-profiles/feature.ts +18 -5
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/compliance-profiles/index.ts +1 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
- package/src/delivery/feature.ts +13 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/index.ts +1 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
- package/src/derivatives-sharp/render.ts +55 -15
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
- package/src/file-derivatives/feature.ts +12 -13
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
- package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
- package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
- package/src/form-draft/constants.ts +13 -0
- package/src/form-draft/db/queries/cleanup.ts +4 -1
- package/src/form-draft/db/queries/draft-count.ts +20 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
- package/src/form-draft/handlers/cleanup.job.ts +6 -0
- package/src/form-draft/handlers/discard.write.ts +6 -0
- package/src/form-draft/handlers/save.write.ts +15 -2
- package/src/form-draft/schemas.ts +16 -2
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
- package/src/personal-access-tokens/feature.ts +4 -1
- package/src/personal-access-tokens/roles.ts +11 -3
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
- package/src/sessions/feature.ts +18 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
- package/src/sessions/index.ts +1 -1
- package/src/sessions/session-callbacks.ts +7 -1
- package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
- package/src/tenant/handlers/remove-member.write.ts +23 -1
- package/src/tenant/handlers/update-member-roles.write.ts +23 -1
- package/src/tenant/handlers/update.write.ts +35 -2
- package/src/tenant/invitation-table.ts +8 -1
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
- package/src/user-data-rights/feature.ts +10 -7
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
package/src/delivery/jobs.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
9
|
import type {
|
|
10
|
-
|
|
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
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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>(
|
|
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>(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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: [
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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");
|