@pdsjs/spaces 2.0.1 → 2.0.2
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 +2 -2
- package/src/authority.d.ts +10 -0
- package/src/authority.js +18 -0
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +15 -0
- package/src/dpop.js +85 -0
- package/src/handlers/auth.d.ts +56 -1
- package/src/handlers/auth.js +112 -31
- package/src/handlers/manage.js +343 -62
- package/src/handlers/read.d.ts +2 -0
- package/src/handlers/read.js +148 -63
- package/src/handlers/write.d.ts +4 -0
- package/src/handlers/write.js +88 -24
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +38 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +7 -0
- package/src/routes.js +11 -0
- package/src/token.d.ts +11 -1
- package/src/token.js +18 -3
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,9 +105,41 @@ 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
144
|
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
114
145
|
* @param {(did: string) => Promise<any>} ctx.resolveDid
|
|
@@ -116,45 +147,9 @@ async function buildSignedCommit({ spaceStorage, space, author, signer }) {
|
|
|
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,6 +1,8 @@
|
|
|
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
7
|
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
6
8
|
* @param {(did: string) => Promise<any>} ctx.resolveDid
|
|
@@ -9,6 +11,8 @@
|
|
|
9
11
|
*/
|
|
10
12
|
export declare function createWriteRoutes(ctx: {
|
|
11
13
|
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
14
|
+
linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
|
|
15
|
+
unlinkBlobs: (recordUri: string) => Promise<void>;
|
|
12
16
|
getDid: () => Promise<string | null>;
|
|
13
17
|
getSigner: () => Promise<{
|
|
14
18
|
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
package/src/handlers/write.js
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
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 { createTid } from '@pdsjs/core/repo';
|
|
10
|
+
import { createTid, findBlobRefs, recordTimeOf } from '@pdsjs/core/repo';
|
|
11
11
|
import { ScopePermissions } from '@pdsjs/core/scope';
|
|
12
12
|
import { spaceHostEndpoint } from '../authority.js';
|
|
13
13
|
import { LtHash } from '../lthash.js';
|
|
14
|
+
import { forwardToSyncers } from '../notify.js';
|
|
14
15
|
import { createServiceAuth } from '../service-auth.js';
|
|
15
16
|
import { parseSpaceUri } from '../uri.js';
|
|
16
17
|
import { applyWrites, SpaceWriteError } from '../writer.js';
|
|
@@ -142,9 +143,32 @@ function recordUri(space, did, collection, rkey) {
|
|
|
142
143
|
return `${space}/${did}/${collection}/${rkey}`;
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
const WRITE_ACTIONS = ['create', 'update', 'delete'];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Which of the three operations a write in an applyWrites batch is.
|
|
150
|
+
*
|
|
151
|
+
* The proposal made these a closed union discriminated by `$type` after clients
|
|
152
|
+
* had shipped against an `action` string. Both say the same thing, so both are
|
|
153
|
+
* read; anything else is a write this server cannot place.
|
|
154
|
+
*
|
|
155
|
+
* @param {any} write
|
|
156
|
+
* @returns {'create'|'update'|'delete'|null}
|
|
157
|
+
*/
|
|
158
|
+
function writeAction(write) {
|
|
159
|
+
const fromType = String(write?.$type ?? '').match(
|
|
160
|
+
/^com\.atproto\.space\.applyWrites#(create|update|delete)$/,
|
|
161
|
+
);
|
|
162
|
+
if (fromType) return /** @type {any} */ (fromType[1]);
|
|
163
|
+
if (WRITE_ACTIONS.includes(write?.action)) return write.action;
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
145
167
|
/**
|
|
146
168
|
* @param {Object} ctx
|
|
147
169
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
170
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
171
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
148
172
|
* @param {() => Promise<string|null>} ctx.getDid
|
|
149
173
|
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
150
174
|
* @param {(did: string) => Promise<any>} ctx.resolveDid
|
|
@@ -152,7 +176,33 @@ function recordUri(space, did, collection, rkey) {
|
|
|
152
176
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
153
177
|
*/
|
|
154
178
|
export function createWriteRoutes(ctx) {
|
|
155
|
-
const { spaceStorage, getDid, getSigner, resolveDid } =
|
|
179
|
+
const { spaceStorage, linkBlob, unlinkBlobs, getDid, getSigner, resolveDid } =
|
|
180
|
+
ctx;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Record which blobs a space write leaves referenced.
|
|
184
|
+
*
|
|
185
|
+
* The links go in the account's one blob link table, beside those of public
|
|
186
|
+
* records. Orphan reaping asks that table whether anything still points at a
|
|
187
|
+
* blob, so a space record protects its blobs the same way a public record
|
|
188
|
+
* does, with no second table for the reaper to be taught about.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} space
|
|
191
|
+
* @param {string} did
|
|
192
|
+
* @param {import('../writer.js').SpaceWriteInput[]} writes
|
|
193
|
+
* @returns {Promise<void>}
|
|
194
|
+
*/
|
|
195
|
+
async function indexBlobs(space, did, writes) {
|
|
196
|
+
for (const write of writes) {
|
|
197
|
+
const uri = recordUri(space, did, write.collection, write.rkey);
|
|
198
|
+
// An update replaces the record's references rather than adding to them.
|
|
199
|
+
await unlinkBlobs(uri);
|
|
200
|
+
if (write.action === 'delete') continue;
|
|
201
|
+
for (const cid of findBlobRefs(write.record)) {
|
|
202
|
+
await linkBlob(cid, uri, recordTimeOf(write.record));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
156
206
|
|
|
157
207
|
/**
|
|
158
208
|
* Tell the space authority this repo advanced, so it can maintain the writer
|
|
@@ -170,13 +220,21 @@ export function createWriteRoutes(ctx) {
|
|
|
170
220
|
const { spaceDid } = parseSpaceUri(space);
|
|
171
221
|
const did = await getDid();
|
|
172
222
|
if (!did) return;
|
|
173
|
-
// Writing into our own space:
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
223
|
+
// Writing into our own space: this server is both the repo host and the
|
|
224
|
+
// authority, so it does what the authority would have done on receiving
|
|
225
|
+
// the notification. Both halves of that, not just the first — a syncer
|
|
226
|
+
// registered for this space is owed the notice however the write reached
|
|
227
|
+
// it, and a Durable Object cannot post to itself to be told.
|
|
177
228
|
if (spaceDid === did) {
|
|
178
229
|
const hash = await new LtHash(commit.setHash).digest();
|
|
179
230
|
await spaceStorage.putSpaceWriter(space, did, commit.rev, hash);
|
|
231
|
+
await forwardToSyncers(ctx, {
|
|
232
|
+
authorityDid: did,
|
|
233
|
+
space,
|
|
234
|
+
repo: did,
|
|
235
|
+
rev: commit.rev,
|
|
236
|
+
hash,
|
|
237
|
+
});
|
|
180
238
|
return;
|
|
181
239
|
}
|
|
182
240
|
const didDoc = await resolveDid(spaceDid);
|
|
@@ -264,6 +322,11 @@ export function createWriteRoutes(ctx) {
|
|
|
264
322
|
space: body.space,
|
|
265
323
|
writes: [write],
|
|
266
324
|
});
|
|
325
|
+
// After the commit: the write is what makes the references real, and a
|
|
326
|
+
// link to a record that failed to write would keep a blob alive forever.
|
|
327
|
+
await indexBlobs(body.space, did, [
|
|
328
|
+
{ ...write, rkey: commit.results[0].rkey },
|
|
329
|
+
]);
|
|
267
330
|
await fireNotifyWrite(body.space, commit);
|
|
268
331
|
return Response.json(toBody(body, commit.results[0], did));
|
|
269
332
|
} catch (err) {
|
|
@@ -366,7 +429,6 @@ export function createWriteRoutes(ctx) {
|
|
|
366
429
|
/** @type {import('../writer.js').SpaceWriteInput[]} */
|
|
367
430
|
const writes = [];
|
|
368
431
|
for (const w of body.writes) {
|
|
369
|
-
const type = w?.$type;
|
|
370
432
|
const collection = w?.collection;
|
|
371
433
|
if (typeof collection !== 'string') {
|
|
372
434
|
return errorResponse(
|
|
@@ -374,27 +436,24 @@ export function createWriteRoutes(ctx) {
|
|
|
374
436
|
'each write needs a collection',
|
|
375
437
|
);
|
|
376
438
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
439
|
+
const action = writeAction(w);
|
|
440
|
+
if (!action) {
|
|
441
|
+
return errorResponse(
|
|
442
|
+
'InvalidRequest',
|
|
443
|
+
`Unsupported write type: ${w?.$type ?? w?.action ?? '(none)'}`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (action === 'delete') {
|
|
447
|
+
writes.push({ action, collection, rkey: w.rkey });
|
|
448
|
+
} else {
|
|
385
449
|
writes.push({
|
|
386
|
-
action
|
|
450
|
+
action,
|
|
387
451
|
collection,
|
|
388
|
-
|
|
452
|
+
// A create may leave the server to mint the key. An empty string
|
|
453
|
+
// is how the earlier clients say so.
|
|
454
|
+
rkey: action === 'create' ? w.rkey || createTid() : w.rkey,
|
|
389
455
|
record: w.value,
|
|
390
456
|
});
|
|
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
457
|
}
|
|
399
458
|
}
|
|
400
459
|
|
|
@@ -411,6 +470,11 @@ export function createWriteRoutes(ctx) {
|
|
|
411
470
|
space: body.space,
|
|
412
471
|
writes,
|
|
413
472
|
});
|
|
473
|
+
await indexBlobs(
|
|
474
|
+
body.space,
|
|
475
|
+
did,
|
|
476
|
+
writes.map((w, i) => ({ ...w, rkey: commit.results[i].rkey })),
|
|
477
|
+
);
|
|
414
478
|
await fireNotifyWrite(body.space, commit);
|
|
415
479
|
return Response.json({
|
|
416
480
|
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
|
*/
|
package/src/memory-storage.js
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
// @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort
|
|
1
|
+
// @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort, and the blob side
|
|
2
|
+
// of the route context.
|
|
2
3
|
//
|
|
3
4
|
// Backs unit tests and gives handler tests a store with no database. Keyed
|
|
4
5
|
// exactly like the SQL adapters so the shared conformance suite exercises the
|
|
5
6
|
// same behaviour everywhere.
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The blob store and blob link table createSpaceRoutes wants, in memory. A real
|
|
10
|
+
* deployment passes the account's own store and link table; here they are two
|
|
11
|
+
* Maps that answer the same way.
|
|
12
|
+
*
|
|
13
|
+
* @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}}
|
|
14
|
+
*/
|
|
15
|
+
export function createMemoryBlobContext() {
|
|
16
|
+
/** @type {Map<string, {data: Uint8Array, mimeType: string}>} */
|
|
17
|
+
const stored = new Map();
|
|
18
|
+
/** @type {Map<string, Set<string>>} record uri -> blob cids */
|
|
19
|
+
const links = new Map();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
blobs: {
|
|
23
|
+
async get(_did, cid) {
|
|
24
|
+
const blob = stored.get(cid);
|
|
25
|
+
return blob ? { ...blob, data: new Uint8Array(blob.data) } : null;
|
|
26
|
+
},
|
|
27
|
+
async put(_did, cid, data, mimeType) {
|
|
28
|
+
stored.set(cid, { data: new Uint8Array(data), mimeType });
|
|
29
|
+
},
|
|
30
|
+
async delete(_did, cid) {
|
|
31
|
+
stored.delete(cid);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
async linkBlob(blobCid, recordUri) {
|
|
35
|
+
let forRecord = links.get(recordUri);
|
|
36
|
+
if (!forRecord) {
|
|
37
|
+
forRecord = new Set();
|
|
38
|
+
links.set(recordUri, forRecord);
|
|
39
|
+
}
|
|
40
|
+
forRecord.add(blobCid);
|
|
41
|
+
},
|
|
42
|
+
async unlinkBlobs(recordUri) {
|
|
43
|
+
links.delete(recordUri);
|
|
44
|
+
},
|
|
45
|
+
links,
|
|
46
|
+
put(cid, data, mimeType = 'application/octet-stream') {
|
|
47
|
+
stored.set(cid, { data, mimeType });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
7
52
|
/**
|
|
8
53
|
* Keyset pagination over a sorted key list.
|
|
9
54
|
* @template T
|
|
@@ -117,17 +162,31 @@ export function createMemorySpaceStorage() {
|
|
|
117
162
|
return records.get(space)?.get(`${collection}/${rkey}`)?.cid ?? null;
|
|
118
163
|
},
|
|
119
164
|
|
|
120
|
-
async listSpaceRecords(
|
|
165
|
+
async listSpaceRecords(
|
|
166
|
+
space,
|
|
167
|
+
{ collection, cursor, limit, reverse, excludeValues },
|
|
168
|
+
) {
|
|
121
169
|
const inSpace = records.get(space) ?? new Map();
|
|
122
|
-
|
|
123
|
-
.filter((r) => r.collection === collection)
|
|
124
|
-
.map((r) => r.rkey)
|
|
170
|
+
let keys = [...inSpace.values()]
|
|
171
|
+
.filter((r) => !collection || r.collection === collection)
|
|
172
|
+
.map((r) => `${r.collection}/${r.rkey}`)
|
|
125
173
|
.sort();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
174
|
+
// Descending unless the caller asks otherwise, matching the reference.
|
|
175
|
+
if (!reverse) keys.reverse();
|
|
176
|
+
if (cursor) {
|
|
177
|
+
keys = keys.filter((k) => (reverse ? k > cursor : k < cursor));
|
|
178
|
+
}
|
|
179
|
+
const items = keys.slice(0, limit).map((key) => {
|
|
180
|
+
const row = inSpace.get(key);
|
|
181
|
+
const { value, ...rest } = row;
|
|
182
|
+
return excludeValues
|
|
183
|
+
? { ...rest }
|
|
184
|
+
: { ...rest, value: new Uint8Array(value) };
|
|
129
185
|
});
|
|
130
|
-
return {
|
|
186
|
+
return {
|
|
187
|
+
records: items,
|
|
188
|
+
cursor: items.length === limit ? keys[limit - 1] : null,
|
|
189
|
+
};
|
|
131
190
|
},
|
|
132
191
|
|
|
133
192
|
async listAllSpaceRecords(space) {
|
|
@@ -226,19 +285,27 @@ export function createMemorySpaceStorage() {
|
|
|
226
285
|
space,
|
|
227
286
|
serviceDid,
|
|
228
287
|
serviceEndpoint,
|
|
229
|
-
|
|
288
|
+
expiresAt,
|
|
230
289
|
) {
|
|
231
290
|
let inSpace = recipients.get(space);
|
|
232
291
|
if (!inSpace) {
|
|
233
292
|
inSpace = new Map();
|
|
234
293
|
recipients.set(space, inSpace);
|
|
235
294
|
}
|
|
236
|
-
inSpace.set(serviceDid, { serviceDid, serviceEndpoint,
|
|
295
|
+
inSpace.set(serviceDid, { serviceDid, serviceEndpoint, expiresAt });
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async deleteCredentialRecipient(space, serviceDid) {
|
|
299
|
+
recipients.get(space)?.delete(serviceDid);
|
|
237
300
|
},
|
|
238
301
|
|
|
239
302
|
async listCredentialRecipients(space) {
|
|
240
303
|
const inSpace = recipients.get(space) ?? new Map();
|
|
241
|
-
|
|
304
|
+
const now = new Date().toISOString();
|
|
305
|
+
return [...inSpace.keys()]
|
|
306
|
+
.sort()
|
|
307
|
+
.map((k) => ({ ...inSpace.get(k) }))
|
|
308
|
+
.filter((r) => r.expiresAt > now);
|
|
242
309
|
},
|
|
243
310
|
};
|
|
244
311
|
}
|
package/src/notify.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward a write notice to every service registered for this space.
|
|
3
|
+
*
|
|
4
|
+
* Best effort, and deliberately so: sync correctness rests on comparing set
|
|
5
|
+
* hashes, so a syncer that misses a notice finds out on the next one or on its
|
|
6
|
+
* own next walk. One unreachable recipient must not fail the others, and none of
|
|
7
|
+
* them may fail the write that produced the notice.
|
|
8
|
+
*
|
|
9
|
+
* The caller awaits this, which puts one round trip per recipient in front of
|
|
10
|
+
* the response. A Worker cancels a promise still running when it answers, and
|
|
11
|
+
* this package holds no execution context to hand the work to, so backgrounding
|
|
12
|
+
* it here would mean dropping notifications on Cloudflare.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} ctx
|
|
15
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
16
|
+
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
17
|
+
* @param {typeof fetch} [ctx.fetch]
|
|
18
|
+
* @param {Object} notice
|
|
19
|
+
* @param {string} notice.authorityDid - the space's authority, which signs
|
|
20
|
+
* @param {string} notice.space
|
|
21
|
+
* @param {string} notice.repo - the account whose repo advanced
|
|
22
|
+
* @param {string} notice.rev
|
|
23
|
+
* @param {Uint8Array} notice.hash - the repo's commit hash after the write
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
export declare function forwardToSyncers(ctx: {
|
|
27
|
+
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
28
|
+
getSigner: () => Promise<{
|
|
29
|
+
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
30
|
+
}>;
|
|
31
|
+
fetch?: typeof fetch;
|
|
32
|
+
}, { authorityDid, space, repo, rev, hash }: {
|
|
33
|
+
authorityDid: string;
|
|
34
|
+
space: string;
|
|
35
|
+
repo: string;
|
|
36
|
+
rev: string;
|
|
37
|
+
hash: Uint8Array;
|
|
38
|
+
}): Promise<void>;
|