@cosmicdrift/kumiko-bundled-features 0.309.0 → 0.311.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +16 -12
- package/src/delivery/__tests__/delivery.integration.test.ts +21 -29
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +224 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +38 -4
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +40 -0
- package/src/file-derivatives/handlers/public-variant.query.ts +16 -7
- package/src/file-derivatives/index.ts +5 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +10 -12
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.311.0",
|
|
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>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.311.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.311.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.311.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.311.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.311.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.311.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.311.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.311.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type TestStack,
|
|
13
13
|
testTenantId,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
16
|
import * as z from "zod";
|
|
16
17
|
import { createConfigFeature } from "../../config";
|
|
17
18
|
import { createTenantFeature } from "../../tenant";
|
|
@@ -71,18 +72,21 @@ type AuditRow = {
|
|
|
71
72
|
type AuditResponse = { rows: AuditRow[]; nextBefore: string | null };
|
|
72
73
|
|
|
73
74
|
async function pollForEscapeHatchRow(): Promise<AuditRow> {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
let row: AuditRow | undefined;
|
|
76
|
+
await waitFor(
|
|
77
|
+
async () => {
|
|
78
|
+
const res = await stack.http.queryOk<AuditResponse>(
|
|
79
|
+
AuditQueries.list,
|
|
80
|
+
{ eventType: ESCAPE_HATCH_USED_EVENT },
|
|
81
|
+
adminOfSameTenant,
|
|
82
|
+
);
|
|
83
|
+
row = res.rows[0];
|
|
84
|
+
return row !== undefined;
|
|
85
|
+
},
|
|
86
|
+
{ delays: Array(40).fill(50) },
|
|
87
|
+
);
|
|
88
|
+
if (row === undefined) throw new Error("escape-hatch-used audit row did not appear within 2s");
|
|
89
|
+
return row;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
describe("createEscapeHatchAuditSink — persists audit:event:escape-hatch-used", () => {
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
testTenantId,
|
|
27
27
|
unsafePushTables,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
29
30
|
import * as z from "zod";
|
|
30
31
|
import { createChannelEmailFeature } from "../../channel-email/feature";
|
|
31
32
|
import { createInMemoryTransport, type EmailMessage } from "../../channel-email/types";
|
|
@@ -1502,21 +1503,6 @@ describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
|
|
|
1502
1503
|
// non-string `to` as a broadcast target ("tenant" in to).
|
|
1503
1504
|
const asyncRecipient = "7";
|
|
1504
1505
|
|
|
1505
|
-
async function waitFor(check: () => Promise<void>, timeoutMs = 10000): Promise<void> {
|
|
1506
|
-
const start = Date.now();
|
|
1507
|
-
let lastErr: unknown;
|
|
1508
|
-
for (;;) {
|
|
1509
|
-
try {
|
|
1510
|
-
await check();
|
|
1511
|
-
return;
|
|
1512
|
-
} catch (err) {
|
|
1513
|
-
lastErr = err;
|
|
1514
|
-
if (Date.now() - start > timeoutMs) throw lastErr;
|
|
1515
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
1506
|
function makeStubRunner(): {
|
|
1521
1507
|
runner: JobRunner;
|
|
1522
1508
|
dispatched: Array<{
|
|
@@ -1580,13 +1566,16 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1580
1566
|
|
|
1581
1567
|
// Email: queued → render job → send job → sent. Proves the framework
|
|
1582
1568
|
// jobRunner-into-job-ctx injection (render dispatches send) end-to-end.
|
|
1583
|
-
await waitFor(
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1569
|
+
await waitFor(
|
|
1570
|
+
async () => {
|
|
1571
|
+
const rows = await selectMany(db, deliveryAttemptsTable, {
|
|
1572
|
+
notificationType: "app:notify:async-e2e",
|
|
1573
|
+
channel: "email",
|
|
1574
|
+
});
|
|
1575
|
+
expect(rows.some((r) => r["status"] === "sent")).toBe(true);
|
|
1576
|
+
},
|
|
1577
|
+
{ delays: Array(40).fill(250) },
|
|
1578
|
+
);
|
|
1590
1579
|
const email = emailTransport.sent.find((m) => m.to === testEmail(asyncRecipient));
|
|
1591
1580
|
expect(email).toBeDefined();
|
|
1592
1581
|
expect(email?.subject).toBe("Async Subject");
|
|
@@ -1594,13 +1583,16 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1594
1583
|
expect(email?.html).toContain("<!DOCTYPE html>");
|
|
1595
1584
|
|
|
1596
1585
|
// Push: no render step → dispatched straight to delivery.send.
|
|
1597
|
-
await waitFor(
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1586
|
+
await waitFor(
|
|
1587
|
+
async () => {
|
|
1588
|
+
const rows = await selectMany(db, deliveryAttemptsTable, {
|
|
1589
|
+
notificationType: "app:notify:async-e2e",
|
|
1590
|
+
channel: "push",
|
|
1591
|
+
});
|
|
1592
|
+
expect(rows.some((r) => r["status"] === "sent")).toBe(true);
|
|
1593
|
+
},
|
|
1594
|
+
{ delays: Array(40).fill(250) },
|
|
1595
|
+
);
|
|
1604
1596
|
expect(
|
|
1605
1597
|
pushTransport.sent.find((m) => m.token === testPushToken(asyncRecipient)),
|
|
1606
1598
|
).toBeDefined();
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Proves kumiko-framework#3255: `publicTenantResolution: "fileRef"` lets a
|
|
2
|
+
// single shared platform host serve every tenant's public variants — the
|
|
3
|
+
// variant is read from the FileRef row's own tenant, not the host's, while
|
|
4
|
+
// resolveApexTenant still gates the host and the FileRef-tenant's own
|
|
5
|
+
// `isPublic` predicate remains the default-deny gate. Default ("host")
|
|
6
|
+
// mode is unchanged: a tenant B file requested through tenant A's host
|
|
7
|
+
// still 404s, identically to any other unknown FileRef.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
createEntity,
|
|
12
|
+
createImageField,
|
|
13
|
+
defineFeature,
|
|
14
|
+
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
15
|
+
EXT_DERIVATIVE_RENDERER,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import {
|
|
18
|
+
createFilesFeature,
|
|
19
|
+
createInMemoryFileProvider,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
testTenantId,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
+
import {
|
|
28
|
+
buildMultipartBody,
|
|
29
|
+
patchFileInstanceofForBunTest,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
31
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
32
|
+
import { createConfigFeature } from "../../config";
|
|
33
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
34
|
+
import { createFileDerivativesFeature } from "../feature";
|
|
35
|
+
import type { DerivativePublicPredicateArgs } from "../handlers/public-variant.query";
|
|
36
|
+
import { PUBLIC_VARIANT_BY_FILE_REF_QN } from "../handlers/public-variant-by-file-ref.query";
|
|
37
|
+
|
|
38
|
+
const VARIANT_BYTES = new Uint8Array([4, 2, 4, 2]);
|
|
39
|
+
const fakeRender: DerivativeRendererPlugin["render"] = async () => VARIANT_BYTES;
|
|
40
|
+
|
|
41
|
+
const TENANT_A = testTenantId(11);
|
|
42
|
+
const TENANT_B = testTenantId(12);
|
|
43
|
+
const HOST_A = "cross-tenant-a.example.com";
|
|
44
|
+
|
|
45
|
+
let receivedArgs: DerivativePublicPredicateArgs[] = [];
|
|
46
|
+
const gadgetEntity = createEntity({
|
|
47
|
+
table: "cross_tenant_public_variant_gadgets",
|
|
48
|
+
fields: {
|
|
49
|
+
img: createImageField({ variants: { thumb: { maxEdge: 160, format: "webp" } } }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const gadgetPredicateFeature = defineFeature("crosstenantpublicvarianttest", (r) => {
|
|
54
|
+
r.entity("gadget", gadgetEntity);
|
|
55
|
+
r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "gadget", {
|
|
56
|
+
isPublic: (args: DerivativePublicPredicateArgs) => {
|
|
57
|
+
receivedArgs.push(args);
|
|
58
|
+
return args.entityId === "public-1";
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", { render: fakeRender });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const userA = createTestUser({ id: 1, tenantId: TENANT_A, roles: ["Admin"] });
|
|
65
|
+
const userB = createTestUser({ id: 2, tenantId: TENANT_B, roles: ["Admin"] });
|
|
66
|
+
|
|
67
|
+
async function uploadImage(
|
|
68
|
+
stack: TestStack,
|
|
69
|
+
asUser: typeof userA,
|
|
70
|
+
entityId: string,
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
const token = await stack.jwt.sign(asUser);
|
|
73
|
+
const fd = new FormData();
|
|
74
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "img.jpg", { type: "image/jpeg" }));
|
|
75
|
+
fd.append("entityType", "gadget");
|
|
76
|
+
fd.append("entityId", entityId);
|
|
77
|
+
fd.append("fieldName", "img");
|
|
78
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
79
|
+
const res = await stack.app.request("/api/files", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
82
|
+
body,
|
|
83
|
+
});
|
|
84
|
+
expect(res.status).toBe(201);
|
|
85
|
+
const json = (await res.json()) as { id: string };
|
|
86
|
+
return json.id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("file-derivatives :: publicTenantResolution 'fileRef' — cross-tenant shared host", () => {
|
|
90
|
+
let stack: TestStack;
|
|
91
|
+
|
|
92
|
+
beforeAll(async () => {
|
|
93
|
+
patchFileInstanceofForBunTest();
|
|
94
|
+
stack = await setupTestStack({
|
|
95
|
+
features: [
|
|
96
|
+
createConfigFeature(),
|
|
97
|
+
fileFoundationFeature,
|
|
98
|
+
createFilesFeature(),
|
|
99
|
+
createFileDerivativesFeature({
|
|
100
|
+
resolveApexTenant: (host) => (host === HOST_A ? TENANT_A : null),
|
|
101
|
+
publicTenantResolution: "fileRef",
|
|
102
|
+
}),
|
|
103
|
+
gadgetPredicateFeature,
|
|
104
|
+
],
|
|
105
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterAll(async () => {
|
|
110
|
+
await stack.cleanup();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
receivedArgs = [];
|
|
115
|
+
await stack.redis.flushNamespace();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a tenant B public file is served through tenant A's shared host, isPublic runs against tenant B", async () => {
|
|
119
|
+
const fileId = await uploadImage(stack, userB, "public-1");
|
|
120
|
+
|
|
121
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
122
|
+
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
125
|
+
expect(receivedArgs).toHaveLength(1);
|
|
126
|
+
expect(receivedArgs[0]?.tenantId).toBe(TENANT_B);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a tenant A public file is also served through tenant A's host", async () => {
|
|
130
|
+
const fileId = await uploadImage(stack, userA, "public-1");
|
|
131
|
+
|
|
132
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
133
|
+
|
|
134
|
+
expect(res.status).toBe(200);
|
|
135
|
+
expect(receivedArgs[0]?.tenantId).toBe(TENANT_A);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a non-public tenant B file, an unknown fileRefId, and an unknown host all 404 with the same body", async () => {
|
|
139
|
+
const privateFileId = await uploadImage(stack, userB, "private-1");
|
|
140
|
+
|
|
141
|
+
const privateRes = await stack.app.request(`http://${HOST_A}/media/${privateFileId}/thumb`);
|
|
142
|
+
const unknownFileRefRes = await stack.app.request(
|
|
143
|
+
`http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(privateRes.status).toBe(404);
|
|
147
|
+
expect(unknownFileRefRes.status).toBe(404);
|
|
148
|
+
|
|
149
|
+
const unknownFileRefBody = await unknownFileRefRes.text();
|
|
150
|
+
expect(await privateRes.text()).toBe(unknownFileRefBody);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("an unknown host 404s even for a public file, before isPublic runs", async () => {
|
|
154
|
+
const publicFileId = await uploadImage(stack, userB, "public-1");
|
|
155
|
+
const unknownFileRefRes = await stack.app.request(
|
|
156
|
+
`http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
|
|
157
|
+
);
|
|
158
|
+
receivedArgs = [];
|
|
159
|
+
|
|
160
|
+
const unknownHostRes = await stack.app.request(
|
|
161
|
+
`http://unknown-host.example.com/media/${publicFileId}/thumb`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(unknownHostRes.status).toBe(404);
|
|
165
|
+
expect(await unknownHostRes.text()).toBe(await unknownFileRefRes.text());
|
|
166
|
+
expect(receivedArgs).toHaveLength(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("PUBLIC_VARIANT_BY_FILE_REF_QN is registered on the stack in 'fileRef' mode", () => {
|
|
170
|
+
expect(stack.registry.getQueryHandler(PUBLIC_VARIANT_BY_FILE_REF_QN)).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("file-derivatives :: default ('host') mode is unchanged", () => {
|
|
175
|
+
let stack: TestStack;
|
|
176
|
+
|
|
177
|
+
beforeAll(async () => {
|
|
178
|
+
patchFileInstanceofForBunTest();
|
|
179
|
+
stack = await setupTestStack({
|
|
180
|
+
features: [
|
|
181
|
+
createConfigFeature(),
|
|
182
|
+
fileFoundationFeature,
|
|
183
|
+
createFilesFeature(),
|
|
184
|
+
createFileDerivativesFeature({
|
|
185
|
+
resolveApexTenant: (host) => (host === HOST_A ? TENANT_A : null),
|
|
186
|
+
}),
|
|
187
|
+
gadgetPredicateFeature,
|
|
188
|
+
],
|
|
189
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
afterAll(async () => {
|
|
194
|
+
await stack.cleanup();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
beforeEach(async () => {
|
|
198
|
+
receivedArgs = [];
|
|
199
|
+
await stack.redis.flushNamespace();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a tenant B public file requested through tenant A's host 404s, identical body to an unknown FileRef", async () => {
|
|
203
|
+
const fileId = await uploadImage(stack, userB, "public-1");
|
|
204
|
+
|
|
205
|
+
const crossTenantRes = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
206
|
+
const unknownFileRefRes = await stack.app.request(
|
|
207
|
+
`http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(crossTenantRes.status).toBe(404);
|
|
211
|
+
expect(unknownFileRefRes.status).toBe(404);
|
|
212
|
+
expect(await crossTenantRes.text()).toBe(await unknownFileRefRes.text());
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("PUBLIC_VARIANT_BY_FILE_REF_QN is not registered in default mode — not reachable via the generic /api/query dispatch", () => {
|
|
216
|
+
expect(stack.registry.getQueryHandler(PUBLIC_VARIANT_BY_FILE_REF_QN)).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("createFileDerivativesFeature :: publicTenantResolution requires resolveApexTenant", () => {
|
|
221
|
+
test("throws when 'fileRef' is passed without resolveApexTenant", () => {
|
|
222
|
+
expect(() => createFileDerivativesFeature({ publicTenantResolution: "fileRef" })).toThrow();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.310.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "publicTenantResolution \"fileRef\" reads a public variant from its FileRef's own tenant",
|
|
6
|
+
"detail": "`createFileDerivativesFeature({ resolveApexTenant, publicTenantResolution: \"fileRef\" })`\nkeeps resolveApexTenant as the host gate (an unknown host still 404s), but\nreads the variant in the tenant that owns the FileRef, so one shared\nplatform host serves `/media/:fileRefId/:variant` for every tenant. The\nFileRef tenant's `isPublic` predicate stays the default-deny gate; unknown\nand non-public FileRefs answer the identical 404. Default \"host\" keeps the\nprevious behavior. In \"fileRef\" mode the anonymous\n`file-derivatives:query:public-variant-by-file-ref` query is also reachable\nover `/api`, independent of the host, and returns only variants the\npredicate allows. Passing publicTenantResolution without resolveApexTenant\nthrows at construction."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.287.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
19
|
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
20
20
|
import { PUBLIC_VARIANT_QN, publicVariantQuery } from "./handlers/public-variant.query";
|
|
21
|
+
import {
|
|
22
|
+
PUBLIC_VARIANT_BY_FILE_REF_QN,
|
|
23
|
+
publicVariantByFileRefQuery,
|
|
24
|
+
} from "./handlers/public-variant-by-file-ref.query";
|
|
21
25
|
|
|
22
26
|
const FEATURE_NAME = "file-derivatives";
|
|
23
27
|
|
|
@@ -42,6 +46,8 @@ export type PublicVariantResolveApexTenant = (
|
|
|
42
46
|
host: string,
|
|
43
47
|
) => Promise<TenantId | null> | TenantId | null;
|
|
44
48
|
|
|
49
|
+
export type PublicVariantTenantResolution = "host" | "fileRef";
|
|
50
|
+
|
|
45
51
|
export type FileDerivativesOptions = {
|
|
46
52
|
/** Host → tenantId for the anonymous `/media/:fileRefId/:variant` route.
|
|
47
53
|
* Without this option, neither the httpRoute NOR the `publicVariant`
|
|
@@ -52,6 +58,14 @@ export type FileDerivativesOptions = {
|
|
|
52
58
|
readonly resolveApexTenant?: PublicVariantResolveApexTenant;
|
|
53
59
|
/** Base path of the public variant route. Default "/media". */
|
|
54
60
|
readonly basePath?: string;
|
|
61
|
+
/** How the route picks the tenant a variant is read from. "host" (default):
|
|
62
|
+
* tenant = resolveApexTenant(host). "fileRef": resolveApexTenant still
|
|
63
|
+
* gates the host (an unknown host still 404s), but the variant itself is
|
|
64
|
+
* read from the FileRef row's own tenant — a single shared platform host
|
|
65
|
+
* then serves every tenant's public variants. The FileRef-tenant's own
|
|
66
|
+
* `isPublic` predicate remains the default-deny gate either way; requires
|
|
67
|
+
* `resolveApexTenant`. */
|
|
68
|
+
readonly publicTenantResolution?: PublicVariantTenantResolution;
|
|
55
69
|
};
|
|
56
70
|
|
|
57
71
|
// Raw handler-return of publicVariantQuery — systemQuery dispatches
|
|
@@ -73,13 +87,22 @@ type PublicVariantQueryResult = {
|
|
|
73
87
|
// — only a name, resolved against the field's own `variants` declaration —
|
|
74
88
|
// and only after the app's registered `isPublic` predicate for the FileRef's
|
|
75
89
|
// entityType says yes. tenantId is resolved from the request Host via
|
|
76
|
-
// `resolveApexTenant`, NEVER read from the request payload
|
|
90
|
+
// `resolveApexTenant`, NEVER read from the request payload — in
|
|
91
|
+
// `publicTenantResolution: "fileRef"` mode the tenant instead comes from the
|
|
92
|
+
// FileRef row matching `fileRefId` (never from the payload either), and the
|
|
93
|
+
// by-file-ref handler is reachable over `/api` independently of any Host,
|
|
94
|
+
// but still only returns a variant its FileRef-tenant's `isPublic` allows.
|
|
77
95
|
export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}): FeatureDefinition {
|
|
78
96
|
const basePath = opts.basePath ?? "/media";
|
|
97
|
+
if (opts.publicTenantResolution !== undefined && !opts.resolveApexTenant) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"createFileDerivativesFeature: publicTenantResolution requires resolveApexTenant — it only changes which tenant a variant is read from, resolveApexTenant still gates the host.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
79
102
|
|
|
80
103
|
return defineFeature(FEATURE_NAME, (r) => {
|
|
81
104
|
r.describe(
|
|
82
|
-
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
|
|
105
|
+
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. `publicTenantResolution: \"fileRef\"` keeps resolveApexTenant as the host gate but reads the variant from the FileRef row's own tenant instead of the host's, so one shared platform host serves every tenant's public variants — each FileRef-tenant's own `isPublic` predicate still default-denies. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
|
|
83
106
|
);
|
|
84
107
|
r.uiHints({
|
|
85
108
|
displayLabel: "File Derivatives",
|
|
@@ -127,8 +150,18 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
127
150
|
// `resolveApexTenant` enforces.
|
|
128
151
|
if (opts.resolveApexTenant) {
|
|
129
152
|
const resolveApexTenant = opts.resolveApexTenant;
|
|
153
|
+
const byFileRef = opts.publicTenantResolution === "fileRef";
|
|
154
|
+
const routeQn = byFileRef ? PUBLIC_VARIANT_BY_FILE_REF_QN : PUBLIC_VARIANT_QN;
|
|
130
155
|
|
|
131
156
|
r.queryHandler(publicVariantQuery);
|
|
157
|
+
// publicVariantByFileRefQuery is the ONLY way "fileRef" mode is
|
|
158
|
+
// reachable — registered ONLY in that mode for the same reason
|
|
159
|
+
// publicVariantQuery is gated above: registering it unconditionally
|
|
160
|
+
// would expose its unsafeRaw, cross-tenant FileRef lookup via the
|
|
161
|
+
// generic `/api` query dispatch even in default "host" mode.
|
|
162
|
+
if (byFileRef) {
|
|
163
|
+
r.queryHandler(publicVariantByFileRefQuery);
|
|
164
|
+
}
|
|
132
165
|
|
|
133
166
|
r.httpRoute({
|
|
134
167
|
method: "GET",
|
|
@@ -156,9 +189,10 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
156
189
|
let result: PublicVariantQueryResult;
|
|
157
190
|
try {
|
|
158
191
|
// @cast-boundary engine-payload — shape comes from
|
|
159
|
-
// publicVariantQuery's return type
|
|
192
|
+
// publicVariantQuery's return type (publicVariantByFileRefQuery
|
|
193
|
+
// returns exactly that, or null, never its own shape).
|
|
160
194
|
result = (await systemQuery(
|
|
161
|
-
|
|
195
|
+
routeQn,
|
|
162
196
|
{ fileRefId, variant },
|
|
163
197
|
tenantId,
|
|
164
198
|
)) as PublicVariantQueryResult;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createAnonymousUser,
|
|
4
|
+
defineQueryHandler,
|
|
5
|
+
isUuid,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
9
|
+
import { PUBLIC_VARIANT_QN, publicVariantPayloadSchema } from "./public-variant.query";
|
|
10
|
+
|
|
11
|
+
type FileRefTenantRow = {
|
|
12
|
+
readonly tenantId: TenantId;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const BY_FILE_REF_ESCAPE_HATCH_REASON =
|
|
16
|
+
"shared public host serves every tenant's public variants; the FileRef row names the tenant, and publicVariantQuery's isPublic gate still runs inside that tenant via queryAs";
|
|
17
|
+
|
|
18
|
+
// Full QN this handler is registered under, mirrors PUBLIC_VARIANT_QN.
|
|
19
|
+
export const PUBLIC_VARIANT_BY_FILE_REF_QN = "file-derivatives:query:public-variant-by-file-ref";
|
|
20
|
+
|
|
21
|
+
export const publicVariantByFileRefQuery = defineQueryHandler({
|
|
22
|
+
name: "public-variant-by-file-ref",
|
|
23
|
+
schema: publicVariantPayloadSchema,
|
|
24
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
25
|
+
agent: { expose: false },
|
|
26
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
27
|
+
escapeHatch: {
|
|
28
|
+
reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
|
|
29
|
+
},
|
|
30
|
+
handler: async (query, ctx) => {
|
|
31
|
+
const row = await fetchOne<FileRefTenantRow>(
|
|
32
|
+
ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON),
|
|
33
|
+
fileRefsTable,
|
|
34
|
+
{ id: query.payload.fileRefId, isDeleted: false },
|
|
35
|
+
);
|
|
36
|
+
if (!row || !isUuid(row.tenantId)) return null;
|
|
37
|
+
|
|
38
|
+
return ctx.queryAs(createAnonymousUser(row.tenantId), PUBLIC_VARIANT_QN, query.payload);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// feature.ts), `ctx.user.tenantId` instead comes from that dispatcher's own
|
|
17
17
|
// `anonymousAccess` resolution — tenant provenance there depends on the
|
|
18
18
|
// consumer's `resolverTrust`/anonymousAccess setup, not the host.
|
|
19
|
+
//
|
|
20
|
+
// In `publicTenantResolution: "fileRef"` mode, this handler is also the
|
|
21
|
+
// `ctx.queryAs(createAnonymousUser(fileRefTenantId), ...)` target of
|
|
22
|
+
// publicVariantByFileRefQuery — tenantId there comes from the FileRef row,
|
|
23
|
+
// but this handler's own isPublic gate runs exactly the same either way.
|
|
19
24
|
|
|
20
25
|
import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
|
|
21
26
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
@@ -72,15 +77,19 @@ declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
|
72
77
|
// def itself — mirrors managed-pages' BY_SLUG_QN.
|
|
73
78
|
export const PUBLIC_VARIANT_QN = "file-derivatives:query:public-variant";
|
|
74
79
|
|
|
80
|
+
// Shared with publicVariantByFileRefQuery — same payload shape, both routes
|
|
81
|
+
// forward `{ fileRefId, variant }` unchanged.
|
|
82
|
+
export const publicVariantPayloadSchema = z.object({
|
|
83
|
+
// Loose, version-agnostic UUID shape (same as isUuid/TENANT_ID_REGEX in
|
|
84
|
+
// packages/types) — not zod's `.uuid()`, which is stricter than this
|
|
85
|
+
// repo's convention and would reject valid v7/nil ids the DB accepts.
|
|
86
|
+
fileRefId: z.string().refine(isUuid, "invalid fileRefId"),
|
|
87
|
+
variant: z.string().min(1).max(64),
|
|
88
|
+
});
|
|
89
|
+
|
|
75
90
|
export const publicVariantQuery = defineQueryHandler({
|
|
76
91
|
name: "public-variant",
|
|
77
|
-
schema:
|
|
78
|
-
// Loose, version-agnostic UUID shape (same as isUuid/TENANT_ID_REGEX in
|
|
79
|
-
// packages/types) — not zod's `.uuid()`, which is stricter than this
|
|
80
|
-
// repo's convention and would reject valid v7/nil ids the DB accepts.
|
|
81
|
-
fileRefId: z.string().refine(isUuid, "invalid fileRefId"),
|
|
82
|
-
variant: z.string().min(1).max(64),
|
|
83
|
-
}),
|
|
92
|
+
schema: publicVariantPayloadSchema,
|
|
84
93
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
85
94
|
agent: { expose: false },
|
|
86
95
|
// ponytail: "ip" trusts the first x-forwarded-for hop (buildRequestContextData
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export type {
|
|
1
|
+
export type {
|
|
2
|
+
FileDerivativesOptions,
|
|
3
|
+
PublicVariantResolveApexTenant,
|
|
4
|
+
PublicVariantTenantResolution,
|
|
5
|
+
} from "./feature";
|
|
2
6
|
export { createFileDerivativesFeature, fileDerivativesFeature } from "./feature";
|
|
3
7
|
export type {
|
|
4
8
|
DerivativePublicPredicateArgs,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
6
|
import { EventEmitter } from "node:events";
|
|
7
7
|
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
+
import { sleep, waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
8
9
|
import {
|
|
9
10
|
type InboundMailContext,
|
|
10
11
|
isInboundAuthError,
|
|
@@ -152,13 +153,6 @@ const account: MailAccountRecord = {
|
|
|
152
153
|
watchState: "idle",
|
|
153
154
|
};
|
|
154
155
|
|
|
155
|
-
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
156
|
-
const t0 = Date.now();
|
|
157
|
-
while (!predicate() && Date.now() - t0 < timeoutMs) {
|
|
158
|
-
await Bun.sleep(5);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
156
|
function ctxWithDoc(doc: string | null): InboundMailContext {
|
|
163
157
|
return {
|
|
164
158
|
_userId: "imap-plugin-mocked",
|
|
@@ -332,7 +326,9 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
332
326
|
|
|
333
327
|
// drainNew() does MIME-parsing before onMessages fires — a fixed sleep
|
|
334
328
|
// flakes on a loaded CI runner; poll instead.
|
|
335
|
-
await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed"))
|
|
329
|
+
await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed")), {
|
|
330
|
+
delays: Array(40).fill(5),
|
|
331
|
+
});
|
|
336
332
|
expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
|
|
337
333
|
|
|
338
334
|
await stop();
|
|
@@ -347,11 +343,13 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
347
343
|
},
|
|
348
344
|
});
|
|
349
345
|
lastIdleClient?.emit("error", new Error("socket hang up"));
|
|
350
|
-
await waitFor(() => errors >= 1);
|
|
346
|
+
await waitFor(() => errors >= 1, { delays: Array(40).fill(5) });
|
|
351
347
|
lastIdleClient?.emit("error", new Error("second"));
|
|
352
|
-
// Confirms onError stays unsubscribed after the first error
|
|
353
|
-
//
|
|
354
|
-
|
|
348
|
+
// Confirms onError stays unsubscribed after the first error. This is a
|
|
349
|
+
// grace period for a negative outcome, not a wait-for-true condition, so
|
|
350
|
+
// it's a single sleep (not a poll loop) rather than waitFor, which would
|
|
351
|
+
// throw once errors never reaches 2.
|
|
352
|
+
await sleep(100);
|
|
355
353
|
expect(errors).toBe(1);
|
|
356
354
|
await stop().catch(() => {});
|
|
357
355
|
});
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
unsafeCreateEntityTable,
|
|
41
41
|
unsafePushTables,
|
|
42
42
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
43
|
-
import {
|
|
43
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
44
44
|
import { createJobsFeature } from "../feature";
|
|
45
45
|
import { createJobRunLogger } from "../job-run-logger";
|
|
46
46
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
@@ -148,7 +148,7 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// Poll until the worker drained the queue and the rebuild refilled.
|
|
151
|
-
|
|
151
|
+
await waitFor(async () => (await getCount()) === 2, { delays: Array(40).fill(200) });
|
|
152
152
|
expect(await getCount()).toBe(2);
|
|
153
153
|
|
|
154
154
|
// getCount()==2 only proves rebuildProjection's own writes landed — the
|
|
@@ -156,13 +156,15 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
156
156
|
// async append that starts only after the handler returns, so it can
|
|
157
157
|
// still be in flight here. Poll status too instead of racing it.
|
|
158
158
|
let runs: readonly { jobName: string; status: string }[] = [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
jobName:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
159
|
+
await waitFor(
|
|
160
|
+
async () => {
|
|
161
|
+
runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
|
|
162
|
+
jobName: PROJECTION_REBUILD_JOB,
|
|
163
|
+
});
|
|
164
|
+
return runs.some((r) => r.status === "completed");
|
|
165
|
+
},
|
|
166
|
+
{ delays: Array(40).fill(200) },
|
|
167
|
+
);
|
|
166
168
|
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
167
169
|
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
168
170
|
}, 30000);
|