@powerhousedao/switchboard 6.2.3-dev.24 → 6.2.3-dev.26
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/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +55 -2
- package/CHANGELOG.md +41 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +4 -5
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/{server-BFbus-at.mjs → server-DQCIIJzK.mjs} +236 -39
- package/dist/server-DQCIIJzK.mjs.map +1 -0
- package/dist/server.d.mts +38 -24
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -4
- package/dist/{utils-BBHt2kdo.mjs → utils-uaBlzyjS.mjs} +3 -3
- package/dist/{utils-BBHt2kdo.mjs.map → utils-uaBlzyjS.mjs.map} +1 -1
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +18 -18
- package/test/attachments/byte-grants.test.ts +287 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +194 -31
- package/test/attachments/routes-integration.test.ts +20 -5
- package/test/attachments/routes.test.ts +43 -25
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/workflow/harness.ts +1 -1
- package/test/workflow-read-gate.test.ts +145 -0
- package/tsdown.config.ts +3 -1
- package/dist/server-BFbus-at.mjs.map +0 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import {
|
|
3
|
+
ReactorBuilder,
|
|
4
|
+
ReactorClientBuilder,
|
|
5
|
+
type AttachmentHash,
|
|
6
|
+
type AttachmentRef,
|
|
7
|
+
type InProcessReactorClientModule,
|
|
8
|
+
} from "@powerhousedao/reactor";
|
|
9
|
+
import {
|
|
10
|
+
AttachmentBuilder,
|
|
11
|
+
AttachmentNotFound,
|
|
12
|
+
AttachmentReferenceIndexBuilder,
|
|
13
|
+
SwitchboardAttachmentTransport,
|
|
14
|
+
createRef,
|
|
15
|
+
type AttachmentBuildResult,
|
|
16
|
+
} from "@powerhousedao/reactor-attachments";
|
|
17
|
+
import {
|
|
18
|
+
AttachmentAccessService,
|
|
19
|
+
AuthorizationPolicy,
|
|
20
|
+
createCanonicalDocumentIdResolver,
|
|
21
|
+
createHttpAdapter,
|
|
22
|
+
type API,
|
|
23
|
+
type IAuthorizationService,
|
|
24
|
+
} from "@powerhousedao/reactor-api";
|
|
25
|
+
import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
|
|
26
|
+
import {
|
|
27
|
+
initializeAuth,
|
|
28
|
+
withSignaturePolicy,
|
|
29
|
+
type DocumentModelModule,
|
|
30
|
+
} from "@powerhousedao/shared/document-model";
|
|
31
|
+
import { documentModelDocumentModelModule } from "document-model";
|
|
32
|
+
import { Kysely } from "kysely";
|
|
33
|
+
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
34
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
35
|
+
import type { Server } from "node:http";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
39
|
+
import { registerAttachmentRoutes } from "../../src/attachments/index.js";
|
|
40
|
+
import { AttachmentUrlSigner } from "../../src/attachments/url-signer.js";
|
|
41
|
+
|
|
42
|
+
const READER = "0xreader";
|
|
43
|
+
const OUTSIDER = "0xoutsider";
|
|
44
|
+
const DOC_ID = "byte-routes-policed";
|
|
45
|
+
const PAYLOAD = "policed attachment bytes";
|
|
46
|
+
|
|
47
|
+
const openAuthorization: IAuthorizationService = {
|
|
48
|
+
config: {
|
|
49
|
+
admins: [],
|
|
50
|
+
defaultProtection: false,
|
|
51
|
+
policy: AuthorizationPolicy.OPEN,
|
|
52
|
+
},
|
|
53
|
+
isSupremeAdmin: () => true,
|
|
54
|
+
canCreate: () => true,
|
|
55
|
+
canRead: () => Promise.resolve(true),
|
|
56
|
+
canWrite: () => Promise.resolve(true),
|
|
57
|
+
canManage: () => Promise.resolve(true),
|
|
58
|
+
canMutate: () => Promise.resolve(true),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Bearer tokens name the caller directly; the gate is what is under test.
|
|
62
|
+
const verifyBearer = (authorization: string | undefined) => {
|
|
63
|
+
const token = authorization?.replace(/^Bearer /, "");
|
|
64
|
+
const address =
|
|
65
|
+
token === "reader" ? READER : token === "outsider" ? OUTSIDER : undefined;
|
|
66
|
+
return Promise.resolve({
|
|
67
|
+
user: address ? { address, chainId: 1, networkId: "mainnet" } : undefined,
|
|
68
|
+
admins: [],
|
|
69
|
+
auth_enabled: true,
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
74
|
+
|
|
75
|
+
describe("attachment byte routes behind the reactor's read gate", () => {
|
|
76
|
+
let module: InProcessReactorClientModule;
|
|
77
|
+
let db: Kysely<unknown>;
|
|
78
|
+
let replicaDb: Kysely<unknown>;
|
|
79
|
+
let storagePath: string;
|
|
80
|
+
let replicaPath: string;
|
|
81
|
+
let attachments: AttachmentBuildResult;
|
|
82
|
+
let server: Server;
|
|
83
|
+
let baseUrl: string;
|
|
84
|
+
let now = Date.now();
|
|
85
|
+
let hash: AttachmentHash;
|
|
86
|
+
let unreferencedHash: AttachmentHash;
|
|
87
|
+
|
|
88
|
+
async function upload(content: string, token = "reader") {
|
|
89
|
+
const reserved = await fetch(`${baseUrl}/attachments/reservations`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { ...bearer(token), "content-type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ mimeType: "text/plain", fileName: "a.txt" }),
|
|
93
|
+
});
|
|
94
|
+
const { reservationId } = (await reserved.json()) as {
|
|
95
|
+
reservationId: string;
|
|
96
|
+
};
|
|
97
|
+
const sent = await fetch(
|
|
98
|
+
`${baseUrl}/attachments/reservations/${reservationId}`,
|
|
99
|
+
{
|
|
100
|
+
method: "PUT",
|
|
101
|
+
headers: {
|
|
102
|
+
...bearer(token),
|
|
103
|
+
"content-type": "application/octet-stream",
|
|
104
|
+
},
|
|
105
|
+
body: content,
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
expect(sent.status).toBe(200);
|
|
109
|
+
return ((await sent.json()) as { hash: AttachmentHash }).hash;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
beforeAll(async () => {
|
|
113
|
+
module = await new ReactorClientBuilder()
|
|
114
|
+
.withReactorBuilder(
|
|
115
|
+
new ReactorBuilder()
|
|
116
|
+
.withDocumentModelSources([
|
|
117
|
+
driveDocumentModelModule as unknown as DocumentModelModule,
|
|
118
|
+
documentModelDocumentModelModule as unknown as DocumentModelModule,
|
|
119
|
+
])
|
|
120
|
+
.withExecutorConfig({
|
|
121
|
+
featureFlags: { documentDecisions: true, authEnforcement: true },
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
.buildModule();
|
|
125
|
+
|
|
126
|
+
await module.client.create(
|
|
127
|
+
withSignaturePolicy(
|
|
128
|
+
documentModelDocumentModelModule.utils.createDocument(),
|
|
129
|
+
"legacy",
|
|
130
|
+
{ id: DOC_ID },
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
await module.client.execute(DOC_ID, "main", [
|
|
134
|
+
initializeAuth({
|
|
135
|
+
version: 1,
|
|
136
|
+
grants: [
|
|
137
|
+
{
|
|
138
|
+
id: "g-read",
|
|
139
|
+
description: "the reader reads the global scope",
|
|
140
|
+
effect: "allow",
|
|
141
|
+
principal: { address: READER },
|
|
142
|
+
capability: { can: "read", scope: "global" },
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "g-admin",
|
|
146
|
+
description: "administration stays reachable",
|
|
147
|
+
effect: "allow",
|
|
148
|
+
principal: { anyone: true },
|
|
149
|
+
capability: { can: "execute", scope: "auth" },
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
}),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
db = new Kysely<unknown>({ dialect: new PGliteDialect(new PGlite()) });
|
|
156
|
+
storagePath = await mkdtemp(join(tmpdir(), "byte-routes-gate-"));
|
|
157
|
+
attachments = await new AttachmentBuilder(db, storagePath).build();
|
|
158
|
+
const index = await new AttachmentReferenceIndexBuilder(db).build();
|
|
159
|
+
const attachmentAccess = new AttachmentAccessService(
|
|
160
|
+
createCanonicalDocumentIdResolver(module.client),
|
|
161
|
+
openAuthorization,
|
|
162
|
+
index.store,
|
|
163
|
+
{ status: "available" },
|
|
164
|
+
module.client,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const { adapter } = await createHttpAdapter("express");
|
|
168
|
+
adapter.setupMiddleware({});
|
|
169
|
+
registerAttachmentRoutes(
|
|
170
|
+
{
|
|
171
|
+
httpAdapter: adapter,
|
|
172
|
+
attachments,
|
|
173
|
+
attachmentAccess,
|
|
174
|
+
authService: { verifyBearer },
|
|
175
|
+
} as unknown as API,
|
|
176
|
+
{
|
|
177
|
+
urlSigner: new AttachmentUrlSigner("i".repeat(32), () => now),
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
181
|
+
const address = server.address();
|
|
182
|
+
if (!address || typeof address === "string") throw new Error("no addr");
|
|
183
|
+
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
184
|
+
|
|
185
|
+
hash = await upload(PAYLOAD);
|
|
186
|
+
unreferencedHash = await upload("never referenced");
|
|
187
|
+
await index.store.addReferences([
|
|
188
|
+
{
|
|
189
|
+
documentId: DOC_ID,
|
|
190
|
+
ref: createRef(hash) as AttachmentRef,
|
|
191
|
+
operationId: `${DOC_ID}-global`,
|
|
192
|
+
branch: "main",
|
|
193
|
+
scope: "global",
|
|
194
|
+
ordinal: 1,
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
replicaDb = new Kysely<unknown>({
|
|
199
|
+
dialect: new PGliteDialect(new PGlite()),
|
|
200
|
+
});
|
|
201
|
+
replicaPath = await mkdtemp(join(tmpdir(), "byte-routes-replica-"));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
afterAll(async () => {
|
|
205
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
206
|
+
attachments.destroy();
|
|
207
|
+
module.reactor.kill();
|
|
208
|
+
await db.destroy();
|
|
209
|
+
await replicaDb.destroy();
|
|
210
|
+
await rm(storagePath, { recursive: true, force: true });
|
|
211
|
+
await rm(replicaPath, { recursive: true, force: true });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("refuses a hash-only GET and HEAD, even to a reader", async () => {
|
|
215
|
+
for (const method of ["GET", "HEAD"]) {
|
|
216
|
+
for (const headers of [{}, bearer("reader")]) {
|
|
217
|
+
const res = await fetch(`${baseUrl}/attachments/${hash}`, {
|
|
218
|
+
method,
|
|
219
|
+
headers,
|
|
220
|
+
});
|
|
221
|
+
expect(res.status, `${method} ${JSON.stringify(headers)}`).toBe(404);
|
|
222
|
+
await res.arrayBuffer();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("serves a documentId read to a reader and refuses an outsider or anonymous caller", async () => {
|
|
228
|
+
const url = `${baseUrl}/attachments/${hash}?documentId=${DOC_ID}`;
|
|
229
|
+
|
|
230
|
+
const got = await fetch(url, { headers: bearer("reader") });
|
|
231
|
+
expect(got.status).toBe(200);
|
|
232
|
+
expect(await got.text()).toBe(PAYLOAD);
|
|
233
|
+
|
|
234
|
+
const head = await fetch(url, {
|
|
235
|
+
method: "HEAD",
|
|
236
|
+
headers: bearer("reader"),
|
|
237
|
+
});
|
|
238
|
+
expect(head.status).toBe(200);
|
|
239
|
+
|
|
240
|
+
for (const headers of [bearer("outsider"), {}]) {
|
|
241
|
+
for (const method of ["GET", "HEAD"]) {
|
|
242
|
+
const res = await fetch(url, { method, headers });
|
|
243
|
+
expect(res.status, `${method} ${JSON.stringify(headers)}`).toBe(404);
|
|
244
|
+
await res.arrayBuffer();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("serves the unreferenced blob to no one, its uploader included", async () => {
|
|
250
|
+
const res = await fetch(
|
|
251
|
+
`${baseUrl}/attachments/${unreferencedHash}?documentId=${DOC_ID}`,
|
|
252
|
+
{ method: "HEAD", headers: bearer("reader") },
|
|
253
|
+
);
|
|
254
|
+
expect(res.status).toBe(404);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("mints a signed URL that works without credentials and fails when tampered or expired", async () => {
|
|
258
|
+
const minted = await fetch(
|
|
259
|
+
`${baseUrl}/attachments/${hash}/download-target?documentId=${DOC_ID}`,
|
|
260
|
+
{ headers: bearer("reader") },
|
|
261
|
+
);
|
|
262
|
+
expect(minted.status).toBe(200);
|
|
263
|
+
const target = (await minted.json()) as { url: string; kind: string };
|
|
264
|
+
expect(target.kind).toBe("switchboard");
|
|
265
|
+
|
|
266
|
+
const signed = await fetch(target.url);
|
|
267
|
+
expect(signed.status).toBe(200);
|
|
268
|
+
expect(await signed.text()).toBe(PAYLOAD);
|
|
269
|
+
|
|
270
|
+
const outsiderTarget = await fetch(
|
|
271
|
+
`${baseUrl}/attachments/${hash}/download-target?documentId=${DOC_ID}`,
|
|
272
|
+
{ headers: bearer("outsider") },
|
|
273
|
+
);
|
|
274
|
+
expect(outsiderTarget.status).toBe(404);
|
|
275
|
+
|
|
276
|
+
const tampered = new URL(target.url);
|
|
277
|
+
tampered.pathname = `/attachments/${unreferencedHash}`;
|
|
278
|
+
const tamperedRes = await fetch(tampered);
|
|
279
|
+
expect(tamperedRes.status).toBe(404);
|
|
280
|
+
|
|
281
|
+
const mintedAt = now;
|
|
282
|
+
now = mintedAt + 301_000;
|
|
283
|
+
try {
|
|
284
|
+
const expired = await fetch(target.url);
|
|
285
|
+
expect(expired.status).toBe(404);
|
|
286
|
+
} finally {
|
|
287
|
+
now = mintedAt;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("lets a replica pull the bytes for a document its subject reads", async () => {
|
|
292
|
+
const replicaFor = async (token: string, path: string) =>
|
|
293
|
+
(
|
|
294
|
+
await new AttachmentBuilder(replicaDb, path)
|
|
295
|
+
.withTransport(
|
|
296
|
+
new SwitchboardAttachmentTransport({
|
|
297
|
+
remoteUrl: baseUrl,
|
|
298
|
+
jwtHandler: () => Promise.resolve(token),
|
|
299
|
+
}),
|
|
300
|
+
)
|
|
301
|
+
.build()
|
|
302
|
+
).store;
|
|
303
|
+
|
|
304
|
+
const outsider = await replicaFor("outsider", join(replicaPath, "out"));
|
|
305
|
+
await expect(outsider.get(hash, undefined, DOC_ID)).rejects.toBeInstanceOf(
|
|
306
|
+
AttachmentNotFound,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const reader = await replicaFor("reader", join(replicaPath, "in"));
|
|
310
|
+
const pulled = await reader.get(hash, undefined, DOC_ID);
|
|
311
|
+
expect(await new Response(pulled.body).text()).toBe(PAYLOAD);
|
|
312
|
+
expect(await reader.has(hash)).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
});
|