~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1107 lines · 34.3 KB Raw History
// The /account/api/drive endpoints: session-gated, served by the drive
// extension over fake storage, write path blocked in read-only mode.

import { PersonalDataServer } from '@pdsjs/core';
import {
  CID,
  cborEncodeDagCbor,
  cidToString,
  createBlobCid,
  createCid,
} from '@pdsjs/core/repo';
import { describe, expect, it } from 'vitest';
import { createDriveAccountRoutes } from '../src/account.js';
import { createDriveBrowser } from '../src/browser.js';
import { createDriveWriter } from '../src/writer.js';

const DID = 'did:plc:driveapitest';
const PASSWORD = 'account-api-password';
const PDS_HOST = 'pds.example.com';

const NOTES = new TextEncoder().encode('the notes');
const SHOT = new TextEncoder().encode('jpeg bytes here');

/** @param {{name: string, parent?: string}} folder */
function folderRecord(folder) {
  return cborEncodeDagCbor({
    $type: 'dev.pdsjs.drive.folder',
    name: folder.name,
    ...(folder.parent ? { parent: folder.parent } : {}),
    createdAt: '2026-08-14T00:00:00.000Z',
  });
}

/**
 * @param {{name: string, folder?: string, mimeType: string, data: Uint8Array}} file
 */
async function fileRecord(file) {
  return cborEncodeDagCbor({
    $type: 'dev.pdsjs.drive.file',
    name: file.name,
    ...(file.folder ? { folder: file.folder } : {}),
    parts: [
      {
        $type: 'blob',
        ref: new CID(await createBlobCid(file.data)),
        mimeType: 'application/octet-stream',
        size: file.data.length,
      },
    ],
    mimeType: file.mimeType,
    createdAt: '2026-08-14T00:00:00.000Z',
  });
}

/**
 * @param {{drive?: boolean, readOnly?: boolean, upload?: boolean, streaming?: boolean, spaces?: Array<{uri: string, spaceType: string, deletedAt?: string}>}} [opts]
 */
async function makePds(opts = {}) {
  /** @type {Map<string, {uri: string, cid: string, value: Uint8Array}>} */
  const records = new Map();
  // One folder record, so a file can name it by key the way the lexicon says.
  for (const folder of [{ rkey: '3photostid', name: 'photos' }]) {
    const value = folderRecord(folder);
    const uri = `at://${DID}/dev.pdsjs.drive.folder/${folder.rkey}`;
    records.set(uri, { uri, cid: cidToString(await createCid(value)), value });
  }
  const specs = [
    {
      rkey: '3notestid',
      name: 'notes.txt',
      mimeType: 'text/plain',
      data: NOTES,
    },
    {
      rkey: '3shottid',
      name: 'shot.jpg',
      folder: '3photostid',
      mimeType: 'image/jpeg',
      data: SHOT,
    },
  ];
  for (const spec of specs) {
    const value = await fileRecord(spec);
    const uri = `at://${DID}/dev.pdsjs.drive.file/${spec.rkey}`;
    records.set(uri, { uri, cid: cidToString(await createCid(value)), value });
  }

  const deleted = /** @type {string[]} */ ([]);
  const unlinked = /** @type {string[]} */ ([]);
  const created = /** @type {Array<{type: string, skey: string}>} */ ([]);
  // The repo event log, which the change feed reads to build a delta.
  /** @type {Array<{seq: number, evt: Uint8Array}>} */
  const events = [];
  const actorStorage = /** @type {any} */ ({
    getDid: async () => DID,
    getHandle: async () => 'user.example.com',
    getAccountStatus: async () => 'active',
    getRecord: async (/** @type {string} */ uri) => records.get(uri) || null,
    listRecords: async (/** @type {string} */ collection) => ({
      records: [...records.values()]
        .filter((row) => row.uri.includes(`/${collection}/`))
        .map((row) => ({ ...row })),
      cursor: null,
    }),
    getLatestSeq: async () => events.length,
    getEventsBefore: async (
      /** @type {number} */ before,
      /** @type {number} */ limit,
    ) =>
      events
        .filter((row) => row.seq < before)
        .sort((left, right) => right.seq - left.seq)
        .slice(0, limit),
    deleteRecord: async (/** @type {string} */ uri) => {
      deleted.push(uri);
      records.delete(uri);
    },
    unlinkBlobsFromRecord: async (/** @type {string} */ uri) => {
      unlinked.push(uri);
    },
    putBlock: async () => {},
    putRecord: async (
      /** @type {string} */ uri,
      /** @type {string} */ cid,
      /** @type {string} */ _collection,
      /** @type {string} */ _rkey,
      /** @type {Uint8Array} */ value,
    ) => {
      records.set(uri, { uri, cid, value });
    },
    putBlob: async () => {},
    linkBlobToRecord: async () => {},
  });

  /** @type {Map<string, Uint8Array>} */
  const written = new Map();
  const blobs = /** @type {any} */ ({
    put: async (
      /** @type {string} */ _did,
      /** @type {string} */ cid,
      /** @type {Uint8Array} */ data,
    ) => {
      written.set(cid, data);
    },
    get: async (/** @type {string} */ _did, /** @type {string} */ cid) => {
      for (const data of [NOTES, SHOT]) {
        if (cidToString(await createBlobCid(data)) === cid) {
          return { data, mimeType: 'application/octet-stream' };
        }
      }
      const stored = written.get(cid);
      return stored
        ? { data: stored, mimeType: 'application/octet-stream' }
        : null;
    },
    ...(opts.streaming
      ? {
          createWriter: async () => {
            /** @type {Uint8Array[]} */
            const staged = [];
            return {
              write: async (/** @type {Uint8Array} */ chunk) => {
                staged.push(chunk);
              },
              commit: async (/** @type {string} */ cid) => {
                const size = staged.reduce((sum, c) => sum + c.length, 0);
                const data = new Uint8Array(size);
                let at = 0;
                for (const c of staged) {
                  data.set(c, at);
                  at += c.length;
                }
                written.set(cid, data);
              },
              abort: async () => {},
            };
          },
        }
      : {}),
  });

  const pds = new PersonalDataServer({
    actorStorage,
    sharedStorage: /** @type {any} */ ({}),
    blobs,
    jwtSecret: 'account-api-secret',
    hostname: PDS_HOST,
    password: PASSWORD,
    readOnly: opts.readOnly,
    blobUploadLimit: 64,
    extensions:
      opts.drive === false
        ? []
        : [
            (host) => {
              const ports = { actorStorage, blobs, getDid: host.getDid };
              const browser = createDriveBrowser(ports);
              const writer =
                opts.upload === false
                  ? null
                  : createDriveWriter({
                      ...ports,
                      createCommit: host.createCommit,
                      chunkSize: host.blobUploadLimit,
                    });
              return {
                name: 'drive',
                collections: ['dev.pdsjs.drive.file', 'dev.pdsjs.drive.folder'],
                routes: createDriveAccountRoutes({
                  host,
                  browser,
                  writer,
                  spaceBrowser: opts.spaces
                    ? /** @type {any} */ ({
                        listSpaces: async () =>
                          opts.spaces?.map((space) => ({
                            deletedAt: null,
                            ...space,
                          })),
                        countRecords: async () => [],
                      })
                    : null,
                  spaceAdmin: opts.spaces
                    ? /** @type {any} */ ({
                        createSpace: async (
                          /** @type {{type: string, skey: string}} */ made,
                        ) => {
                          created.push(made);
                          return {
                            uri: `at://${DID}/space/${made.type}/${made.skey}`,
                          };
                        },
                      })
                    : null,
                }),
                features: { drive: true, driveUpload: Boolean(writer) },
              };
            },
          ],
  });
  // Commit and reap run their full course in integration; here the API
  // contract is under test, not the repo machinery.
  const commits = /** @type {Array<{action: string, uri: string}[]>} */ ([]);
  pds.createCommit = /** @type {any} */ (
    async (/** @type {string} */ _did, /** @type {any[]} */ writes) => {
      commits.push(writes);
      // The real commit appends to the event log; the change feed reads it.
      events.push({
        seq: events.length + 1,
        evt: cborEncodeDagCbor({
          ops: writes.map((write) => ({
            action: write.action,
            path: write.uri.split('/').slice(-2).join('/'),
          })),
        }),
      });
    }
  );
  let reaped = 0;
  pds.reapOrphanedBlobs = /** @type {any} */ (
    async () => {
      reaped++;
      return [];
    }
  );
  return {
    pds,
    deleted,
    unlinked,
    commits,
    records,
    created,
    reapCount: () => reaped,
  };
}

/** @param {PersonalDataServer} pds */
async function signInCookie(pds) {
  const response = await pds.fetch(
    new Request(`https://${PDS_HOST}/account/sign-in`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        username: DID,
        password: PASSWORD,
      }).toString(),
    }),
  );
  const header = response.headers.get('set-cookie') || '';
  const value = decodeURIComponent(
    header.match(/pdsjs_account=([^;]*)/)?.[1] ?? '',
  );
  expect(value).not.toBe('');
  return `pdsjs_account=${encodeURIComponent(value)}`;
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} path
 * @param {{cookie?: string, method?: string, body?: unknown}} [opts]
 */
async function apiRaw(pds, path, opts = {}) {
  return pds.fetch(
    new Request(`https://${PDS_HOST}${path}`, {
      method: opts.method || 'GET',
      headers: {
        ...(opts.cookie ? { Cookie: opts.cookie } : {}),
        ...(opts.body !== undefined
          ? { 'Content-Type': 'application/json' }
          : {}),
      },
      body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
    }),
  );
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} path
 * @param {{cookie?: string, method?: string, body?: unknown}} [opts]
 */
async function api(pds, path, opts = {}) {
  const response = await apiRaw(pds, path, opts);
  return { status: response.status, body: await response.json() };
}

describe('/account/api/drive/files', () => {
  it('requires a session', async () => {
    const { pds } = await makePds();
    const { status } = await api(pds, '/account/api/drive/files');
    expect(status).toBe(401);
  });

  it('lists the top level through the browser port', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(pds, '/account/api/drive/files', {
      cookie,
    });
    expect(status).toBe(200);
    expect(body.enabled).toBe(true);
    expect(body.folder).toBe('');
    expect(body.files.map((/** @type {any} */ f) => f.name)).toEqual([
      'notes.txt',
    ]);
    expect(body.folders).toEqual([
      {
        rkey: '3photostid',
        name: 'photos',
        files: 1,
        size: SHOT.length,
        createdAt: '2026-08-14T00:00:00.000Z',
      },
    ]);
    expect(body.totalFiles).toBe(2);
  });

  it('lists a folder by its record key', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { body } = await api(
      pds,
      '/account/api/drive/files?folder=3photostid',
      { cookie },
    );
    expect(body.folder).toBe('3photostid');
    expect(body.path).toBe('photos');
    expect(body.ancestry).toEqual([{ rkey: '3photostid', name: 'photos' }]);
    expect(body.files.map((/** @type {any} */ f) => f.name)).toEqual([
      'shot.jpg',
    ]);
    expect(body.folders).toEqual([]);
  });

  it('refuses a folder that is not there', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(
      pds,
      '/account/api/drive/files?folder=nosuchfolder',
      { cookie },
    );
    expect(status).toBe(400);
    expect(body.error).toMatch(/not valid/);
  });

  it('has no routes without the extension', async () => {
    const { pds } = await makePds({ drive: false });
    const cookie = await signInCookie(pds);
    const response = await apiRaw(pds, '/account/api/drive/files', {
      cookie,
    });
    expect(response.status).toBe(404);
  });
});

describe('/account/api/drive/file', () => {
  it('serves the bytes as an attachment', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const response = await apiRaw(
      pds,
      '/account/api/drive/file?rkey=3notestid',
      { cookie },
    );
    expect(response.status).toBe(200);
    expect(response.headers.get('content-type')).toBe('text/plain');
    expect(response.headers.get('content-disposition')).toContain(
      'filename="notes.txt"',
    );
    expect(response.headers.get('x-content-type-options')).toBe('nosniff');
    expect(new Uint8Array(await response.arrayBuffer())).toEqual(NOTES);
  });

  it('rejects a missing rkey and an unknown file', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const unnamed = await api(pds, '/account/api/drive/file', { cookie });
    expect(unnamed.status).toBe(400);

    const missing = await api(pds, '/account/api/drive/file?rkey=nope', {
      cookie,
    });
    expect(missing.status).toBe(400);
    expect(missing.body.error).toMatch(/No such file/);
  });
});

describe('/account/api/drive/upload', () => {
  /**
   * @param {PersonalDataServer} pds
   * @param {string} path
   * @param {{cookie?: string, body?: BodyInit, type?: string, origin?: string}} opts
   */
  async function send(pds, path, opts) {
    const response = await pds.fetch(
      new Request(`https://${PDS_HOST}${path}`, {
        method: 'POST',
        headers: {
          ...(opts.cookie ? { Cookie: opts.cookie } : {}),
          'Content-Type': opts.type || 'text/plain',
          ...(opts.origin ? { Origin: opts.origin } : {}),
        },
        body: opts.body,
      }),
    );
    return { status: response.status, body: await response.json() };
  }

  it('stores the body as a file and commits it', async () => {
    const { pds, commits } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await send(
      pds,
      '/account/api/drive/upload?name=hello.txt',
      { cookie, body: 'hello there' },
    );
    expect(status).toBe(200);
    expect(body).toMatchObject({
      name: 'hello.txt',
      folder: '',
      mimeType: 'text/plain',
      size: 11,
      partCount: 1,
    });
    expect(commits).toEqual([
      [{ action: 'create', uri: body.uri, cid: body.cid }],
    ]);

    const listing = await api(pds, '/account/api/drive/files', { cookie });
    expect(listing.body.files.map((/** @type {any} */ f) => f.name)).toContain(
      'hello.txt',
    );
  });

  it('splits a body past the chunk size into several parts', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    // The harness sets a 64-byte chunk, so this needs three blobs.
    const { body } = await send(pds, '/account/api/drive/upload?name=big.bin', {
      cookie,
      body: 'x'.repeat(150),
      type: 'application/octet-stream',
    });
    expect(body.partCount).toBe(3);
    expect(body.size).toBe(150);

    const download = await apiRaw(
      pds,
      `/account/api/drive/file?rkey=${body.rkey}`,
      { cookie },
    );
    expect(await download.text()).toBe('x'.repeat(150));
  });

  it('puts the file in the folder the query names', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { body } = await send(
      pds,
      '/account/api/drive/upload?name=note.txt&folder=3photostid',
      { cookie, body: 'notes' },
    );
    expect(body.folder).toBe('3photostid');
  });

  it('requires a session', async () => {
    const { pds } = await makePds();
    const { status } = await send(pds, '/account/api/drive/upload?name=a.txt', {
      body: 'x',
    });
    expect(status).toBe(401);
  });

  it('refuses a cross-origin post', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status } = await send(pds, '/account/api/drive/upload?name=a.txt', {
      cookie,
      body: 'x',
      origin: 'https://evil.example',
    });
    expect(status).toBe(403);
  });

  it('rejects a name that is not one path segment', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await send(
      pds,
      '/account/api/drive/upload?name=a%2Fb.txt',
      { cookie, body: 'x' },
    );
    expect(status).toBe(400);
    expect(body.error).toMatch(/file name/);
  });

  it('is blocked in read-only mode', async () => {
    const { pds } = await makePds({ readOnly: true });
    const cookie = await signInCookie(pds);
    const { status } = await send(pds, '/account/api/drive/upload?name=a.txt', {
      cookie,
      body: 'x',
    });
    expect(status).toBe(403);
  });

  it('reports the feature off without a writer', async () => {
    const { pds } = await makePds({ upload: false });
    const cookie = await signInCookie(pds);
    const { status, body } = await send(
      pds,
      '/account/api/drive/upload?name=a.txt',
      { cookie, body: 'x' },
    );
    expect(status).toBe(400);
    expect(body.error).toMatch(/not enabled/);
  });
});

describe('/account/api/drive/delete', () => {
  it('trashes the file, leaving the bytes where they are', async () => {
    const { pds, deleted, unlinked, reapCount } = await makePds();
    const cookie = await signInCookie(pds);
    const { status } = await api(pds, '/account/api/drive/delete', {
      cookie,
      method: 'POST',
      body: { rkey: '3notestid' },
    });
    expect(status).toBe(200);

    // Nothing removed, nothing unlinked, no sweep: the bytes have to survive
    // for a restore to give them back.
    expect(deleted).toEqual([]);
    expect(unlinked).toEqual([]);
    expect(reapCount()).toBe(0);

    const listing = await api(pds, '/account/api/drive/files', { cookie });
    expect(
      listing.body.files.map((/** @type {any} */ f) => f.name),
    ).not.toContain('notes.txt');
    const trash = await api(pds, '/account/api/drive/trash', { cookie });
    expect(trash.body.items.map((/** @type {any} */ i) => i.name)).toEqual([
      'notes.txt',
    ]);
  });

  it('restores what it trashed', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    await api(pds, '/account/api/drive/delete', {
      cookie,
      method: 'POST',
      body: { rkey: '3notestid' },
    });
    const { status, body } = await api(pds, '/account/api/drive/restore', {
      cookie,
      method: 'POST',
      body: { rkey: '3notestid' },
    });
    expect(status).toBe(200);
    expect(body.restored).toBe(1);

    const listing = await api(pds, '/account/api/drive/files', { cookie });
    expect(listing.body.files.map((/** @type {any} */ f) => f.name)).toContain(
      'notes.txt',
    );
  });

  it('frees the bytes only when the trash is emptied', async () => {
    const { pds, deleted, unlinked, reapCount } = await makePds();
    const cookie = await signInCookie(pds);
    const uri = `at://${DID}/dev.pdsjs.drive.file/3notestid`;
    await api(pds, '/account/api/drive/delete', {
      cookie,
      method: 'POST',
      body: { rkey: '3notestid' },
    });
    const { status, body } = await api(pds, '/account/api/drive/purge', {
      cookie,
      method: 'POST',
      body: { rkey: '3notestid' },
    });
    expect(status).toBe(200);
    expect(body.purged).toBe(1);
    expect(unlinked).toEqual([uri]);
    expect(deleted).toEqual([uri]);
    expect(reapCount()).toBe(1);
  });

  it('rejects an unknown file and a missing rkey', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const missing = await api(pds, '/account/api/drive/delete', {
      cookie,
      method: 'POST',
      body: { rkey: 'nope' },
    });
    expect(missing.status).toBe(400);
    expect(missing.body.error).toMatch(/No such file/);

    const unnamed = await api(pds, '/account/api/drive/delete', {
      cookie,
      method: 'POST',
      body: {},
    });
    expect(unnamed.status).toBe(400);
  });

  it('is blocked in read-only mode while listing still works', async () => {
    const { pds, deleted } = await makePds({ readOnly: true });
    const cookie = await signInCookie(pds);
    const list = await api(pds, '/account/api/drive/files', { cookie });
    expect(list.status).toBe(200);

    const removal = await api(pds, '/account/api/drive/delete', {
      cookie,
      method: 'POST',
      body: { rkey: '3notestid' },
    });
    expect(removal.status).toBe(403);
    expect(deleted).toHaveLength(0);
  });
});

describe('/account/api/drive/file ranges', () => {
  it('advertises ranges and serves one', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const whole = await apiRaw(pds, '/account/api/drive/file?rkey=3notestid', {
      cookie,
    });
    expect(whole.headers.get('accept-ranges')).toBe('bytes');

    const response = await pds.fetch(
      new Request(`https://${PDS_HOST}/account/api/drive/file?rkey=3notestid`, {
        headers: { Cookie: cookie, Range: 'bytes=4-7' },
      }),
    );
    expect(response.status).toBe(206);
    expect(response.headers.get('content-range')).toBe(
      `bytes 4-7/${NOTES.length}`,
    );
    expect(await response.text()).toBe(
      new TextDecoder().decode(NOTES.subarray(4, 8)),
    );
  });

  it('serves a suffix range', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const response = await pds.fetch(
      new Request(`https://${PDS_HOST}/account/api/drive/file?rkey=3notestid`, {
        headers: { Cookie: cookie, Range: 'bytes=-3' },
      }),
    );
    expect(response.status).toBe(206);
    expect(await response.text()).toBe(
      new TextDecoder().decode(NOTES.subarray(NOTES.length - 3)),
    );
  });

  it('answers 416 for a range past the end', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const response = await pds.fetch(
      new Request(`https://${PDS_HOST}/account/api/drive/file?rkey=3notestid`, {
        headers: { Cookie: cookie, Range: 'bytes=999-1200' },
      }),
    );
    expect(response.status).toBe(416);
    expect(response.headers.get('content-range')).toBe(
      `bytes */${NOTES.length}`,
    );
  });
});

describe('/account/api/drive/folder', () => {
  it('makes, renames and removes a folder', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);

    const made = await api(pds, '/account/api/drive/folder', {
      cookie,
      method: 'POST',
      body: { name: 'plans' },
    });
    expect(made.status).toBe(200);
    expect(made.body.name).toBe('plans');

    const renamed = await api(pds, '/account/api/drive/folder/rename', {
      cookie,
      method: 'POST',
      body: { rkey: made.body.rkey, name: 'roadmap' },
    });
    expect(renamed.status).toBe(200);
    expect(renamed.body.name).toBe('roadmap');

    const listing = await api(
      pds,
      `/account/api/drive/files?folder=${made.body.rkey}`,
      { cookie },
    );
    expect(listing.body.path).toBe('roadmap');

    const removed = await api(pds, '/account/api/drive/folder/delete', {
      cookie,
      method: 'POST',
      body: { rkey: made.body.rkey },
    });
    expect(removed.status).toBe(200);
  });

  it('rejects an unnamed folder and one that is not there', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const unnamed = await api(pds, '/account/api/drive/folder', {
      cookie,
      method: 'POST',
      body: {},
    });
    expect(unnamed.status).toBe(400);

    const missing = await api(pds, '/account/api/drive/folder/delete', {
      cookie,
      method: 'POST',
      body: { rkey: 'nosuch' },
    });
    expect(missing.status).toBe(400);
  });

  it('is blocked in read-only mode', async () => {
    const { pds } = await makePds({ readOnly: true });
    const cookie = await signInCookie(pds);
    const { status } = await api(pds, '/account/api/drive/folder', {
      cookie,
      method: 'POST',
      body: { name: 'plans' },
    });
    expect(status).toBe(403);
  });
});

describe('/account/api/drive/changes', () => {
  it('tells a caller with no anchor to enumerate', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(pds, '/account/api/drive/changes', {
      cookie,
    });
    expect(status).toBe(200);
    expect(body.reenumerate).toBe(true);
    expect(typeof body.anchor).toBe('number');
  });

  it('tells a caller watching a space to enumerate', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { body } = await api(
      pds,
      '/account/api/drive/changes?since=0&space=at%3A%2F%2Fx%2Fspace%2Fa%2Fb',
      { cookie },
    );
    // A space keeps its own commit log, which the repo sequence does not cover.
    expect(body.reenumerate).toBe(true);
    expect(body.anchor).toBe(null);
  });

  it('rejects an anchor that is not a number', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status } = await api(pds, '/account/api/drive/changes?since=abc', {
      cookie,
    });
    expect(status).toBe(400);
  });
});

describe('the change feed as a client would walk it', () => {
  it('reports what changed since an anchor, and what went away', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);

    const first = await api(pds, '/account/api/drive/changes', { cookie });
    const anchor = first.body.anchor;

    const made = await api(pds, '/account/api/drive/folder', {
      cookie,
      method: 'POST',
      body: { name: 'plans' },
    });
    const removal = await api(pds, '/account/api/drive/delete', {
      cookie,
      method: 'POST',
      body: { rkey: '3notestid' },
    });
    expect(removal.status).toBe(200);

    const delta = await api(pds, `/account/api/drive/changes?since=${anchor}`, {
      cookie,
    });
    expect(delta.body.reenumerate).toBe(false);
    expect(delta.body.changed).toContain(
      `dev.pdsjs.drive.folder/${made.body.rkey}`,
    );
    expect(delta.body.deleted).toContain('dev.pdsjs.drive.file/3notestid');
    expect(delta.body.anchor).toBeGreaterThan(anchor);
  });

  it('reports nothing when nothing has happened', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const first = await api(pds, '/account/api/drive/changes', { cookie });
    const again = await api(
      pds,
      `/account/api/drive/changes?since=${first.body.anchor}`,
      { cookie },
    );
    expect(again.body.changed).toEqual([]);
    expect(again.body.deleted).toEqual([]);
  });
});

describe('which spaces a drive is offered', () => {
  const uriFor = (/** @type {string} */ type, /** @type {string} */ key) =>
    `at://${DID}/space/${type}/${key}`;

  it("offers only spaces made for a drive, never another app's", async () => {
    const { pds } = await makePds({
      spaces: [
        // An app's own space holds that app's data model, and its members
        // read what is in it. A file put there is squatting and is shared.
        {
          uri: uriFor('app.bulleted.space', 'self'),
          spaceType: 'app.bulleted.space',
        },
        {
          uri: uriFor('sh.tangled.repo', '3msw4lgrmisi4'),
          spaceType: 'sh.tangled.repo',
        },
        {
          uri: uriFor('dev.pdsjs.drive.space', 'photos'),
          spaceType: 'dev.pdsjs.drive.space',
        },
      ],
    });
    const cookie = await signInCookie(pds);
    const { body } = await api(pds, '/account/api/drive/files', { cookie });
    expect(body.spaces).toEqual([
      {
        uri: uriFor('dev.pdsjs.drive.space', 'photos'),
        type: 'dev.pdsjs.drive.space',
        key: 'photos',
        name: 'photos',
      },
    ]);
  });

  it('names a drive by the key its owner chose', async () => {
    const { pds } = await makePds({
      spaces: [
        {
          uri: uriFor('dev.pdsjs.drive.space', 'photos'),
          spaceType: 'dev.pdsjs.drive.space',
        },
        {
          uri: uriFor('dev.pdsjs.drive.space', 'work'),
          spaceType: 'dev.pdsjs.drive.space',
        },
      ],
    });
    const cookie = await signInCookie(pds);
    const { body } = await api(pds, '/account/api/drive/files', { cookie });
    // Every drive space shares one type, so the key is what tells them apart.
    expect(body.spaces.map((/** @type {any} */ s) => s.name)).toEqual([
      'photos',
      'work',
    ]);
  });

  it('leaves out a deleted drive', async () => {
    const { pds } = await makePds({
      spaces: [
        {
          uri: uriFor('dev.pdsjs.drive.space', 'gone'),
          spaceType: 'dev.pdsjs.drive.space',
          deletedAt: '2026-08-15T00:00:00.000Z',
        },
      ],
    });
    const cookie = await signInCookie(pds);
    const { body } = await api(pds, '/account/api/drive/files', { cookie });
    expect(body.spaces).toEqual([]);
  });
});

describe('/account/api/drive/space', () => {
  it('makes a space of the drive type, not of any other', async () => {
    const { pds, created } = await makePds({ spaces: [] });
    const cookie = await signInCookie(pds);
    const { status, body } = await api(pds, '/account/api/drive/space', {
      cookie,
      method: 'POST',
      body: { name: 'photos' },
    });
    expect(status).toBe(200);
    expect(created).toEqual([
      { type: 'dev.pdsjs.drive.space', skey: 'photos' },
    ]);
    expect(body).toMatchObject({ key: 'photos', name: 'photos' });
  });

  it('refuses a name that would not read as one', async () => {
    const { pds } = await makePds({ spaces: [] });
    const cookie = await signInCookie(pds);
    for (const name of ['', 'Photos', 'my photos', 'a/b', '-lead']) {
      const { status } = await api(pds, '/account/api/drive/space', {
        cookie,
        method: 'POST',
        body: { name },
      });
      expect(status).toBe(400);
    }
  });

  it('reports the feature off where spaces are not enabled', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(pds, '/account/api/drive/space', {
      cookie,
      method: 'POST',
      body: { name: 'photos' },
    });
    expect(status).toBe(400);
    expect(body.error).toMatch(/not enabled/);

    const listing = await api(pds, '/account/api/drive/files', { cookie });
    expect(listing.body.canMakeSpace).toBe(false);
  });

  it('is blocked in read-only mode', async () => {
    const { pds } = await makePds({ spaces: [], readOnly: true });
    const cookie = await signInCookie(pds);
    const { status } = await api(pds, '/account/api/drive/space', {
      cookie,
      method: 'POST',
      body: { name: 'photos' },
    });
    expect(status).toBe(403);
  });
});

describe('/account/api/drive/upload sessions', () => {
  /**
   * @param {PersonalDataServer} pds
   * @param {string} id
   * @param {string} cookie
   * @param {string} text
   */
  async function sendPart(pds, id, cookie, text) {
    const response = await pds.fetch(
      new Request(
        `https://${PDS_HOST}/account/api/drive/upload/part?id=${id}`,
        {
          method: 'POST',
          headers: {
            Cookie: cookie,
            'Content-Type': 'application/octet-stream',
          },
          body: text,
        },
      ),
    );
    return { status: response.status, body: await response.json() };
  }

  it('takes a file across several requests and lands it as one blob', async () => {
    const { pds, records } = await makePds({ streaming: true });
    const cookie = await signInCookie(pds);

    const started = await api(pds, '/account/api/drive/upload/start', {
      cookie,
      method: 'POST',
      body: { name: 'clip.mp4', mimeType: 'video/mp4' },
    });
    expect(started.status).toBe(200);
    const { id } = started.body;

    const first = await sendPart(pds, id, cookie, 'the first part ');
    expect(first.status).toBe(200);
    expect(first.body.received).toBe(15);
    const second = await sendPart(pds, id, cookie, 'and the second');
    expect(second.body.received).toBe(29);

    const finished = await api(pds, '/account/api/drive/upload/finish', {
      cookie,
      method: 'POST',
      body: { id },
    });
    expect(finished.status).toBe(200);
    expect(finished.body).toMatchObject({
      name: 'clip.mp4',
      mimeType: 'video/mp4',
      size: 29,
      partCount: 1,
    });
    expect(records.has(finished.body.uri)).toBe(true);
  });

  it('advertises the part size only when the port streams', async () => {
    const streaming = await makePds({ streaming: true });
    const cookie = await signInCookie(streaming.pds);
    const listing = await api(
      streaming.pds,
      '/account/api/drive/files?folder=',
      {
        cookie,
      },
    );
    expect(listing.body.uploadPartSize).toBeGreaterThan(0);

    const buffered = await makePds();
    const other = await signInCookie(buffered.pds);
    const plain = await api(buffered.pds, '/account/api/drive/files?folder=', {
      cookie: other,
    });
    expect(plain.body.uploadPartSize).toBe(null);
  });

  it('reports the feature off without a streaming blob port', async () => {
    const { pds } = await makePds();
    const cookie = await signInCookie(pds);
    const { status, body } = await api(pds, '/account/api/drive/upload/start', {
      cookie,
      method: 'POST',
      body: { name: 'a.bin' },
    });
    expect(status).toBe(400);
    expect(body.error).toMatch(/in parts/);
  });

  it('abort forgets the session', async () => {
    const { pds } = await makePds({ streaming: true });
    const cookie = await signInCookie(pds);
    const { body: started } = await api(
      pds,
      '/account/api/drive/upload/start',
      { cookie, method: 'POST', body: { name: 'gone.bin' } },
    );
    await sendPart(pds, started.id, cookie, 'some bytes');

    const aborted = await api(pds, '/account/api/drive/upload/abort', {
      cookie,
      method: 'POST',
      body: { id: started.id },
    });
    expect(aborted.body).toEqual({ aborted: true });

    const finished = await api(pds, '/account/api/drive/upload/finish', {
      cookie,
      method: 'POST',
      body: { id: started.id },
    });
    expect(finished.status).toBe(400);
    expect(finished.body.error).toMatch(/No such upload/);
  });
});