@powerhousedao/switchboard 6.2.2-dev.3 → 6.2.2-dev.30
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/.tsbuild/test/attachment-reference-read-model.test.d.ts +2 -0
- package/.tsbuild/test/attachment-reference-read-model.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/download-target.test.d.ts +2 -0
- package/.tsbuild/test/attachments/download-target.test.d.ts.map +1 -0
- package/.tsbuild/test/worker-pool.test.d.ts +2 -0
- package/.tsbuild/test/worker-pool.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +162 -0
- package/README.md +44 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-DgBz3TJW.mjs → server-B4w0-8kx.mjs} +450 -27
- package/dist/server-B4w0-8kx.mjs.map +1 -0
- package/dist/server.d.mts +19 -10
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +12 -12
- package/test/attachment-reference-read-model.test.ts +425 -0
- package/test/attachments/auth.test.ts +138 -8
- package/test/attachments/download-target.test.ts +445 -0
- package/test/attachments/index.test.ts +37 -2
- package/test/attachments/routes-integration.test.ts +98 -1
- package/test/worker-pool.test.ts +232 -0
- package/dist/server-DgBz3TJW.mjs.map +0 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import {
|
|
3
|
+
ReactorClientBuilder,
|
|
4
|
+
ReactorBuilder,
|
|
5
|
+
type ILiveReadModelCoordinator,
|
|
6
|
+
type IReadModelCoordinator,
|
|
7
|
+
type Database,
|
|
8
|
+
type InProcessReactorClientModule,
|
|
9
|
+
} from "@powerhousedao/reactor";
|
|
10
|
+
import { PackageManagementService } from "@powerhousedao/reactor-api";
|
|
11
|
+
import type { HttpPackageLoader } from "@powerhousedao/reactor-api";
|
|
12
|
+
import {
|
|
13
|
+
ATTACHMENT_REFERENCE_READ_MODEL_ID,
|
|
14
|
+
AttachmentReferenceIndexBuilder,
|
|
15
|
+
type AttachmentReferenceInput,
|
|
16
|
+
type AttachmentReferenceReadModel,
|
|
17
|
+
type IAttachmentReferenceWriter,
|
|
18
|
+
} from "@powerhousedao/reactor-attachments";
|
|
19
|
+
import type {
|
|
20
|
+
Action,
|
|
21
|
+
DocumentModelModule,
|
|
22
|
+
DocumentSpecification,
|
|
23
|
+
OperationWithContext,
|
|
24
|
+
} from "@powerhousedao/shared/document-model";
|
|
25
|
+
import type { ILogger } from "document-model";
|
|
26
|
+
import { Kysely } from "kysely";
|
|
27
|
+
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
28
|
+
import { existsSync } from "node:fs";
|
|
29
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
33
|
+
import {
|
|
34
|
+
registerAttachmentReferenceReadModel,
|
|
35
|
+
registerAttachmentReferenceReadModelOnModule,
|
|
36
|
+
} from "../src/attachment-reference-read-model.mjs";
|
|
37
|
+
import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
|
|
38
|
+
import { startSwitchboard } from "../src/server.mjs";
|
|
39
|
+
|
|
40
|
+
const REF_A = `attachment://v1:${"a".repeat(64)}` as const;
|
|
41
|
+
const REF_B = `attachment://v1:${"b".repeat(64)}` as const;
|
|
42
|
+
const REF_C = `attachment://v1:${"c".repeat(64)}` as const;
|
|
43
|
+
const DOCUMENT_TYPE = "runtime/attachments";
|
|
44
|
+
|
|
45
|
+
function runtimeModule(version: number, schema: string): DocumentModelModule {
|
|
46
|
+
const specification: DocumentSpecification = {
|
|
47
|
+
changeLog: [],
|
|
48
|
+
modules: [
|
|
49
|
+
{
|
|
50
|
+
description: null,
|
|
51
|
+
id: "attachments",
|
|
52
|
+
name: "attachments",
|
|
53
|
+
operations: [
|
|
54
|
+
{
|
|
55
|
+
description: null,
|
|
56
|
+
errors: [],
|
|
57
|
+
examples: [],
|
|
58
|
+
id: `attach-files-${version}`,
|
|
59
|
+
name: "ATTACH_FILES",
|
|
60
|
+
reducer: null,
|
|
61
|
+
schema,
|
|
62
|
+
scope: "global",
|
|
63
|
+
template: null,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
state: {
|
|
69
|
+
global: { examples: [], initialValue: "{}", schema: "" },
|
|
70
|
+
local: { examples: [], initialValue: "{}", schema: "" },
|
|
71
|
+
},
|
|
72
|
+
version,
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
actions: {},
|
|
76
|
+
documentModel: {
|
|
77
|
+
global: { id: DOCUMENT_TYPE, specifications: [specification] },
|
|
78
|
+
},
|
|
79
|
+
version,
|
|
80
|
+
} as unknown as DocumentModelModule;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function operation(ordinal: number, input: unknown): OperationWithContext {
|
|
84
|
+
return {
|
|
85
|
+
operation: {
|
|
86
|
+
id: `operation-${ordinal}`,
|
|
87
|
+
index: ordinal,
|
|
88
|
+
skip: 0,
|
|
89
|
+
timestampUtcMs: "2026-07-22T00:00:00.000Z",
|
|
90
|
+
hash: `hash-${ordinal}`,
|
|
91
|
+
action: {
|
|
92
|
+
id: `action-${ordinal}`,
|
|
93
|
+
type: "ATTACH_FILES",
|
|
94
|
+
scope: "global",
|
|
95
|
+
input,
|
|
96
|
+
timestampUtcMs: "2026-07-22T00:00:00.000Z",
|
|
97
|
+
} as Action,
|
|
98
|
+
},
|
|
99
|
+
context: {
|
|
100
|
+
documentId: "runtime-document",
|
|
101
|
+
documentType: DOCUMENT_TYPE,
|
|
102
|
+
scope: "global",
|
|
103
|
+
branch: "main",
|
|
104
|
+
ordinal,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stubLogger(): ILogger & { info: ReturnType<typeof vi.fn> } {
|
|
110
|
+
const logger = {
|
|
111
|
+
verbose: vi.fn(),
|
|
112
|
+
debug: vi.fn(),
|
|
113
|
+
info: vi.fn(),
|
|
114
|
+
warn: vi.fn(),
|
|
115
|
+
error: vi.fn(),
|
|
116
|
+
child: vi.fn(),
|
|
117
|
+
};
|
|
118
|
+
logger.child.mockReturnValue(logger);
|
|
119
|
+
return logger as unknown as ILogger & { info: ReturnType<typeof vi.fn> };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("Switchboard attachment-reference read model registration", () => {
|
|
123
|
+
let database: Kysely<unknown> | undefined;
|
|
124
|
+
let reactor: Awaited<ReturnType<ReactorBuilder["buildModule"]>> | undefined;
|
|
125
|
+
|
|
126
|
+
afterEach(async () => {
|
|
127
|
+
const shutdown = reactor?.reactor.kill();
|
|
128
|
+
await shutdown?.completed;
|
|
129
|
+
await database?.destroy();
|
|
130
|
+
reactor = undefined;
|
|
131
|
+
database = undefined;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("starts on the exact caller-provided client without owning its reactor", async () => {
|
|
135
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-prebuilt-"));
|
|
136
|
+
const readModelPath = join(tempRoot, "read-model");
|
|
137
|
+
const unusedReactorPath = join(tempRoot, "reactor-storage");
|
|
138
|
+
database = new Kysely<unknown>({
|
|
139
|
+
dialect: new PGliteDialect(new PGlite()),
|
|
140
|
+
});
|
|
141
|
+
const reactorBuilder = new ReactorBuilder().withKysely(
|
|
142
|
+
database as unknown as Kysely<Database>,
|
|
143
|
+
);
|
|
144
|
+
const clientBuilder = new ReactorClientBuilder().withReactorBuilder(
|
|
145
|
+
reactorBuilder,
|
|
146
|
+
);
|
|
147
|
+
applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
|
|
148
|
+
signalHandlers: false,
|
|
149
|
+
});
|
|
150
|
+
const callerModule = await clientBuilder.buildModule();
|
|
151
|
+
reactor = callerModule.reactorModule;
|
|
152
|
+
const logger = stubLogger();
|
|
153
|
+
const previousReactorDb = process.env.PH_REACTOR_DATABASE_URL;
|
|
154
|
+
process.env.PH_REACTOR_DATABASE_URL = unusedReactorPath;
|
|
155
|
+
let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
switchboard = await startSwitchboard({
|
|
159
|
+
reactor: callerModule,
|
|
160
|
+
dbPath: readModelPath,
|
|
161
|
+
port: 0,
|
|
162
|
+
mcp: false,
|
|
163
|
+
disableLocalPackages: true,
|
|
164
|
+
identity: { keypairPath: join(tempRoot, "identity.json") },
|
|
165
|
+
logger,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(switchboard.reactor).toBe(callerModule.client);
|
|
169
|
+
expect(switchboard.attachmentReferenceProjection).toEqual({
|
|
170
|
+
status: "available",
|
|
171
|
+
});
|
|
172
|
+
expect(
|
|
173
|
+
callerModule.reactorModule?.readModelCoordinator.readModels.filter(
|
|
174
|
+
({ name }) => name === ATTACHMENT_REFERENCE_READ_MODEL_ID,
|
|
175
|
+
),
|
|
176
|
+
).toHaveLength(1);
|
|
177
|
+
expect(existsSync(unusedReactorPath)).toBe(false);
|
|
178
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
179
|
+
"Reactor metrics instrumentation started (using caller-provided reactor)",
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const kill = vi.spyOn(callerModule.reactor, "kill");
|
|
183
|
+
await switchboard.shutdown();
|
|
184
|
+
expect(kill).not.toHaveBeenCalled();
|
|
185
|
+
switchboard = undefined;
|
|
186
|
+
} finally {
|
|
187
|
+
await switchboard?.shutdown();
|
|
188
|
+
if (previousReactorDb === undefined) {
|
|
189
|
+
delete process.env.PH_REACTOR_DATABASE_URL;
|
|
190
|
+
} else {
|
|
191
|
+
process.env.PH_REACTOR_DATABASE_URL = previousReactorDb;
|
|
192
|
+
}
|
|
193
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}, 30_000);
|
|
196
|
+
|
|
197
|
+
it("backfills and closes the registration handoff without loss or duplication", async () => {
|
|
198
|
+
database = new Kysely<unknown>({
|
|
199
|
+
dialect: new PGliteDialect(new PGlite()),
|
|
200
|
+
});
|
|
201
|
+
const attachmentReferenceIndex = await new AttachmentReferenceIndexBuilder(
|
|
202
|
+
database,
|
|
203
|
+
).build();
|
|
204
|
+
reactor = await new ReactorBuilder()
|
|
205
|
+
.withKysely(database as unknown as Kysely<Database>)
|
|
206
|
+
.buildModule();
|
|
207
|
+
reactor.documentModelRegistry.registerModules(
|
|
208
|
+
runtimeModule(1, "input AttachFilesInput { direct: AttachmentRef! }"),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const persisted = [
|
|
212
|
+
operation(1, { direct: REF_A }),
|
|
213
|
+
operation(2, { direct: REF_B }),
|
|
214
|
+
];
|
|
215
|
+
vi.spyOn(reactor.operationIndex, "getSinceOrdinal").mockImplementation(
|
|
216
|
+
(ordinal: number) =>
|
|
217
|
+
Promise.resolve({
|
|
218
|
+
results: persisted.filter(
|
|
219
|
+
({ context }) =>
|
|
220
|
+
context.ordinal > ordinal &&
|
|
221
|
+
(ordinal > 0 || context.ordinal === 1),
|
|
222
|
+
),
|
|
223
|
+
options: { cursor: String(ordinal), limit: 100 },
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const writtenOrdinals: number[] = [];
|
|
228
|
+
const writer: IAttachmentReferenceWriter = {
|
|
229
|
+
addReferences: async (
|
|
230
|
+
references: readonly AttachmentReferenceInput[],
|
|
231
|
+
) => {
|
|
232
|
+
writtenOrdinals.push(...references.map(({ ordinal }) => ordinal));
|
|
233
|
+
await attachmentReferenceIndex.store.addReferences(references);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const coordinator =
|
|
238
|
+
reactor.readModelCoordinator as ILiveReadModelCoordinator;
|
|
239
|
+
const addReadModel = coordinator.addReadModel.bind(coordinator);
|
|
240
|
+
let boundaryIndexing: Promise<void> | undefined;
|
|
241
|
+
vi.spyOn(coordinator, "addReadModel").mockImplementation(
|
|
242
|
+
(readModel, stage) => {
|
|
243
|
+
addReadModel(readModel, stage);
|
|
244
|
+
boundaryIndexing = readModel.indexOperations([persisted[1]!]);
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const clientModule = {
|
|
249
|
+
reactorModule: reactor,
|
|
250
|
+
} as InProcessReactorClientModule;
|
|
251
|
+
await expect(
|
|
252
|
+
registerAttachmentReferenceReadModelOnModule(clientModule, writer),
|
|
253
|
+
).resolves.toEqual({ status: "available" });
|
|
254
|
+
await boundaryIndexing;
|
|
255
|
+
|
|
256
|
+
const matchingReadModels = coordinator.readModels.filter(
|
|
257
|
+
({ name }) => name === ATTACHMENT_REFERENCE_READ_MODEL_ID,
|
|
258
|
+
);
|
|
259
|
+
expect(matchingReadModels).toHaveLength(1);
|
|
260
|
+
expect(writtenOrdinals).toEqual([1, 2]);
|
|
261
|
+
await expect(
|
|
262
|
+
attachmentReferenceIndex.store.hasReference("runtime-document", REF_A),
|
|
263
|
+
).resolves.toBe(true);
|
|
264
|
+
await expect(
|
|
265
|
+
attachmentReferenceIndex.store.hasReference("runtime-document", REF_B),
|
|
266
|
+
).resolves.toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("leaves a custom coordinator running and reports projection unavailable", async () => {
|
|
270
|
+
database = new Kysely<unknown>({
|
|
271
|
+
dialect: new PGliteDialect(new PGlite()),
|
|
272
|
+
});
|
|
273
|
+
const start = vi.fn();
|
|
274
|
+
const customCoordinator: IReadModelCoordinator = {
|
|
275
|
+
readModels: [],
|
|
276
|
+
start,
|
|
277
|
+
stop: vi.fn(),
|
|
278
|
+
drain: vi.fn().mockResolvedValue(undefined),
|
|
279
|
+
getChainDepth: vi.fn().mockReturnValue(0),
|
|
280
|
+
};
|
|
281
|
+
reactor = await new ReactorBuilder()
|
|
282
|
+
.withKysely(database as unknown as Kysely<Database>)
|
|
283
|
+
.withReadModelCoordinator(customCoordinator)
|
|
284
|
+
.buildModule();
|
|
285
|
+
const addReferences = vi.fn();
|
|
286
|
+
const writer = { addReferences } as IAttachmentReferenceWriter;
|
|
287
|
+
|
|
288
|
+
await expect(
|
|
289
|
+
registerAttachmentReferenceReadModelOnModule(
|
|
290
|
+
{ reactorModule: reactor } as InProcessReactorClientModule,
|
|
291
|
+
writer,
|
|
292
|
+
),
|
|
293
|
+
).resolves.toEqual({
|
|
294
|
+
status: "unavailable",
|
|
295
|
+
reason: "live-read-model-registration-unsupported",
|
|
296
|
+
});
|
|
297
|
+
expect(start).toHaveBeenCalledOnce();
|
|
298
|
+
expect(customCoordinator.readModels).toEqual([]);
|
|
299
|
+
expect(addReferences).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("starts Switchboard with a custom coordinator while reporting projection unavailable", async () => {
|
|
303
|
+
const tempRoot = await mkdtemp(
|
|
304
|
+
join(tmpdir(), "switchboard-custom-coordinator-"),
|
|
305
|
+
);
|
|
306
|
+
database = new Kysely<unknown>({
|
|
307
|
+
dialect: new PGliteDialect(new PGlite()),
|
|
308
|
+
});
|
|
309
|
+
const customCoordinator: IReadModelCoordinator = {
|
|
310
|
+
readModels: [],
|
|
311
|
+
start: vi.fn(),
|
|
312
|
+
stop: vi.fn(),
|
|
313
|
+
drain: vi.fn().mockResolvedValue(undefined),
|
|
314
|
+
getChainDepth: vi.fn().mockReturnValue(0),
|
|
315
|
+
};
|
|
316
|
+
const reactorBuilder = new ReactorBuilder()
|
|
317
|
+
.withKysely(database as unknown as Kysely<Database>)
|
|
318
|
+
.withReadModelCoordinator(customCoordinator);
|
|
319
|
+
const clientBuilder = new ReactorClientBuilder().withReactorBuilder(
|
|
320
|
+
reactorBuilder,
|
|
321
|
+
);
|
|
322
|
+
applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
|
|
323
|
+
signalHandlers: false,
|
|
324
|
+
});
|
|
325
|
+
const callerModule = await clientBuilder.buildModule();
|
|
326
|
+
reactor = callerModule.reactorModule;
|
|
327
|
+
let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
switchboard = await startSwitchboard({
|
|
331
|
+
reactor: callerModule,
|
|
332
|
+
dbPath: join(tempRoot, "read-model"),
|
|
333
|
+
port: 0,
|
|
334
|
+
mcp: false,
|
|
335
|
+
disableLocalPackages: true,
|
|
336
|
+
identity: { keypairPath: join(tempRoot, "identity.json") },
|
|
337
|
+
logger: stubLogger(),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(switchboard.reactor).toBe(callerModule.client);
|
|
341
|
+
expect(switchboard.attachmentReferenceProjection).toEqual({
|
|
342
|
+
status: "unavailable",
|
|
343
|
+
reason: "live-read-model-registration-unsupported",
|
|
344
|
+
});
|
|
345
|
+
expect(customCoordinator.readModels).toEqual([]);
|
|
346
|
+
await switchboard.shutdown();
|
|
347
|
+
switchboard = undefined;
|
|
348
|
+
} finally {
|
|
349
|
+
await switchboard?.shutdown();
|
|
350
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
351
|
+
}
|
|
352
|
+
}, 30_000);
|
|
353
|
+
|
|
354
|
+
it("uses the live registry for packages installed after startup and caches by module identity", async () => {
|
|
355
|
+
database = new Kysely<unknown>({
|
|
356
|
+
dialect: new PGliteDialect(new PGlite()),
|
|
357
|
+
});
|
|
358
|
+
const attachmentReferenceIndex = await new AttachmentReferenceIndexBuilder(
|
|
359
|
+
database,
|
|
360
|
+
).build();
|
|
361
|
+
const builder = new ReactorBuilder().withKysely(
|
|
362
|
+
database as unknown as Kysely<Database>,
|
|
363
|
+
);
|
|
364
|
+
registerAttachmentReferenceReadModel(builder, {
|
|
365
|
+
baseKysely: database,
|
|
366
|
+
attachmentReferenceWriter: attachmentReferenceIndex.store,
|
|
367
|
+
});
|
|
368
|
+
reactor = await builder.buildModule();
|
|
369
|
+
|
|
370
|
+
const matchingReadModels = reactor.readModelCoordinator.readModels.filter(
|
|
371
|
+
({ name }) => name === ATTACHMENT_REFERENCE_READ_MODEL_ID,
|
|
372
|
+
);
|
|
373
|
+
expect(matchingReadModels).toHaveLength(1);
|
|
374
|
+
expect(() =>
|
|
375
|
+
reactor!.documentModelRegistry.getModule(DOCUMENT_TYPE),
|
|
376
|
+
).toThrow();
|
|
377
|
+
|
|
378
|
+
const firstModule = runtimeModule(
|
|
379
|
+
1,
|
|
380
|
+
"input NestedAttachmentInput { ref: AttachmentRef! }\n" +
|
|
381
|
+
"input AttachFilesInput { direct: AttachmentRef!, nested: NestedAttachmentInput! }",
|
|
382
|
+
);
|
|
383
|
+
const secondModule = runtimeModule(
|
|
384
|
+
2,
|
|
385
|
+
"input ReplacementAttachmentInput { ref: AttachmentRef! }\n" +
|
|
386
|
+
"input AttachFilesInput { payload: ReplacementAttachmentInput! }",
|
|
387
|
+
);
|
|
388
|
+
const modulesByPackage = new Map([
|
|
389
|
+
["runtime-v1", [firstModule]],
|
|
390
|
+
["runtime-v2", [secondModule]],
|
|
391
|
+
]);
|
|
392
|
+
const httpLoader = {
|
|
393
|
+
loadDocumentModels: (name: string) =>
|
|
394
|
+
Promise.resolve(modulesByPackage.get(name) ?? []),
|
|
395
|
+
} as unknown as HttpPackageLoader;
|
|
396
|
+
const packageManagementService = new PackageManagementService({
|
|
397
|
+
defaultRegistryUrl: "https://registry.example.test",
|
|
398
|
+
httpLoader,
|
|
399
|
+
documentModelRegistry: reactor.documentModelRegistry,
|
|
400
|
+
});
|
|
401
|
+
const readModel = matchingReadModels[0] as AttachmentReferenceReadModel;
|
|
402
|
+
|
|
403
|
+
await packageManagementService.installPackage("runtime-v1");
|
|
404
|
+
await readModel.indexOperations([
|
|
405
|
+
operation(1, { direct: REF_A, nested: { ref: REF_B } }),
|
|
406
|
+
]);
|
|
407
|
+
await expect(
|
|
408
|
+
attachmentReferenceIndex.store.hasReference("runtime-document", REF_A),
|
|
409
|
+
).resolves.toBe(true);
|
|
410
|
+
await expect(
|
|
411
|
+
attachmentReferenceIndex.store.hasReference("runtime-document", REF_B),
|
|
412
|
+
).resolves.toBe(true);
|
|
413
|
+
|
|
414
|
+
await packageManagementService.installPackage("runtime-v2");
|
|
415
|
+
expect(reactor.documentModelRegistry.getModule(DOCUMENT_TYPE)).toBe(
|
|
416
|
+
secondModule,
|
|
417
|
+
);
|
|
418
|
+
await readModel.indexOperations([
|
|
419
|
+
operation(2, { payload: { ref: REF_C } }),
|
|
420
|
+
]);
|
|
421
|
+
await expect(
|
|
422
|
+
attachmentReferenceIndex.store.hasReference("runtime-document", REF_C),
|
|
423
|
+
).resolves.toBe(true);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -60,20 +60,34 @@ function makeAuthService(
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
describe("requireAuth", () => {
|
|
63
|
-
it("
|
|
64
|
-
const handler
|
|
63
|
+
it("invokes the handler with the anonymous actor when authService is undefined (auth disabled path)", async () => {
|
|
64
|
+
const handler = vi.fn<NodeHandler>();
|
|
65
65
|
const wrapped = requireAuth(undefined, handler);
|
|
66
|
-
|
|
66
|
+
const req = makeReq({});
|
|
67
|
+
const res = makeRes();
|
|
68
|
+
await wrapped(req, res);
|
|
69
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(handler).toHaveBeenCalledWith(req, res, undefined, {
|
|
71
|
+
user: undefined,
|
|
72
|
+
authEnabled: false,
|
|
73
|
+
});
|
|
67
74
|
});
|
|
68
75
|
|
|
69
|
-
it("
|
|
76
|
+
it("ignores caller identity headers in auth-disabled mode", async () => {
|
|
70
77
|
const handler = vi.fn<NodeHandler>();
|
|
71
78
|
const wrapped = requireAuth(undefined, handler);
|
|
72
|
-
const req = makeReq({
|
|
79
|
+
const req = makeReq({
|
|
80
|
+
headers: {
|
|
81
|
+
"x-user-address": "0xspoofed",
|
|
82
|
+
"user-address": "0xspoofed",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
73
85
|
const res = makeRes();
|
|
74
86
|
await wrapped(req, res);
|
|
75
|
-
expect(handler).
|
|
76
|
-
|
|
87
|
+
expect(handler).toHaveBeenCalledWith(req, res, undefined, {
|
|
88
|
+
user: undefined,
|
|
89
|
+
authEnabled: false,
|
|
90
|
+
});
|
|
77
91
|
});
|
|
78
92
|
|
|
79
93
|
it("returns 401 with { error: 'Authentication required' } when Authorization header is missing", async () => {
|
|
@@ -163,13 +177,62 @@ describe("requireAuth", () => {
|
|
|
163
177
|
await wrapped(req, res);
|
|
164
178
|
|
|
165
179
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
166
|
-
expect(handler).toHaveBeenCalledWith(req, res
|
|
180
|
+
expect(handler).toHaveBeenCalledWith(req, res, undefined, {
|
|
181
|
+
user: { address: "0x123", chainId: 1, networkId: "mainnet" },
|
|
182
|
+
authEnabled: true,
|
|
183
|
+
});
|
|
167
184
|
expect(res.statusCode).toBe(200);
|
|
168
185
|
expect(res._body).toBe("");
|
|
169
186
|
expect(res._ended).toBe(false);
|
|
170
187
|
expect(res._headers["content-type"]).toBeUndefined();
|
|
171
188
|
});
|
|
172
189
|
|
|
190
|
+
it("derives the actor only from the verified bearer, ignoring spoofed identity headers", async () => {
|
|
191
|
+
const { service } = makeAuthService(() =>
|
|
192
|
+
Promise.resolve({
|
|
193
|
+
user: { address: "0xverified", chainId: 1, networkId: "mainnet" },
|
|
194
|
+
admins: [],
|
|
195
|
+
auth_enabled: true,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
const handler = vi.fn<NodeHandler>();
|
|
199
|
+
const wrapped = requireAuth(service, handler);
|
|
200
|
+
const req = makeReq({
|
|
201
|
+
headers: {
|
|
202
|
+
authorization: "Bearer good-token",
|
|
203
|
+
"x-user-address": "0xspoofed",
|
|
204
|
+
"user-address": "0xspoofed",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const res = makeRes();
|
|
208
|
+
await wrapped(req, res);
|
|
209
|
+
|
|
210
|
+
const actor = handler.mock.calls[0]?.[3];
|
|
211
|
+
expect(actor?.user?.address).toBe("0xverified");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("forwards the adapter-parsed body after successful authentication", async () => {
|
|
215
|
+
const { service } = makeAuthService(() =>
|
|
216
|
+
Promise.resolve({
|
|
217
|
+
user: { address: "0x123", chainId: 1, networkId: "mainnet" },
|
|
218
|
+
admins: [],
|
|
219
|
+
auth_enabled: true,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const handler = vi.fn<NodeHandler>();
|
|
223
|
+
const wrapped = requireAuth(service, handler);
|
|
224
|
+
const req = makeReq({ headers: { authorization: "Bearer good-token" } });
|
|
225
|
+
const res = makeRes();
|
|
226
|
+
const body = { mimeType: "application/pdf" };
|
|
227
|
+
|
|
228
|
+
await wrapped(req, res, body);
|
|
229
|
+
|
|
230
|
+
expect(handler).toHaveBeenCalledWith(req, res, body, {
|
|
231
|
+
user: { address: "0x123", chainId: 1, networkId: "mainnet" },
|
|
232
|
+
authEnabled: true,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
173
236
|
it("returns 500 with a sanitized body when AuthService throws", async () => {
|
|
174
237
|
const { service } = makeAuthService(async () => {
|
|
175
238
|
await Promise.resolve();
|
|
@@ -212,6 +275,73 @@ describe("requireAuth", () => {
|
|
|
212
275
|
expect(spy).toHaveBeenCalledWith("Bearer t");
|
|
213
276
|
});
|
|
214
277
|
|
|
278
|
+
describe("allowAnonymous", () => {
|
|
279
|
+
it("passes a missing bearer through as an anonymous actor with authEnabled true", async () => {
|
|
280
|
+
const { service } = makeAuthService(() =>
|
|
281
|
+
Promise.resolve({
|
|
282
|
+
user: undefined,
|
|
283
|
+
admins: [],
|
|
284
|
+
auth_enabled: true,
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
const handler = vi.fn<NodeHandler>();
|
|
288
|
+
const wrapped = requireAuth(service, handler, { allowAnonymous: true });
|
|
289
|
+
|
|
290
|
+
const req = makeReq({ headers: {} });
|
|
291
|
+
const res = makeRes();
|
|
292
|
+
await wrapped(req, res);
|
|
293
|
+
|
|
294
|
+
expect(res.statusCode).toBe(200);
|
|
295
|
+
expect(handler).toHaveBeenCalledWith(req, res, undefined, {
|
|
296
|
+
user: undefined,
|
|
297
|
+
authEnabled: true,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("still rejects an invalid bearer — a bad token is never downgraded to anonymous", async () => {
|
|
302
|
+
const { service } = makeAuthService(() =>
|
|
303
|
+
Promise.resolve(
|
|
304
|
+
new Response(JSON.stringify({ error: "Verification failed" }), {
|
|
305
|
+
status: 401,
|
|
306
|
+
headers: { "content-type": "application/json" },
|
|
307
|
+
}),
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
const handler = vi.fn<NodeHandler>();
|
|
311
|
+
const wrapped = requireAuth(service, handler, { allowAnonymous: true });
|
|
312
|
+
|
|
313
|
+
const res = makeRes();
|
|
314
|
+
await wrapped(
|
|
315
|
+
makeReq({ headers: { authorization: "Bearer bad-token" } }),
|
|
316
|
+
res,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
expect(res.statusCode).toBe(401);
|
|
320
|
+
expect(handler).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("still verifies and forwards a valid bearer identity", async () => {
|
|
324
|
+
const { service } = makeAuthService(() =>
|
|
325
|
+
Promise.resolve({
|
|
326
|
+
user: { address: "0x123", chainId: 1, networkId: "mainnet" },
|
|
327
|
+
admins: [],
|
|
328
|
+
auth_enabled: true,
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
const handler = vi.fn<NodeHandler>();
|
|
332
|
+
const wrapped = requireAuth(service, handler, { allowAnonymous: true });
|
|
333
|
+
|
|
334
|
+
const req = makeReq({ headers: { authorization: "Bearer good-token" } });
|
|
335
|
+
const res = makeRes();
|
|
336
|
+
await wrapped(req, res);
|
|
337
|
+
|
|
338
|
+
expect(handler).toHaveBeenCalledWith(req, res, undefined, {
|
|
339
|
+
user: { address: "0x123", chainId: 1, networkId: "mainnet" },
|
|
340
|
+
authEnabled: true,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
215
345
|
it("calls verifyBearer with undefined when no authorization header is present", async () => {
|
|
216
346
|
const { service, spy } = makeAuthService(() =>
|
|
217
347
|
Promise.resolve({
|