@pdsjs/spaces 2.0.1 → 2.0.3
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 +4 -3
- package/src/admin.d.ts +10 -0
- package/src/admin.js +115 -0
- package/src/authority.d.ts +13 -3
- package/src/authority.js +22 -4
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +31 -0
- package/src/dpop.js +121 -0
- package/src/handlers/auth.d.ts +66 -15
- package/src/handlers/auth.js +146 -38
- package/src/handlers/manage.d.ts +4 -6
- package/src/handlers/manage.js +346 -65
- package/src/handlers/read.d.ts +6 -6
- package/src/handlers/read.js +150 -65
- package/src/handlers/write.d.ts +19 -6
- package/src/handlers/write.js +163 -26
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +36 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +14 -6
- package/src/routes.js +15 -2
- package/src/service-auth.d.ts +4 -6
- package/src/service-auth.js +49 -27
- package/src/token.d.ts +44 -8
- package/src/token.js +98 -19
- package/src/writer.d.ts +11 -0
- package/src/writer.js +10 -0
package/src/handlers/read.js
CHANGED
|
@@ -8,12 +8,11 @@
|
|
|
8
8
|
// does not own — it is signed by the authority, so it verifies without
|
|
9
9
|
// contacting them.
|
|
10
10
|
|
|
11
|
-
import { cborDecode } from '@pdsjs/core/repo';
|
|
11
|
+
import { cborDecode, findBlobRefs } from '@pdsjs/core/repo';
|
|
12
12
|
import { serializeRepo } from '../car.js';
|
|
13
13
|
import { RepoCommit } from '../commit.js';
|
|
14
|
-
import { SpaceTokenError } from '../token.js';
|
|
15
14
|
import { parseSpaceUri } from '../uri.js';
|
|
16
|
-
import {
|
|
15
|
+
import { createReadAuthorizer } from './auth.js';
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* @param {string} error
|
|
@@ -106,55 +105,51 @@ async function buildSignedCommit({ spaceStorage, space, author, signer }) {
|
|
|
106
105
|
);
|
|
107
106
|
}
|
|
108
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Every blob CID a space's records reference, paired with the rev of the commit
|
|
110
|
+
* that wrote the referencing record.
|
|
111
|
+
*
|
|
112
|
+
* Read from the records themselves rather than from a link table. A space repo
|
|
113
|
+
* on a personal server is small, and a table that can drift is the one thing a
|
|
114
|
+
* blob's reachability must not rest on.
|
|
115
|
+
*
|
|
116
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} spaceStorage
|
|
117
|
+
* @param {string} space
|
|
118
|
+
* @returns {Promise<Map<string, string>>} blob CID -> earliest referencing rev
|
|
119
|
+
*/
|
|
120
|
+
async function spaceBlobRefs(spaceStorage, space) {
|
|
121
|
+
/** @type {Map<string, string>} */
|
|
122
|
+
const refs = new Map();
|
|
123
|
+
const paths = await spaceStorage.listAllSpaceRecords(space);
|
|
124
|
+
for (const path of paths) {
|
|
125
|
+
const row = await spaceStorage.getSpaceRecord(
|
|
126
|
+
space,
|
|
127
|
+
path.collection,
|
|
128
|
+
path.rkey,
|
|
129
|
+
);
|
|
130
|
+
if (!row) continue;
|
|
131
|
+
for (const cid of findBlobRefs(cborDecode(row.value))) {
|
|
132
|
+
const seen = refs.get(cid);
|
|
133
|
+
if (seen === undefined || row.repoRev < seen) refs.set(cid, row.repoRev);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return refs;
|
|
137
|
+
}
|
|
138
|
+
|
|
109
139
|
/**
|
|
110
140
|
* @param {Object} ctx
|
|
111
141
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
142
|
+
* @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs
|
|
112
143
|
* @param {() => Promise<string|null>} ctx.getDid
|
|
113
|
-
* @param {() => Promise<
|
|
114
|
-
* @param {(
|
|
144
|
+
* @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
|
|
145
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
|
|
115
146
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
116
147
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
117
148
|
*/
|
|
118
149
|
export function createReadRoutes(ctx) {
|
|
119
|
-
const { spaceStorage, getDid, getSigner, resolveDid, verifier } = ctx;
|
|
150
|
+
const { spaceStorage, blobs, getDid, getSigner, resolveDid, verifier } = ctx;
|
|
120
151
|
|
|
121
|
-
|
|
122
|
-
* Authorize a read: the hosted account itself, or a valid space credential for
|
|
123
|
-
* the space being read.
|
|
124
|
-
*
|
|
125
|
-
* @param {Request} request
|
|
126
|
-
* @param {string} space
|
|
127
|
-
* @param {{did: string}|null} auth
|
|
128
|
-
* @returns {Promise<Response|null>} a Response to return, or null to proceed
|
|
129
|
-
*/
|
|
130
|
-
async function authorizeRead(request, space, auth) {
|
|
131
|
-
const hosted = await getDid();
|
|
132
|
-
if (auth && auth.did === hosted) return null;
|
|
133
|
-
|
|
134
|
-
const header = request.headers.get('authorization') ?? '';
|
|
135
|
-
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
136
|
-
if (!match) {
|
|
137
|
-
return errorResponse(
|
|
138
|
-
'AuthenticationRequired',
|
|
139
|
-
'A session or space credential is required',
|
|
140
|
-
401,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
await verifySpaceCredential({
|
|
145
|
-
credential: match[1],
|
|
146
|
-
space,
|
|
147
|
-
resolveDid,
|
|
148
|
-
verifier,
|
|
149
|
-
});
|
|
150
|
-
return null;
|
|
151
|
-
} catch (err) {
|
|
152
|
-
if (err instanceof SpaceTokenError) {
|
|
153
|
-
return errorResponse(err.code, err.message, 401);
|
|
154
|
-
}
|
|
155
|
-
throw err;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
152
|
+
const authorizeRead = createReadAuthorizer({ getDid, resolveDid, verifier });
|
|
158
153
|
|
|
159
154
|
/**
|
|
160
155
|
* The repo a request targets. `repo` is optional on the wire; this PDS hosts
|
|
@@ -171,6 +166,27 @@ export function createReadRoutes(ctx) {
|
|
|
171
166
|
return did;
|
|
172
167
|
}
|
|
173
168
|
|
|
169
|
+
/** @type {import('@pdsjs/core/pds').Route} */
|
|
170
|
+
const latestCommitRoute = {
|
|
171
|
+
auth: 'optional',
|
|
172
|
+
handler: async (request, url, auth) => {
|
|
173
|
+
const target = readTarget(url);
|
|
174
|
+
if (target instanceof Response) return target;
|
|
175
|
+
const denied = await authorizeRead(request, target.space, auth);
|
|
176
|
+
if (denied) return denied;
|
|
177
|
+
const did = await resolveRepo(target.repo);
|
|
178
|
+
if (did instanceof Response) return did;
|
|
179
|
+
|
|
180
|
+
const commit = await buildSignedCommit({
|
|
181
|
+
spaceStorage,
|
|
182
|
+
space: target.space,
|
|
183
|
+
author: did,
|
|
184
|
+
signer: await getSigner(),
|
|
185
|
+
});
|
|
186
|
+
return Response.json(commit ? { commit: toJsonCommit(commit) } : {});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
174
190
|
return {
|
|
175
191
|
'/xrpc/com.atproto.space.getRecord': {
|
|
176
192
|
auth: 'optional',
|
|
@@ -221,29 +237,26 @@ export function createReadRoutes(ctx) {
|
|
|
221
237
|
const did = await resolveRepo(target.repo);
|
|
222
238
|
if (did instanceof Response) return did;
|
|
223
239
|
|
|
224
|
-
const limit = readLimit(url, 'limit', 50,
|
|
240
|
+
const limit = readLimit(url, 'limit', 50, 1000);
|
|
225
241
|
if (limit instanceof Response) return limit;
|
|
226
242
|
|
|
227
|
-
const collection = url.searchParams.get('collection');
|
|
228
|
-
if (!collection) {
|
|
229
|
-
return errorResponse(
|
|
230
|
-
'InvalidRequest',
|
|
231
|
-
'collection is required by this implementation',
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
243
|
const { records, cursor } = await spaceStorage.listSpaceRecords(
|
|
236
244
|
target.space,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
245
|
+
{
|
|
246
|
+
collection: url.searchParams.get('collection'),
|
|
247
|
+
cursor: url.searchParams.get('cursor'),
|
|
248
|
+
limit,
|
|
249
|
+
reverse: url.searchParams.get('reverse') === 'true',
|
|
250
|
+
excludeValues: url.searchParams.get('excludeValues') === 'true',
|
|
251
|
+
},
|
|
240
252
|
);
|
|
241
253
|
return Response.json({
|
|
242
254
|
...(cursor ? { cursor } : {}),
|
|
243
255
|
records: records.map((r) => ({
|
|
244
|
-
|
|
256
|
+
collection: r.collection,
|
|
257
|
+
rkey: r.rkey,
|
|
245
258
|
cid: r.cid,
|
|
246
|
-
value: cborDecode(r.value),
|
|
259
|
+
...(r.value === undefined ? {} : { value: cborDecode(r.value) }),
|
|
247
260
|
})),
|
|
248
261
|
});
|
|
249
262
|
},
|
|
@@ -316,7 +329,17 @@ export function createReadRoutes(ctx) {
|
|
|
316
329
|
},
|
|
317
330
|
},
|
|
318
331
|
|
|
319
|
-
'/xrpc/com.atproto.space.getLatestCommit':
|
|
332
|
+
'/xrpc/com.atproto.space.getLatestCommit': latestCommitRoute,
|
|
333
|
+
|
|
334
|
+
// The same handler under the name this method shipped as before the draft
|
|
335
|
+
// renamed it. Both are in use, and a client that knows only one reads the
|
|
336
|
+
// whole feature as missing.
|
|
337
|
+
'/xrpc/com.atproto.space.getRepoState': latestCommitRoute,
|
|
338
|
+
|
|
339
|
+
// Blobs are uploaded through com.atproto.repo.uploadBlob and referenced
|
|
340
|
+
// from a space record, so a client writing blob-bearing records needs a
|
|
341
|
+
// blob permission alongside its space permission.
|
|
342
|
+
'/xrpc/com.atproto.space.listBlobs': {
|
|
320
343
|
auth: 'optional',
|
|
321
344
|
handler: async (request, url, auth) => {
|
|
322
345
|
const target = readTarget(url);
|
|
@@ -326,13 +349,65 @@ export function createReadRoutes(ctx) {
|
|
|
326
349
|
const did = await resolveRepo(target.repo);
|
|
327
350
|
if (did instanceof Response) return did;
|
|
328
351
|
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
352
|
+
const limit = readLimit(url, 'limit', 500, 1000);
|
|
353
|
+
if (limit instanceof Response) return limit;
|
|
354
|
+
const since = url.searchParams.get('since');
|
|
355
|
+
const cursor = url.searchParams.get('cursor');
|
|
356
|
+
|
|
357
|
+
const refs = await spaceBlobRefs(spaceStorage, target.space);
|
|
358
|
+
const cids = [...refs]
|
|
359
|
+
.filter(([, rev]) => (since ? rev > since : true))
|
|
360
|
+
.map(([cid]) => cid)
|
|
361
|
+
.filter((cid) => (cursor ? cid > cursor : true))
|
|
362
|
+
.sort();
|
|
363
|
+
const page = cids.slice(0, limit);
|
|
364
|
+
|
|
365
|
+
return Response.json({
|
|
366
|
+
cids: page,
|
|
367
|
+
...(page.length < limit ? {} : { cursor: page[page.length - 1] }),
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
// Scoped to the space: a credential admits its holder to that space, not to
|
|
373
|
+
// the account's whole blob store. The reference serves any blob the account
|
|
374
|
+
// holds once the space read passes.
|
|
375
|
+
'/xrpc/com.atproto.space.getBlob': {
|
|
376
|
+
auth: 'optional',
|
|
377
|
+
handler: async (request, url, auth) => {
|
|
378
|
+
const target = readTarget(url);
|
|
379
|
+
if (target instanceof Response) return target;
|
|
380
|
+
const denied = await authorizeRead(request, target.space, auth);
|
|
381
|
+
if (denied) return denied;
|
|
382
|
+
const did = await resolveRepo(target.repo);
|
|
383
|
+
if (did instanceof Response) return did;
|
|
384
|
+
|
|
385
|
+
const cid = url.searchParams.get('cid');
|
|
386
|
+
if (!cid) return errorResponse('InvalidRequest', 'cid is required');
|
|
387
|
+
|
|
388
|
+
const refs = await spaceBlobRefs(spaceStorage, target.space);
|
|
389
|
+
if (!refs.has(cid)) {
|
|
390
|
+
return errorResponse(
|
|
391
|
+
'BlobNotFound',
|
|
392
|
+
`No record in ${target.space} references ${cid}`,
|
|
393
|
+
404,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const blob = await blobs.get(did, cid);
|
|
398
|
+
if (!blob) {
|
|
399
|
+
return errorResponse('BlobNotFound', 'Blob not found', 404);
|
|
400
|
+
}
|
|
401
|
+
return new Response(/** @type {BodyInit} */ (blob.data), {
|
|
402
|
+
headers: {
|
|
403
|
+
'Content-Type': blob.mimeType,
|
|
404
|
+
'X-Content-Type-Options': 'nosniff',
|
|
405
|
+
// Force download and block execution so a hostile blob cannot run
|
|
406
|
+
// in the PDS origin.
|
|
407
|
+
'Content-Disposition': `attachment; filename="${cid}"`,
|
|
408
|
+
'Content-Security-Policy': "default-src 'none'; sandbox",
|
|
409
|
+
},
|
|
334
410
|
});
|
|
335
|
-
return Response.json(commit ? { commit: toJsonCommit(commit) } : {});
|
|
336
411
|
},
|
|
337
412
|
},
|
|
338
413
|
|
|
@@ -360,9 +435,19 @@ export function createReadRoutes(ctx) {
|
|
|
360
435
|
);
|
|
361
436
|
}
|
|
362
437
|
|
|
438
|
+
const excludeValues = url.searchParams.get('excludeValues') === 'true';
|
|
363
439
|
const paths = await spaceStorage.listAllSpaceRecords(target.space);
|
|
440
|
+
/** @type {import('../car.js').SerializedRecord[]} */
|
|
364
441
|
const records = [];
|
|
365
442
|
for (const p of paths) {
|
|
443
|
+
if (excludeValues) {
|
|
444
|
+
records.push({
|
|
445
|
+
collection: p.collection,
|
|
446
|
+
rkey: p.rkey,
|
|
447
|
+
cid: p.cid,
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
366
451
|
const row = await spaceStorage.getSpaceRecord(
|
|
367
452
|
target.space,
|
|
368
453
|
p.collection,
|
|
@@ -378,7 +463,7 @@ export function createReadRoutes(ctx) {
|
|
|
378
463
|
}
|
|
379
464
|
}
|
|
380
465
|
|
|
381
|
-
const car = await serializeRepo(commit, records);
|
|
466
|
+
const car = await serializeRepo(commit, records, { excludeValues });
|
|
382
467
|
// Copy into a plain ArrayBuffer: a Uint8Array view is not a BodyInit
|
|
383
468
|
// per the DOM lib types, and `.buffer` widens to ArrayBufferLike, which
|
|
384
469
|
// includes SharedArrayBuffer. The copy keeps this honest rather than
|
package/src/handlers/write.d.ts
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @param {Object} ctx
|
|
3
3
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
4
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
5
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
4
6
|
* @param {() => Promise<string|null>} ctx.getDid
|
|
5
|
-
* @param {() => Promise<
|
|
6
|
-
* @param {(
|
|
7
|
+
* @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
|
|
8
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
|
|
9
|
+
* @param {{collections: string[], check: (write: {space: string|null, collection: string, rkey: string, prevValue: unknown|null, nextValue: unknown|null}) => Promise<void>}} [ctx.recordGuard]
|
|
7
10
|
* @param {typeof fetch} [ctx.fetch]
|
|
8
11
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
9
12
|
*/
|
|
10
13
|
export declare function createWriteRoutes(ctx: {
|
|
11
14
|
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
15
|
+
linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
|
|
16
|
+
unlinkBlobs: (recordUri: string) => Promise<void>;
|
|
12
17
|
getDid: () => Promise<string | null>;
|
|
13
|
-
getSigner: () => Promise<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
getSigner: () => Promise<import('../token.js').SpaceSigner>;
|
|
19
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
20
|
+
recordGuard?: {
|
|
21
|
+
collections: string[];
|
|
22
|
+
check: (write: {
|
|
23
|
+
space: string | null;
|
|
24
|
+
collection: string;
|
|
25
|
+
rkey: string;
|
|
26
|
+
prevValue: unknown | null;
|
|
27
|
+
nextValue: unknown | null;
|
|
28
|
+
}) => Promise<void>;
|
|
29
|
+
};
|
|
17
30
|
fetch?: typeof fetch;
|
|
18
31
|
}): import('@pdsjs/core/pds').Routes;
|
package/src/handlers/write.js
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
// Authorization is two checks: the caller may only write its own repo, and the
|
|
8
8
|
// token's `space:` scope must cover the target space, action and collection.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
cborDecode,
|
|
12
|
+
createTid,
|
|
13
|
+
findBlobRefs,
|
|
14
|
+
recordTimeOf,
|
|
15
|
+
} from '@pdsjs/core/repo';
|
|
11
16
|
import { ScopePermissions } from '@pdsjs/core/scope';
|
|
12
17
|
import { spaceHostEndpoint } from '../authority.js';
|
|
13
18
|
import { LtHash } from '../lthash.js';
|
|
19
|
+
import { forwardToSyncers } from '../notify.js';
|
|
14
20
|
import { createServiceAuth } from '../service-auth.js';
|
|
15
21
|
import { parseSpaceUri } from '../uri.js';
|
|
16
22
|
import { applyWrites, SpaceWriteError } from '../writer.js';
|
|
@@ -142,17 +148,110 @@ function recordUri(space, did, collection, rkey) {
|
|
|
142
148
|
return `${space}/${did}/${collection}/${rkey}`;
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
const WRITE_ACTIONS = ['create', 'update', 'delete'];
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Which of the three operations a write in an applyWrites batch is.
|
|
155
|
+
*
|
|
156
|
+
* The proposal made these a closed union discriminated by `$type` after clients
|
|
157
|
+
* had shipped against an `action` string. Both say the same thing, so both are
|
|
158
|
+
* read; anything else is a write this server cannot place.
|
|
159
|
+
*
|
|
160
|
+
* @param {any} write
|
|
161
|
+
* @returns {'create'|'update'|'delete'|null}
|
|
162
|
+
*/
|
|
163
|
+
function writeAction(write) {
|
|
164
|
+
const fromType = String(write?.$type ?? '').match(
|
|
165
|
+
/^com\.atproto\.space\.applyWrites#(create|update|delete)$/,
|
|
166
|
+
);
|
|
167
|
+
if (fromType) return /** @type {any} */ (fromType[1]);
|
|
168
|
+
if (WRITE_ACTIONS.includes(write?.action)) return write.action;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
/**
|
|
146
173
|
* @param {Object} ctx
|
|
147
174
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
175
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
176
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
148
177
|
* @param {() => Promise<string|null>} ctx.getDid
|
|
149
|
-
* @param {() => Promise<
|
|
150
|
-
* @param {(
|
|
178
|
+
* @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
|
|
179
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
|
|
180
|
+
* @param {{collections: string[], check: (write: {space: string|null, collection: string, rkey: string, prevValue: unknown|null, nextValue: unknown|null}) => Promise<void>}} [ctx.recordGuard]
|
|
151
181
|
* @param {typeof fetch} [ctx.fetch]
|
|
152
182
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
153
183
|
*/
|
|
154
184
|
export function createWriteRoutes(ctx) {
|
|
155
|
-
const {
|
|
185
|
+
const {
|
|
186
|
+
spaceStorage,
|
|
187
|
+
linkBlob,
|
|
188
|
+
unlinkBlobs,
|
|
189
|
+
getDid,
|
|
190
|
+
getSigner,
|
|
191
|
+
resolveDid,
|
|
192
|
+
recordGuard,
|
|
193
|
+
} = ctx;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Consult the write guard for one write in a collection it covers. Null to
|
|
197
|
+
* proceed, the refusal Response otherwise. Read-then-check races the write
|
|
198
|
+
* queue; the single account that writes here makes that a non-event.
|
|
199
|
+
* @param {string} space
|
|
200
|
+
* @param {import('../writer.js').SpaceWriteInput} write
|
|
201
|
+
* @returns {Promise<Response|null>}
|
|
202
|
+
*/
|
|
203
|
+
async function guardWrite(space, write) {
|
|
204
|
+
if (!recordGuard || !recordGuard.collections.includes(write.collection)) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const prev = await spaceStorage.getSpaceRecord(
|
|
208
|
+
space,
|
|
209
|
+
write.collection,
|
|
210
|
+
write.rkey,
|
|
211
|
+
);
|
|
212
|
+
try {
|
|
213
|
+
await recordGuard.check({
|
|
214
|
+
space,
|
|
215
|
+
collection: write.collection,
|
|
216
|
+
rkey: write.rkey,
|
|
217
|
+
prevValue: prev ? cborDecode(prev.value) : null,
|
|
218
|
+
nextValue: write.action === 'delete' ? null : (write.record ?? null),
|
|
219
|
+
});
|
|
220
|
+
return null;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
+
const code =
|
|
224
|
+
err instanceof Error && 'code' in err
|
|
225
|
+
? String(/** @type {{code: unknown}} */ (err).code)
|
|
226
|
+
: 'InvalidRequest';
|
|
227
|
+
return errorResponse(code, message);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Record which blobs a space write leaves referenced.
|
|
233
|
+
*
|
|
234
|
+
* The links go in the account's one blob link table, beside those of public
|
|
235
|
+
* records. Orphan reaping asks that table whether anything still points at a
|
|
236
|
+
* blob, so a space record protects its blobs the same way a public record
|
|
237
|
+
* does, with no second table for the reaper to be taught about.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} space
|
|
240
|
+
* @param {string} did
|
|
241
|
+
* @param {import('../writer.js').SpaceWriteInput[]} writes
|
|
242
|
+
* @returns {Promise<void>}
|
|
243
|
+
*/
|
|
244
|
+
async function indexBlobs(space, did, writes) {
|
|
245
|
+
for (const write of writes) {
|
|
246
|
+
const uri = recordUri(space, did, write.collection, write.rkey);
|
|
247
|
+
// An update replaces the record's references rather than adding to them.
|
|
248
|
+
await unlinkBlobs(uri);
|
|
249
|
+
if (write.action === 'delete') continue;
|
|
250
|
+
for (const cid of findBlobRefs(write.record)) {
|
|
251
|
+
await linkBlob(cid, uri, recordTimeOf(write.record));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
156
255
|
|
|
157
256
|
/**
|
|
158
257
|
* Tell the space authority this repo advanced, so it can maintain the writer
|
|
@@ -170,13 +269,21 @@ export function createWriteRoutes(ctx) {
|
|
|
170
269
|
const { spaceDid } = parseSpaceUri(space);
|
|
171
270
|
const did = await getDid();
|
|
172
271
|
if (!did) return;
|
|
173
|
-
// Writing into our own space:
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
272
|
+
// Writing into our own space: this server is both the repo host and the
|
|
273
|
+
// authority, so it does what the authority would have done on receiving
|
|
274
|
+
// the notification. Both halves of that, not just the first — a syncer
|
|
275
|
+
// registered for this space is owed the notice however the write reached
|
|
276
|
+
// it, and a Durable Object cannot post to itself to be told.
|
|
177
277
|
if (spaceDid === did) {
|
|
178
278
|
const hash = await new LtHash(commit.setHash).digest();
|
|
179
279
|
await spaceStorage.putSpaceWriter(space, did, commit.rev, hash);
|
|
280
|
+
await forwardToSyncers(ctx, {
|
|
281
|
+
authorityDid: did,
|
|
282
|
+
space,
|
|
283
|
+
repo: did,
|
|
284
|
+
rev: commit.rev,
|
|
285
|
+
hash,
|
|
286
|
+
});
|
|
180
287
|
return;
|
|
181
288
|
}
|
|
182
289
|
const didDoc = await resolveDid(spaceDid);
|
|
@@ -259,11 +366,19 @@ export function createWriteRoutes(ctx) {
|
|
|
259
366
|
});
|
|
260
367
|
if (scopeError) return scopeError;
|
|
261
368
|
|
|
369
|
+
const refused = await guardWrite(body.space, write);
|
|
370
|
+
if (refused) return refused;
|
|
371
|
+
|
|
262
372
|
try {
|
|
263
373
|
const commit = await applyWrites(spaceStorage, {
|
|
264
374
|
space: body.space,
|
|
265
375
|
writes: [write],
|
|
266
376
|
});
|
|
377
|
+
// After the commit: the write is what makes the references real, and a
|
|
378
|
+
// link to a record that failed to write would keep a blob alive forever.
|
|
379
|
+
await indexBlobs(body.space, did, [
|
|
380
|
+
{ ...write, rkey: commit.results[0].rkey },
|
|
381
|
+
]);
|
|
267
382
|
await fireNotifyWrite(body.space, commit);
|
|
268
383
|
return Response.json(toBody(body, commit.results[0], did));
|
|
269
384
|
} catch (err) {
|
|
@@ -311,11 +426,28 @@ export function createWriteRoutes(ctx) {
|
|
|
311
426
|
if (body.record === undefined) {
|
|
312
427
|
throw new SpaceWriteError('record is required', 'InvalidRequest');
|
|
313
428
|
}
|
|
429
|
+
if (
|
|
430
|
+
body.swapRecord !== undefined &&
|
|
431
|
+
body.swapRecord !== null &&
|
|
432
|
+
typeof body.swapRecord !== 'string'
|
|
433
|
+
) {
|
|
434
|
+
throw new SpaceWriteError(
|
|
435
|
+
'swapRecord must be a CID string or null',
|
|
436
|
+
'InvalidRequest',
|
|
437
|
+
);
|
|
438
|
+
}
|
|
314
439
|
return {
|
|
315
440
|
action: 'put',
|
|
316
441
|
collection: body.collection,
|
|
317
442
|
rkey: body.rkey,
|
|
318
443
|
record: body.record,
|
|
444
|
+
// A pds.js extension the draft proposal does not have: null
|
|
445
|
+
// requires that the record does not exist, a CID requires that
|
|
446
|
+
// exact version, absent skips the check. A server without it
|
|
447
|
+
// ignores the field, so a client falls back to last-write-wins.
|
|
448
|
+
...(body.swapRecord !== undefined
|
|
449
|
+
? { swapCid: body.swapRecord }
|
|
450
|
+
: {}),
|
|
319
451
|
};
|
|
320
452
|
},
|
|
321
453
|
(body, result, did) => ({
|
|
@@ -366,7 +498,6 @@ export function createWriteRoutes(ctx) {
|
|
|
366
498
|
/** @type {import('../writer.js').SpaceWriteInput[]} */
|
|
367
499
|
const writes = [];
|
|
368
500
|
for (const w of body.writes) {
|
|
369
|
-
const type = w?.$type;
|
|
370
501
|
const collection = w?.collection;
|
|
371
502
|
if (typeof collection !== 'string') {
|
|
372
503
|
return errorResponse(
|
|
@@ -374,27 +505,24 @@ export function createWriteRoutes(ctx) {
|
|
|
374
505
|
'each write needs a collection',
|
|
375
506
|
);
|
|
376
507
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
508
|
+
const action = writeAction(w);
|
|
509
|
+
if (!action) {
|
|
510
|
+
return errorResponse(
|
|
511
|
+
'InvalidRequest',
|
|
512
|
+
`Unsupported write type: ${w?.$type ?? w?.action ?? '(none)'}`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
if (action === 'delete') {
|
|
516
|
+
writes.push({ action, collection, rkey: w.rkey });
|
|
517
|
+
} else {
|
|
385
518
|
writes.push({
|
|
386
|
-
action
|
|
519
|
+
action,
|
|
387
520
|
collection,
|
|
388
|
-
|
|
521
|
+
// A create may leave the server to mint the key. An empty string
|
|
522
|
+
// is how the earlier clients say so.
|
|
523
|
+
rkey: action === 'create' ? w.rkey || createTid() : w.rkey,
|
|
389
524
|
record: w.value,
|
|
390
525
|
});
|
|
391
|
-
} else if (type === 'com.atproto.space.applyWrites#delete') {
|
|
392
|
-
writes.push({ action: 'delete', collection, rkey: w.rkey });
|
|
393
|
-
} else {
|
|
394
|
-
return errorResponse(
|
|
395
|
-
'InvalidRequest',
|
|
396
|
-
`Unsupported write type: ${type}`,
|
|
397
|
-
);
|
|
398
526
|
}
|
|
399
527
|
}
|
|
400
528
|
|
|
@@ -405,12 +533,21 @@ export function createWriteRoutes(ctx) {
|
|
|
405
533
|
});
|
|
406
534
|
if (scopeError) return scopeError;
|
|
407
535
|
}
|
|
536
|
+
for (const w of writes) {
|
|
537
|
+
const refused = await guardWrite(body.space, w);
|
|
538
|
+
if (refused) return refused;
|
|
539
|
+
}
|
|
408
540
|
|
|
409
541
|
try {
|
|
410
542
|
const commit = await applyWrites(spaceStorage, {
|
|
411
543
|
space: body.space,
|
|
412
544
|
writes,
|
|
413
545
|
});
|
|
546
|
+
await indexBlobs(
|
|
547
|
+
body.space,
|
|
548
|
+
did,
|
|
549
|
+
writes.map((w, i) => ({ ...w, rkey: commit.results[i].rkey })),
|
|
550
|
+
);
|
|
414
551
|
await fireNotifyWrite(body.space, commit);
|
|
415
552
|
return Response.json({
|
|
416
553
|
results: commit.results.map((r) =>
|
package/src/memory-storage.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The blob store and blob link table createSpaceRoutes wants, in memory. A real
|
|
3
|
+
* deployment passes the account's own store and link table; here they are two
|
|
4
|
+
* Maps that answer the same way.
|
|
5
|
+
*
|
|
6
|
+
* @returns {{blobs: import('@pdsjs/core/ports').BlobPort, linkBlob: (blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>, unlinkBlobs: (recordUri: string) => Promise<void>, links: Map<string, Set<string>>, put: (cid: string, data: Uint8Array, mimeType?: string) => void}}
|
|
7
|
+
*/
|
|
8
|
+
export declare function createMemoryBlobContext(): {
|
|
9
|
+
blobs: import('@pdsjs/core/ports').BlobPort;
|
|
10
|
+
linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
|
|
11
|
+
unlinkBlobs: (recordUri: string) => Promise<void>;
|
|
12
|
+
links: Map<string, Set<string>>;
|
|
13
|
+
put: (cid: string, data: Uint8Array, mimeType?: string) => void;
|
|
14
|
+
};
|
|
1
15
|
/**
|
|
2
16
|
* @returns {import('@pdsjs/core/ports').SpaceStoragePort}
|
|
3
17
|
*/
|