@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.
@@ -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 { verifySpaceCredential } from './auth.js';
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<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
114
- * @param {(did: string) => Promise<any>} ctx.resolveDid
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, 100);
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
- collection,
238
- url.searchParams.get('cursor'),
239
- limit,
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
- uri: `${target.space}/${did}/${r.collection}/${r.rkey}`,
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 commit = await buildSignedCommit({
330
- spaceStorage,
331
- space: target.space,
332
- author: did,
333
- signer: await getSigner(),
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
@@ -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<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
- * @param {(did: string) => Promise<any>} ctx.resolveDid
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
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
15
- }>;
16
- resolveDid: (did: string) => Promise<any>;
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;
@@ -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 { createTid } from '@pdsjs/core/repo';
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<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
150
- * @param {(did: string) => Promise<any>} ctx.resolveDid
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 { spaceStorage, getDid, getSigner, resolveDid } = ctx;
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: record the writer directly instead of
174
- // notifying ourselves over HTTP. Skipping entirely would leave the
175
- // authority out of its own writer set, and every syncer walking
176
- // listRepos would silently miss this repo.
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
- if (type === 'com.atproto.space.applyWrites#create') {
378
- writes.push({
379
- action: 'create',
380
- collection,
381
- rkey: w.rkey ?? createTid(),
382
- record: w.value,
383
- });
384
- } else if (type === 'com.atproto.space.applyWrites#update') {
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: 'update',
519
+ action,
387
520
  collection,
388
- rkey: w.rkey,
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) =>
@@ -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
  */