@cosmicdrift/kumiko-framework 0.319.0 → 0.321.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 +8 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +164 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +118 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +17 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +130 -40
- package/src/changes.json +121 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/schema-cli.ts +5 -1
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
- package/src/upgrade-cli.ts +58 -18
|
@@ -25,9 +25,11 @@ export {
|
|
|
25
25
|
|
|
26
26
|
type WebhookHttpMethod = "POST" | "PUT" | "PATCH";
|
|
27
27
|
|
|
28
|
+
// Name inside the tenant-owned secrets namespace
|
|
29
|
+
// `step-dispatcher:webhook-auth.<secret>` (secrets feature).
|
|
28
30
|
type WebhookAuth =
|
|
29
|
-
| { readonly kind: "bearer"; readonly
|
|
30
|
-
| { readonly kind: "header"; readonly name: string; readonly
|
|
31
|
+
| { readonly kind: "bearer"; readonly secret: string }
|
|
32
|
+
| { readonly kind: "header"; readonly name: string; readonly secret: string };
|
|
31
33
|
|
|
32
34
|
type WebhookSendArgs = {
|
|
33
35
|
readonly url: StepResolver<string>;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Legacy path — re-exported for callers still importing this module directly.
|
|
2
2
|
export type * from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
-
export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
export { isOpenToAllGranted, isUiAccessGranted } from "@cosmicdrift/kumiko-types/handlers";
|
|
@@ -218,7 +218,11 @@ export type {
|
|
|
218
218
|
WriteHandlerFn,
|
|
219
219
|
WriteResult,
|
|
220
220
|
} from "@cosmicdrift/kumiko-types/handlers";
|
|
221
|
-
export {
|
|
221
|
+
export {
|
|
222
|
+
isOpenToAllGranted,
|
|
223
|
+
isRateLimitDisabled,
|
|
224
|
+
isUiAccessGranted,
|
|
225
|
+
} from "@cosmicdrift/kumiko-types/handlers";
|
|
222
226
|
export type {
|
|
223
227
|
DeleteContext,
|
|
224
228
|
EntityHookMap,
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -17,7 +17,18 @@
|
|
|
17
17
|
// const entry = createApiEntrypoint(opts);
|
|
18
18
|
// attachSignalHandlers(entry.lifecycle);
|
|
19
19
|
// await entry.start();
|
|
20
|
-
// serve({
|
|
20
|
+
// serve({
|
|
21
|
+
// fetch: (req, server) => entry.app.fetch(req, server.requestIP(req)?.address),
|
|
22
|
+
// port: 3000,
|
|
23
|
+
// });
|
|
24
|
+
//
|
|
25
|
+
// `entry.app.fetch` alone (without the 2nd arg) loses the caller's real
|
|
26
|
+
// socket address — the client-ip resolver then sees no env, falls back to
|
|
27
|
+
// "unknown", and every caller shares one IP-keyed rate-limit bucket (see
|
|
28
|
+
// api/client-ip.ts). @cosmicdrift/kumiko-server-runtime's
|
|
29
|
+
// `buildBunServeOptions(port, entry.app.fetch)` wires this same pattern
|
|
30
|
+
// plus the SSE-safe idleTimeout default, for consumers that already
|
|
31
|
+
// depend on that package.
|
|
21
32
|
//
|
|
22
33
|
// The `lifecycle` handle drives graceful-shutdown LIFO; the framework
|
|
23
34
|
// registers its own shutdown hooks (eventDispatcher.stop, jobRunner.stop)
|
|
@@ -107,6 +118,7 @@ export type ApiEntrypointOptions = BaseEntrypointOptions & {
|
|
|
107
118
|
readonly eventDispatcher?: ServerOptions["eventDispatcher"] & {
|
|
108
119
|
readonly runLocal?: boolean;
|
|
109
120
|
};
|
|
121
|
+
readonly trustedProxyHops?: ServerOptions["trustedProxyHops"];
|
|
110
122
|
};
|
|
111
123
|
|
|
112
124
|
export type WorkerEntrypointOptions = BaseEntrypointOptions &
|
|
@@ -231,6 +243,7 @@ function buildApiServer(
|
|
|
231
243
|
processLane,
|
|
232
244
|
jwtIssuer: opts.jwtIssuer,
|
|
233
245
|
auth: opts.auth,
|
|
246
|
+
trustedProxyHops: opts.trustedProxyHops,
|
|
234
247
|
anonymousAccess: opts.anonymousAccess,
|
|
235
248
|
sseBroker: opts.sseBroker,
|
|
236
249
|
rateLimit: opts.rateLimit,
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// transferAggregateStreams — the event store's own tenant-transfer primitive.
|
|
2
|
+
// Pins: events, snapshots and the archive marker all move together; a system
|
|
3
|
+
// aggregate.transferred event lands per moved aggregate; an empty id list is
|
|
4
|
+
// a no-op; an id with no events under the given tenant/aggregateType is left
|
|
5
|
+
// untouched; an aggregate belonging to an unrelated third tenant is never
|
|
6
|
+
// touched by another tenant's move; an archive marker for a DIFFERENT
|
|
7
|
+
// aggregate type sharing the same aggregate id stays behind.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
11
|
+
import { asRawClient } from "../../db/query";
|
|
12
|
+
import type { TenantId } from "../../engine/types";
|
|
13
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
14
|
+
import { generateId as uuid } from "../../utils";
|
|
15
|
+
import {
|
|
16
|
+
AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
17
|
+
AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
18
|
+
append,
|
|
19
|
+
archiveStream,
|
|
20
|
+
isStreamArchived,
|
|
21
|
+
loadAggregate,
|
|
22
|
+
loadLatestSnapshot,
|
|
23
|
+
saveSnapshot,
|
|
24
|
+
transferAggregateStreams,
|
|
25
|
+
} from "../index";
|
|
26
|
+
|
|
27
|
+
let bun: BunTestDb;
|
|
28
|
+
const sourceTenant = uuid() as TenantId;
|
|
29
|
+
const destinationTenant = uuid() as TenantId;
|
|
30
|
+
const otherTenant = uuid() as TenantId;
|
|
31
|
+
const userId = uuid();
|
|
32
|
+
const transferredBy = uuid();
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
await ensureTemporalPolyfill();
|
|
36
|
+
bun = await createTestDb();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await bun.cleanup();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
await asRawClient(bun.db).unsafe(
|
|
45
|
+
`TRUNCATE kumiko_events, kumiko_snapshots, kumiko_archived_streams RESTART IDENTITY`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function seedAggregate(tenantId: TenantId, aggregateType = "counter"): Promise<string> {
|
|
50
|
+
const aggregateId = uuid();
|
|
51
|
+
await append(bun.db, {
|
|
52
|
+
aggregateId,
|
|
53
|
+
aggregateType,
|
|
54
|
+
tenantId,
|
|
55
|
+
expectedVersion: 0,
|
|
56
|
+
type: "counter.incremented",
|
|
57
|
+
payload: { by: 1 },
|
|
58
|
+
metadata: { userId },
|
|
59
|
+
});
|
|
60
|
+
return aggregateId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Each `aggregate.transferred` event lives on its OWN fresh stream (see
|
|
64
|
+
// transfer.ts) — the row's own `aggregate_id` column is that stream's id,
|
|
65
|
+
// not the moved aggregate's. The moved aggregate's id is in the payload.
|
|
66
|
+
async function transferredEvents(tenantId: TenantId): Promise<readonly Record<string, unknown>[]> {
|
|
67
|
+
const rows = (await asRawClient(bun.db).unsafe(
|
|
68
|
+
`SELECT payload FROM kumiko_events WHERE tenant_id = $1 AND aggregate_type = $2 AND type = $3`,
|
|
69
|
+
[tenantId, AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE],
|
|
70
|
+
)) as ReadonlyArray<{ payload: Record<string, unknown> }>;
|
|
71
|
+
return rows.map((row) => row.payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("transferAggregateStreams", () => {
|
|
75
|
+
test("moves events, drops the snapshot, carries the archive marker, and emits one aggregate.transferred event per moved aggregate", async () => {
|
|
76
|
+
const first = await seedAggregate(sourceTenant);
|
|
77
|
+
const second = await seedAggregate(sourceTenant);
|
|
78
|
+
await saveSnapshot(bun.db, {
|
|
79
|
+
aggregateId: first,
|
|
80
|
+
tenantId: sourceTenant,
|
|
81
|
+
aggregateType: "counter",
|
|
82
|
+
version: 1,
|
|
83
|
+
state: { count: 1 },
|
|
84
|
+
});
|
|
85
|
+
await archiveStream(bun.db, {
|
|
86
|
+
tenantId: sourceTenant,
|
|
87
|
+
aggregateId: first,
|
|
88
|
+
aggregateType: "counter",
|
|
89
|
+
archivedBy: "ops",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
93
|
+
sourceTenantId: sourceTenant,
|
|
94
|
+
destinationTenantId: destinationTenant,
|
|
95
|
+
aggregateType: "counter",
|
|
96
|
+
aggregateIds: [first, second],
|
|
97
|
+
transferredBy,
|
|
98
|
+
});
|
|
99
|
+
expect(movedCount).toBe(2);
|
|
100
|
+
|
|
101
|
+
// Events: gone from the source tenant, present under the destination.
|
|
102
|
+
expect(
|
|
103
|
+
await loadAggregate(bun.db, first, sourceTenant, { includeArchived: true }),
|
|
104
|
+
).toHaveLength(0);
|
|
105
|
+
expect(await loadAggregate(bun.db, second, sourceTenant)).toHaveLength(0);
|
|
106
|
+
const firstEvents = await loadAggregate(bun.db, first, destinationTenant, {
|
|
107
|
+
includeArchived: true,
|
|
108
|
+
});
|
|
109
|
+
expect(firstEvents.some((e) => e.type === "counter.incremented")).toBe(true);
|
|
110
|
+
const secondEvents = await loadAggregate(bun.db, second, destinationTenant);
|
|
111
|
+
expect(secondEvents.some((e) => e.type === "counter.incremented")).toBe(true);
|
|
112
|
+
|
|
113
|
+
// Snapshot dropped from the source tenant, not recreated anywhere.
|
|
114
|
+
expect(await loadLatestSnapshot(bun.db, first, sourceTenant)).toBeNull();
|
|
115
|
+
expect(await loadLatestSnapshot(bun.db, first, destinationTenant)).toBeNull();
|
|
116
|
+
|
|
117
|
+
// Archive marker moved with the stream.
|
|
118
|
+
expect(await isStreamArchived(bun.db, sourceTenant, first)).toBe(false);
|
|
119
|
+
expect(await isStreamArchived(bun.db, destinationTenant, first)).toBe(true);
|
|
120
|
+
expect(await isStreamArchived(bun.db, destinationTenant, second)).toBe(false);
|
|
121
|
+
|
|
122
|
+
// One system event per moved aggregate, in the destination tenant.
|
|
123
|
+
const events = await transferredEvents(destinationTenant);
|
|
124
|
+
expect(events).toHaveLength(2);
|
|
125
|
+
const byAggregateId = new Map(events.map((payload) => [payload["aggregateId"], payload]));
|
|
126
|
+
expect(byAggregateId.get(first)).toMatchObject({
|
|
127
|
+
aggregateType: "counter",
|
|
128
|
+
aggregateId: first,
|
|
129
|
+
sourceTenantId: sourceTenant,
|
|
130
|
+
destinationTenantId: destinationTenant,
|
|
131
|
+
});
|
|
132
|
+
expect(byAggregateId.get(second)).toMatchObject({
|
|
133
|
+
aggregateType: "counter",
|
|
134
|
+
aggregateId: second,
|
|
135
|
+
sourceTenantId: sourceTenant,
|
|
136
|
+
destinationTenantId: destinationTenant,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an empty id list is a no-op", async () => {
|
|
141
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
142
|
+
sourceTenantId: sourceTenant,
|
|
143
|
+
destinationTenantId: destinationTenant,
|
|
144
|
+
aggregateType: "counter",
|
|
145
|
+
aggregateIds: [],
|
|
146
|
+
transferredBy,
|
|
147
|
+
});
|
|
148
|
+
expect(movedCount).toBe(0);
|
|
149
|
+
expect(await transferredEvents(destinationTenant)).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an id with no events under this tenant/aggregateType is left untouched and gets no event", async () => {
|
|
153
|
+
const untouched = uuid();
|
|
154
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
155
|
+
sourceTenantId: sourceTenant,
|
|
156
|
+
destinationTenantId: destinationTenant,
|
|
157
|
+
aggregateType: "counter",
|
|
158
|
+
aggregateIds: [untouched],
|
|
159
|
+
transferredBy,
|
|
160
|
+
});
|
|
161
|
+
expect(movedCount).toBe(0);
|
|
162
|
+
expect(await loadAggregate(bun.db, untouched, sourceTenant)).toHaveLength(0);
|
|
163
|
+
expect(await loadAggregate(bun.db, untouched, destinationTenant)).toHaveLength(0);
|
|
164
|
+
expect(await transferredEvents(destinationTenant)).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("an aggregate under an unrelated third tenant with the same id is untouched", async () => {
|
|
168
|
+
const sharedId = uuid();
|
|
169
|
+
await append(bun.db, {
|
|
170
|
+
aggregateId: sharedId,
|
|
171
|
+
aggregateType: "counter",
|
|
172
|
+
tenantId: otherTenant,
|
|
173
|
+
expectedVersion: 0,
|
|
174
|
+
type: "counter.incremented",
|
|
175
|
+
payload: { by: 1 },
|
|
176
|
+
metadata: { userId },
|
|
177
|
+
});
|
|
178
|
+
await append(bun.db, {
|
|
179
|
+
aggregateId: sharedId,
|
|
180
|
+
aggregateType: "counter",
|
|
181
|
+
tenantId: sourceTenant,
|
|
182
|
+
expectedVersion: 0,
|
|
183
|
+
type: "counter.incremented",
|
|
184
|
+
payload: { by: 1 },
|
|
185
|
+
metadata: { userId },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await transferAggregateStreams(bun.db, {
|
|
189
|
+
sourceTenantId: sourceTenant,
|
|
190
|
+
destinationTenantId: destinationTenant,
|
|
191
|
+
aggregateType: "counter",
|
|
192
|
+
aggregateIds: [sharedId],
|
|
193
|
+
transferredBy,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const otherTenantEvents = await loadAggregate(bun.db, sharedId, otherTenant);
|
|
197
|
+
expect(otherTenantEvents).toHaveLength(1);
|
|
198
|
+
expect(await loadAggregate(bun.db, sharedId, sourceTenant)).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("an archived marker for a different aggregate type sharing the moved aggregate's id stays in the source tenant", async () => {
|
|
202
|
+
const sharedId = uuid();
|
|
203
|
+
// `append()` shares its version-conflict check across the plain
|
|
204
|
+
// aggregateId, so a real "widget" stream can't coexist with a real
|
|
205
|
+
// "counter" stream under the same id/tenant — fabricate the archived
|
|
206
|
+
// marker directly (the primary verifier of the event-store primitive,
|
|
207
|
+
// same reasoning as guard-event-store-writes' own __tests__ exclusion)
|
|
208
|
+
// to prove the WHERE clause's aggregate_type filter, not the stream's
|
|
209
|
+
// version-numbering.
|
|
210
|
+
await asRawClient(bun.db).unsafe(
|
|
211
|
+
`INSERT INTO "kumiko_archived_streams" ("tenant_id", "aggregate_id", "aggregate_type", "archived_by") VALUES ($1, $2, $3, $4)`,
|
|
212
|
+
[sourceTenant, sharedId, "widget", "ops"],
|
|
213
|
+
);
|
|
214
|
+
await append(bun.db, {
|
|
215
|
+
aggregateId: sharedId,
|
|
216
|
+
aggregateType: "counter",
|
|
217
|
+
tenantId: sourceTenant,
|
|
218
|
+
expectedVersion: 0,
|
|
219
|
+
type: "counter.incremented",
|
|
220
|
+
payload: { by: 1 },
|
|
221
|
+
metadata: { userId },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
225
|
+
sourceTenantId: sourceTenant,
|
|
226
|
+
destinationTenantId: destinationTenant,
|
|
227
|
+
aggregateType: "counter",
|
|
228
|
+
aggregateIds: [sharedId],
|
|
229
|
+
transferredBy,
|
|
230
|
+
});
|
|
231
|
+
expect(movedCount).toBe(1);
|
|
232
|
+
|
|
233
|
+
expect(await isStreamArchived(bun.db, sourceTenant, sharedId)).toBe(true);
|
|
234
|
+
expect(await isStreamArchived(bun.db, destinationTenant, sharedId)).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/event-store/index.ts
CHANGED
|
@@ -53,6 +53,12 @@ export {
|
|
|
53
53
|
saveSnapshot,
|
|
54
54
|
snapshotsTable,
|
|
55
55
|
} from "./snapshot";
|
|
56
|
+
export {
|
|
57
|
+
AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
58
|
+
AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
59
|
+
type TransferAggregateStreamsArgs,
|
|
60
|
+
transferAggregateStreams,
|
|
61
|
+
} from "./transfer";
|
|
56
62
|
export {
|
|
57
63
|
type EventUpcasters,
|
|
58
64
|
makeUpcastCtx,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The event store's own tenant-transfer primitive: the ONE place allowed to
|
|
2
|
+
// move a stream across the tenant boundary (enforced by
|
|
3
|
+
// guard-event-store-writes' allowlist). Each move leaves an audit event in
|
|
4
|
+
// the destination tenant.
|
|
5
|
+
//
|
|
6
|
+
// Runs entirely on the caller-supplied DbRunner, so the move rides whatever
|
|
7
|
+
// transaction the caller is already inside (tenant-handover's claim handler
|
|
8
|
+
// commits it together with the root row's ownership write — see
|
|
9
|
+
// move-entity-graph.ts's file header for why that matters).
|
|
10
|
+
|
|
11
|
+
import type { DbRunner } from "../db";
|
|
12
|
+
import {
|
|
13
|
+
deleteSnapshotsForAggregates,
|
|
14
|
+
transferArchivedStreamRows,
|
|
15
|
+
transferEventRows,
|
|
16
|
+
} from "../db/queries/event-store-transfer";
|
|
17
|
+
import type { TenantId } from "../engine/types";
|
|
18
|
+
import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
|
|
19
|
+
import { generateId } from "../utils/ids";
|
|
20
|
+
import { append } from "./event-store";
|
|
21
|
+
|
|
22
|
+
export const AGGREGATE_TRANSFER_STREAM_TYPE = "aggregate-transfer";
|
|
23
|
+
export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
|
|
24
|
+
|
|
25
|
+
export type TransferAggregateStreamsArgs = {
|
|
26
|
+
readonly sourceTenantId: TenantId;
|
|
27
|
+
readonly destinationTenantId: TenantId;
|
|
28
|
+
readonly aggregateType: string;
|
|
29
|
+
readonly aggregateIds: readonly string[];
|
|
30
|
+
readonly transferredBy: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Moves one aggregate type's event history, drops its cached snapshots, and
|
|
34
|
+
// carries any archive marker across the tenant boundary. Returns the number
|
|
35
|
+
// of aggregates that actually had events (and therefore moved) — an id in
|
|
36
|
+
// `aggregateIds` with no events under `sourceTenantId`/`aggregateType`
|
|
37
|
+
// contributes nothing and gets no follow-up event.
|
|
38
|
+
export async function transferAggregateStreams(
|
|
39
|
+
db: DbRunner,
|
|
40
|
+
args: TransferAggregateStreamsArgs,
|
|
41
|
+
): Promise<number> {
|
|
42
|
+
if (args.aggregateIds.length === 0) return 0;
|
|
43
|
+
|
|
44
|
+
const movedAggregateIds = await transferEventRows(db, {
|
|
45
|
+
sourceTenantId: args.sourceTenantId,
|
|
46
|
+
destinationTenantId: args.destinationTenantId,
|
|
47
|
+
aggregateType: args.aggregateType,
|
|
48
|
+
aggregateIds: args.aggregateIds,
|
|
49
|
+
});
|
|
50
|
+
if (movedAggregateIds.length === 0) return 0;
|
|
51
|
+
|
|
52
|
+
await deleteSnapshotsForAggregates(db, {
|
|
53
|
+
tenantId: args.sourceTenantId,
|
|
54
|
+
aggregateIds: movedAggregateIds,
|
|
55
|
+
});
|
|
56
|
+
await transferArchivedStreamRows(db, {
|
|
57
|
+
sourceTenantId: args.sourceTenantId,
|
|
58
|
+
destinationTenantId: args.destinationTenantId,
|
|
59
|
+
aggregateType: args.aggregateType,
|
|
60
|
+
aggregateIds: movedAggregateIds,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const aggregateId of movedAggregateIds) {
|
|
64
|
+
// A fresh, own stream — not an append onto the moved aggregate's own
|
|
65
|
+
// stream — so the marker never bumps the moved aggregate's version or
|
|
66
|
+
// shows up when a projection replays that aggregate's history.
|
|
67
|
+
await append(db, {
|
|
68
|
+
aggregateId: generateId(),
|
|
69
|
+
aggregateType: AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
70
|
+
tenantId: args.destinationTenantId,
|
|
71
|
+
expectedVersion: 0,
|
|
72
|
+
type: AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
73
|
+
payload: {
|
|
74
|
+
aggregateType: args.aggregateType,
|
|
75
|
+
aggregateId,
|
|
76
|
+
sourceTenantId: args.sourceTenantId,
|
|
77
|
+
destinationTenantId: args.destinationTenantId,
|
|
78
|
+
},
|
|
79
|
+
metadata: { userId: args.transferredBy },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return movedAggregateIds.length;
|
|
84
|
+
}
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { createFilesFeature } from "../feature";
|
|
34
34
|
import { fileRefsTable } from "../file-ref-table";
|
|
35
35
|
import type { FileRoutesOptions } from "../file-routes";
|
|
36
|
-
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
36
|
+
import { createInMemoryFileProvider, type InMemoryFileProvider } from "../in-memory-provider";
|
|
37
37
|
import { createLocalProvider } from "../local-provider";
|
|
38
38
|
import type { FileStorageProvider, SignedUrlOptions } from "../types";
|
|
39
39
|
import {
|
|
@@ -1643,3 +1643,107 @@ describe("byte-serving routes with field-encrypted fileName", () => {
|
|
|
1643
1643
|
}
|
|
1644
1644
|
});
|
|
1645
1645
|
});
|
|
1646
|
+
|
|
1647
|
+
describe("upload body-size limit", () => {
|
|
1648
|
+
// Small on purpose: the pre-parseBody body limit is maxUploadSize + a
|
|
1649
|
+
// fixed multipart margin regardless of scale, so a tiny maxUploadSize
|
|
1650
|
+
// keeps the over-limit payloads (and this suite) fast.
|
|
1651
|
+
const SMALL_MAX_UPLOAD_SIZE = "1kb";
|
|
1652
|
+
const OVER_LIMIT_FILE_BYTES = 200 * 1024;
|
|
1653
|
+
|
|
1654
|
+
async function withSizeLimitedServer(
|
|
1655
|
+
body: (args: { app: Hono; jwt: JwtHelper; provider: InMemoryFileProvider }) => Promise<void>,
|
|
1656
|
+
): Promise<void> {
|
|
1657
|
+
const isolatedDb = await createTestDb();
|
|
1658
|
+
await unsafePushTables(isolatedDb.db, { fileRefsTable });
|
|
1659
|
+
const provider = createInMemoryFileProvider();
|
|
1660
|
+
const isolatedRegistry = createRegistry([
|
|
1661
|
+
createFilesFeature({ maxUploadSize: SMALL_MAX_UPLOAD_SIZE }),
|
|
1662
|
+
]);
|
|
1663
|
+
const isolatedServer = buildServer({
|
|
1664
|
+
registry: isolatedRegistry,
|
|
1665
|
+
context: { db: isolatedDb.db, _fileProviderResolver: () => Promise.resolve(provider) },
|
|
1666
|
+
jwtSecret: JWT_SECRET,
|
|
1667
|
+
});
|
|
1668
|
+
try {
|
|
1669
|
+
await body({ app: isolatedServer.app, jwt: isolatedServer.jwt, provider });
|
|
1670
|
+
} finally {
|
|
1671
|
+
await isolatedDb.cleanup();
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function buildUnattachedMultipartBody(
|
|
1676
|
+
fileSizeBytes: number,
|
|
1677
|
+
): Promise<{ body: Uint8Array; contentType: string }> {
|
|
1678
|
+
const fd = new FormData();
|
|
1679
|
+
const content = new Uint8Array(fileSizeBytes);
|
|
1680
|
+
content.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic bytes
|
|
1681
|
+
fd.append("file", new File([Buffer.from(content)], "big.png", { type: "image/png" }));
|
|
1682
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
1683
|
+
return { body: body as Uint8Array, contentType };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
test("Content-Length above the limit is rejected without writing to storage", async () => {
|
|
1687
|
+
await withSizeLimitedServer(async ({ app, jwt, provider }) => {
|
|
1688
|
+
const token = await jwt.sign(adminUser);
|
|
1689
|
+
const { body, contentType } = await buildUnattachedMultipartBody(OVER_LIMIT_FILE_BYTES);
|
|
1690
|
+
const res = await app.request("/api/files", {
|
|
1691
|
+
method: "POST",
|
|
1692
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
1693
|
+
body: body as unknown as BodyInit,
|
|
1694
|
+
});
|
|
1695
|
+
expect(res.status).toBe(413);
|
|
1696
|
+
const json = (await res.json()) as { error: string };
|
|
1697
|
+
expectErrorIncludes(json.error, "request_too_large");
|
|
1698
|
+
expect(provider.keys()).toEqual([]);
|
|
1699
|
+
});
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
test("a chunked body without Content-Length above the limit is rejected while streaming", async () => {
|
|
1703
|
+
await withSizeLimitedServer(async ({ app, jwt, provider }) => {
|
|
1704
|
+
const token = await jwt.sign(adminUser);
|
|
1705
|
+
const { body, contentType } = await buildUnattachedMultipartBody(OVER_LIMIT_FILE_BYTES);
|
|
1706
|
+
// Feeding the buffer through a ReadableStream (instead of handing it to
|
|
1707
|
+
// fetch directly) means no Content-Length can be inferred — this drives
|
|
1708
|
+
// bodyLimit's streaming/counting branch instead of its Content-Length
|
|
1709
|
+
// shortcut.
|
|
1710
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1711
|
+
start(controller) {
|
|
1712
|
+
const chunkSize = 8 * 1024;
|
|
1713
|
+
for (let offset = 0; offset < body.length; offset += chunkSize) {
|
|
1714
|
+
controller.enqueue(body.subarray(offset, offset + chunkSize));
|
|
1715
|
+
}
|
|
1716
|
+
controller.close();
|
|
1717
|
+
},
|
|
1718
|
+
});
|
|
1719
|
+
const req = new Request("http://localhost/api/files", {
|
|
1720
|
+
method: "POST",
|
|
1721
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
1722
|
+
body: stream,
|
|
1723
|
+
duplex: "half",
|
|
1724
|
+
} as RequestInit);
|
|
1725
|
+
// A stream body has no known length — confirms this exercises
|
|
1726
|
+
// bodyLimit's streaming/counting branch, not its Content-Length shortcut.
|
|
1727
|
+
expect(req.headers.get("content-length")).toBeNull();
|
|
1728
|
+
const res = await app.request(req);
|
|
1729
|
+
expect(res.status).toBe(413);
|
|
1730
|
+
const json = (await res.json()) as { error: string };
|
|
1731
|
+
expectErrorIncludes(json.error, "request_too_large");
|
|
1732
|
+
expect(provider.keys()).toEqual([]);
|
|
1733
|
+
});
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
test("an upload within the limit still succeeds", async () => {
|
|
1737
|
+
await withSizeLimitedServer(async ({ app, jwt, provider }) => {
|
|
1738
|
+
const token = await jwt.sign(adminUser);
|
|
1739
|
+
const { body, contentType } = await buildUnattachedMultipartBody(128);
|
|
1740
|
+
const res = await app.request("/api/files", {
|
|
1741
|
+
method: "POST",
|
|
1742
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
1743
|
+
body: body as unknown as BodyInit,
|
|
1744
|
+
});
|
|
1745
|
+
expect(res.status).toBe(201);
|
|
1746
|
+
expect(provider.keys().length).toBe(1);
|
|
1747
|
+
});
|
|
1748
|
+
});
|
|
1749
|
+
});
|
package/src/files/file-routes.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
4
|
import { getUser } from "../api/auth-middleware";
|
|
4
5
|
import { requestContext } from "../api/request-context";
|
|
5
6
|
import {
|
|
@@ -29,6 +30,7 @@ import { fileRefsTable } from "./file-ref-table";
|
|
|
29
30
|
import type { FileProviderResolver } from "./provider-resolver";
|
|
30
31
|
import {
|
|
31
32
|
buildStorageKey,
|
|
33
|
+
parseMaxSize,
|
|
32
34
|
resolveServedContentType,
|
|
33
35
|
resolveUploadMimeType,
|
|
34
36
|
validateFile,
|
|
@@ -130,6 +132,45 @@ function resolveAttachedField(
|
|
|
130
132
|
return { kind: "resolved", fieldDef };
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
136
|
+
|
|
137
|
+
// Multipart framing plus the small entityType/entityId/fieldName parts
|
|
138
|
+
// riding alongside the file.
|
|
139
|
+
const MULTIPART_OVERHEAD_MARGIN_BYTES = 64 * 1024;
|
|
140
|
+
|
|
141
|
+
// entityType/fieldName (and therefore the field's own maxSize) are only
|
|
142
|
+
// known after the body is parsed, so this covers the largest configured
|
|
143
|
+
// field, not just the global default.
|
|
144
|
+
export function resolveMaxUploadBodyBytes(
|
|
145
|
+
options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">,
|
|
146
|
+
): number {
|
|
147
|
+
let maxBytes = parseMaxSize(options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE);
|
|
148
|
+
for (const entity of options.registry?.getAllEntities().values() ?? []) {
|
|
149
|
+
for (const field of Object.values(entity.fields)) {
|
|
150
|
+
if (isFileField(field) && field.maxSize) {
|
|
151
|
+
maxBytes = Math.max(maxBytes, parseMaxSize(field.maxSize));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return maxBytes + MULTIPART_OVERHEAD_MARGIN_BYTES;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Upload-route policy carried by createFilesFeature(opts?) — read from the
|
|
159
|
+
// feature's exports so callers apply it without a parallel options surface.
|
|
160
|
+
export function readFilesRouteOptions(
|
|
161
|
+
registry: Registry,
|
|
162
|
+
): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize"> {
|
|
163
|
+
const exp = registry.features.get("files")?.exports;
|
|
164
|
+
if (exp && typeof exp === "object" && "routeOptions" in exp) {
|
|
165
|
+
const ro = (exp as { routeOptions?: unknown }).routeOptions;
|
|
166
|
+
if (ro && typeof ro === "object") {
|
|
167
|
+
// @cast-boundary feature-exports: engine-payload (unknown) → known shape
|
|
168
|
+
return ro as Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
|
|
133
174
|
export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
134
175
|
const { db } = options;
|
|
135
176
|
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
@@ -168,8 +209,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
168
209
|
return typeof decrypted["fileName"] === "string" ? decrypted["fileName"] : "download";
|
|
169
210
|
}
|
|
170
211
|
|
|
212
|
+
// Runs before parseBody buffers the request below: checks Content-Length
|
|
213
|
+
// upfront, counts chunked bodies while streaming.
|
|
214
|
+
const uploadBodyLimit = bodyLimit({
|
|
215
|
+
maxSize: resolveMaxUploadBodyBytes(options),
|
|
216
|
+
onError: (c) =>
|
|
217
|
+
c.json(
|
|
218
|
+
{ error: "request_too_large: request body exceeds the maximum allowed upload size" },
|
|
219
|
+
413,
|
|
220
|
+
),
|
|
221
|
+
});
|
|
222
|
+
|
|
171
223
|
// POST /files — multipart upload.
|
|
172
|
-
api.post("/files", async (c) => {
|
|
224
|
+
api.post("/files", uploadBodyLimit, async (c) => {
|
|
173
225
|
const user = getUser(c);
|
|
174
226
|
const body = await c.req.parseBody();
|
|
175
227
|
const file = body["file"];
|
|
@@ -198,7 +250,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
198
250
|
}
|
|
199
251
|
|
|
200
252
|
// Validate against entity field definition if available.
|
|
201
|
-
let maxSize = options.maxUploadSize ??
|
|
253
|
+
let maxSize = options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
|
|
202
254
|
let accept: readonly string[] | undefined;
|
|
203
255
|
|
|
204
256
|
const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
|
package/src/files/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type {
|
|
|
11
11
|
FileRef,
|
|
12
12
|
FileRoutesOptions,
|
|
13
13
|
} from "./file-routes";
|
|
14
|
-
export { createFileRoutes } from "./file-routes";
|
|
14
|
+
export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes } from "./file-routes";
|
|
15
15
|
export type { InMemoryFileProvider } from "./in-memory-provider";
|
|
16
16
|
export { createInMemoryFileProvider } from "./in-memory-provider";
|
|
17
17
|
export { createLocalProvider } from "./local-provider";
|
package/src/http/index.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
export type { EgressPolicy } from "@cosmicdrift/kumiko-http";
|
|
2
|
-
export {
|
|
1
|
+
export type { EgressPolicy, ResolvedHost } from "@cosmicdrift/kumiko-http";
|
|
2
|
+
export {
|
|
3
|
+
BlockedHostError,
|
|
4
|
+
buildPinnedRequest,
|
|
5
|
+
egress,
|
|
6
|
+
HostResolutionError,
|
|
7
|
+
isPublicHost,
|
|
8
|
+
resolvePublicHost,
|
|
9
|
+
resolvePublicHostname,
|
|
10
|
+
} from "@cosmicdrift/kumiko-http";
|