Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Promoters find local DJs who fit their shows. DJs (called _projects_: a solo DJ,

All data lives on the AT Protocol. Public profiles are ordinary atproto records; everything else (requests, staff roles, the audit log, booking threads, personal planners) lives in [atproto spaces](https://atproto.com/blog/atproto-spaces-alpha), the protocol's permissioned-data extension, where each space's read policy decides who can read it. Published projects, the DJ stats, sets, press kits, shows and follows their managers and fans publish, and the few Bluesky and userinput.app records the site reads are indexed by [HappyView](https://happyview.dev), an AppView whose Lua scripts answer directory search and the show matcher and serve those records to the site. The app keeps its own SQLite index for space data, which it can rebuild from the network, alongside what exists only there (sessions, notifications, press kit view receipts and the geocode cache). Every public record is read through HappyView, never straight from a PDS or Bluesky's AppView (see [Directory](#directory) for the few exceptions); writes go to the PDS.

> Atproto spaces is an alpha (released 2026). This app is built against `@atproto/*@0.0.0-spaces-alpha-20261029171913` and the [0016 spaces proposal](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data). Expect breaking changes upstream; all versions are pinned.
> Atproto spaces is an alpha (released 2026). This app is built against `@atproto/*@0.0.0-spaces-alpha-20261105160032` and the [0016 spaces proposal](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data). Expect breaking changes upstream; all versions are pinned.

## Features

Expand Down Expand Up @@ -207,7 +207,7 @@ A mix whose link is a plyr.fm track (`https://plyr.fm/track/<id>`) also gets a `

### Sync

The app is a space syncer. For each watched space it asks the space host (the authority's PDS) for a space credential, sending a delegation token from the root's PDS and a fresh P-256 key the credential is bound to, and signs every request with that key (RFC 9421). It enumerates writers with `listRepos`, pulls each repo's oplog with `listRepoOps`, and checks the result against the repo's LtHash commit, falling back to a full download (`getRepo`, a STAR archive) when needed. Commits and records aren't signed: the repo host MACs each response with a secret shared for that one request, derived from the author's `#atproto_kem` key. When a MAC fails, the app refetches the author's DID document once, in case the key rotated. Accounts whose DID document has no KEM key can't be synced, and the system page says so. A deleted space is dropped with everything derived from it, however often the authority says so, and a space recreated under the same URI syncs from scratch. A repo that fails doesn't hold back the others: the pass carries on through the space's later pages, and the repo is retried on its own, after a minute at first and at most an hour once it keeps failing. A record whose projection into the app's tables fails, such as an inquiry read while the directory is down, waits on the same schedule and shows on the system page until it goes through ([ADR 0009](../../docs/adr/0009-synced-records-queue-for-projection.md)). It registers for `notifyWrite` (verified with inter-service auth) and also polls. Records are validated against the lexicons, and authority rules are enforced: only root-authored decisions, roles, and details count, a press kit's grants count only from their space's authority (in a project's request space, only for a press kit of that project) and drafts only from the project's current managers, a booking thread's project comes from the root's opening record (or the root's audit copy of its creation) rather than the inquiry, booking records from non-participants are ignored, and a booking status record counts only when the thread's rules allow its author that move ([ADR 0008](../../docs/adr/0008-booking-status-follows-the-rules-not-record-dates.md)).
The app is a space syncer. For each watched space it asks the space host (the authority's PDS) for a space credential, sending a delegation token from the root's PDS and a fresh P-256 key the credential is bound to, and signs every request with that key (RFC 9421). It enumerates writers with `listRepos`, pulls each repo's oplog with `listRepoOps`, and checks the result against the repo's LtHash commit, falling back to a full download (`getRepo`, a STAR archive) when needed. Commits and records aren't signed: the repo host MACs each response with a secret shared for that one request, derived from the author's `#atproto_kem` key. When a MAC fails, the app refetches the author's DID document once, in case the key rotated. Accounts whose DID document has no KEM key can't be synced, and the system page says so. A deleted space is dropped with everything derived from it, however often the authority says so, and a space recreated under the same URI syncs from scratch. A repo that fails doesn't hold back the others: the pass carries on through the space's later pages, and the repo is retried on its own, after a minute at first and at most an hour once it keeps failing. A writer whose account is taken down, suspended, or deactivated is skipped the same way without failing the pass, and the system page shows why. A space whose authority is taken down or deactivated is only suspended, so its local copy stays. A record whose projection into the app's tables fails, such as an inquiry read while the directory is down, waits on the same schedule and shows on the system page until it goes through ([ADR 0009](../../docs/adr/0009-synced-records-queue-for-projection.md)). It registers for `notifyWrite` (verified with inter-service auth) and also polls. Records are validated against the lexicons, and authority rules are enforced: only root-authored decisions, roles, and details count, a press kit's grants count only from their space's authority (in a project's request space, only for a press kit of that project) and drafts only from the project's current managers, a booking thread's project comes from the root's opening record (or the root's audit copy of its creation) rather than the inquiry, booking records from non-participants are ignored, and a booking status record counts only when the thread's rules allow its author that move ([ADR 0008](../../docs/adr/0008-booking-status-follows-the-rules-not-record-dates.md)).

### Directory

Expand Down
34 changes: 17 additions & 17 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,21 @@
"test:storybook": "vitest run --project storybook"
},
"dependencies": {
"@atproto-labs/did-resolver": "0.0.0-spaces-alpha-20261029171913",
"@atproto-labs/fetch-node": "0.0.0-spaces-alpha-20261029171913",
"@atproto-labs/handle-resolver-node": "0.0.0-spaces-alpha-20261029171913",
"@atproto/api": "0.0.0-spaces-alpha-20261029171913",
"@atproto/common-web": "0.0.0-spaces-alpha-20261029171913",
"@atproto/crypto": "0.0.0-spaces-alpha-20261029171913",
"@atproto/identity": "0.0.0-spaces-alpha-20261029171913",
"@atproto/lex-cbor": "0.0.0-spaces-alpha-20261029171913",
"@atproto/lex-data": "0.0.0-spaces-alpha-20261029171913",
"@atproto/lex-json": "0.0.0-spaces-alpha-20261029171913",
"@atproto/lexicon": "0.0.0-spaces-alpha-20261029171913",
"@atproto/oauth-client-node": "0.0.0-spaces-alpha-20261029171913",
"@atproto/oauth-scopes": "0.0.0-spaces-alpha-20261029171913",
"@atproto/space": "0.0.0-spaces-alpha-20261029171913",
"@atproto/syntax": "0.0.0-spaces-alpha-20261029171913",
"@atproto-labs/did-resolver": "0.0.0-spaces-alpha-20261105160032",
"@atproto-labs/fetch-node": "0.0.0-spaces-alpha-20261105160032",
"@atproto-labs/handle-resolver-node": "0.0.0-spaces-alpha-20261105160032",
"@atproto/api": "0.0.0-spaces-alpha-20261105160032",
"@atproto/common-web": "0.0.0-spaces-alpha-20261105160032",
"@atproto/crypto": "0.0.0-spaces-alpha-20261105160032",
"@atproto/identity": "0.0.0-spaces-alpha-20261105160032",
"@atproto/lex-cbor": "0.0.0-spaces-alpha-20261105160032",
"@atproto/lex-data": "0.0.0-spaces-alpha-20261105160032",
"@atproto/lex-json": "0.0.0-spaces-alpha-20261105160032",
"@atproto/lexicon": "0.0.0-spaces-alpha-20261105160032",
"@atproto/oauth-client-node": "0.0.0-spaces-alpha-20261105160032",
"@atproto/oauth-scopes": "0.0.0-spaces-alpha-20261105160032",
"@atproto/space": "0.0.0-spaces-alpha-20261105160032",
"@atproto/syntax": "0.0.0-spaces-alpha-20261105160032",
"@aws-sdk/client-s3": "^3.1159.0",
"@fontsource-variable/instrument-sans": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
Expand All @@ -80,8 +80,8 @@
"zod": "^4.6.5"
},
"devDependencies": {
"@atproto/dev-env": "0.0.0-spaces-alpha-20261029171913",
"@atproto/sync": "0.0.0-spaces-alpha-20261029171913",
"@atproto/dev-env": "0.0.0-spaces-alpha-20261105160032",
"@atproto/sync": "0.0.0-spaces-alpha-20261105160032",
"@axe-core/playwright": "^4.13.0",
"@lhci/cli": "^0.15.1",
"@playwright/test": "^1.63.0",
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/lib/server/atproto/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
parseMacHeader,
parseSpaceToken,
RECORD_MAC_TYPE,
RepoVerificationError,
SpaceMacError,
verifyRecord,
WANT_MAC_HEADER
Expand Down Expand Up @@ -49,9 +48,12 @@ export function isCredentialRejected(cause: unknown): boolean {
return cause instanceof XrpcResponseError && (cause.status === 401 || CREDENTIAL_REJECTED.has(cause.error));
}

/** Whether a response's MAC didn't verify. `verifyRepoStar` reports a commit that fails its MAC as a RepoVerificationError. */
/**
* Whether a response's MAC didn't verify. Not a RepoVerificationError: that also covers an archive that doesn't
* parse, which a fresh KEM key can't fix.
*/
export function isMacFailure(cause: unknown): boolean {
return cause instanceof SpaceMacError || cause instanceof RepoVerificationError;
return cause instanceof SpaceMacError;
}

type RequestInput = {
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/lib/server/sync/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,15 @@ describe('SpaceSyncer against a repo host', () => {
]);
});

it('reports it with the space, though it never synced', async () => {
const s = await failOnce();

expect((await s.status())[0].repos).toEqual([
{ did: DJ, rev: expect.any(String), lastSyncedAt: expect.any(String), lastError: null },
{ did: BAD, rev: null, lastSyncedAt: null, lastError: 'UpstreamFailure' }
]);
});

it('retries it after its backoff, though listRepos no longer lists it', async () => {
const s = await failOnce();
host.repoErrors.clear();
Expand Down Expand Up @@ -621,6 +630,111 @@ describe('SpaceSyncer against a repo host', () => {
);
});

/** Has the host's getRepo archive rewritten by `edit`, keeping its MAC header. */
const editArchive = (edit: (archive: Buffer) => Buffer) =>
tamper(['getRepo'], async (response) => {
const archive = edit(Buffer.from(await response.arrayBuffer()));

return new Response(new Uint8Array(archive), { headers: response.headers });
});

it('reads a repo archive framed with control bytes, as the alpha host has sent since Nov 5', async () => {
const s = await syncer();
await host.put(DJ, NOTE, 'a', note('one'));
const served: Buffer[] = [];
editArchive((archive) => (served.push(archive), archive));

await s.sync(SPACE);
expect(await records()).toEqual([['a', 'one']]);
// Each record follows a 0x01 byte, and a 0x00 byte ends the archive.
const path = Buffer.from(`${NOTE}/a`);
expect(served[0].includes(Buffer.concat([Buffer.from([1, path.length]), path]))).toBe(true);
expect(served[0].at(-1)).toBe(0);
});

describe('a repo archive that does not read', () => {
const PATH = `${NOTE}/a`;

it.each([
['is cut off before its end byte', (archive: Buffer) => archive.subarray(0, -1), 'unexpected end of archive'],
[
'has a record path that is not one',
(archive: Buffer) => archive.fill('&', archive.indexOf(PATH), archive.indexOf(PATH) + 1),
`invalid NSID in record path: &${PATH.slice(1)}`
]
])("fails the repo when it %s, with the reader's error, and keeps the KEM key", async (_, edit, message) => {
const s = await syncer();
await host.put(DJ, NOTE, 'a', note('one'));
editArchive(edit);

await expect(s.sync(SPACE)).rejects.toThrow(message);
expect(host.identity.refreshed).toEqual([]);
expect(calls('getRepo')).toHaveLength(1);
expect(await db.selectFrom('syncRepoRetry').select('lastError').execute()).toEqual([{ lastError: message }]);
});
});

it('refreshes the KEM key once when a full download fails its MAC', async () => {
const s = await syncer();
await host.put(DJ, NOTE, 'a', note('one'));
host.forgeMacs = true;

await expect(s.sync(SPACE)).rejects.toThrow('Commit does not match its MAC');
expect(host.identity.refreshed).toEqual([DJ]);
expect(calls('getRepo')).toHaveLength(2);
expect(await records()).toEqual([]);
});

it.each(['RepoTakendown', 'RepoSuspended', 'RepoDeactivated'])(
'skips a writer whose repo host answers %s, without failing the pass or downloading the repo',
async (error) => {
const WRITER = 'did:plc:writer';
const s = await syncer();
await host.put(DJ, NOTE, 'a', note('one'));
await host.put(WRITER, NOTE, 'b', note('two'));
await s.sync(SPACE);
const downloads = calls('getRepo').length;

host.repoErrors.set(WRITER, error);
await host.put(WRITER, NOTE, 'c', note('three'));
await host.put(DJ, NOTE, 'd', note('four'));
await s.sync(SPACE);

expect(calls('getRepo')).toHaveLength(downloads);
expect(await records()).toEqual([
['a', 'one'],
['b', 'two'],
['d', 'four']
]);
expect(await db.selectFrom('syncRepoRetry').select(['repoDid', 'lastError']).execute()).toEqual([
{ repoDid: WRITER, lastError: error }
]);
const status = (await s.status())[0];
expect(status.lastError).toBe(error);
expect(status.repos.find((repo) => repo.did === WRITER)?.lastError).toBe(error);
}
);

it.each(['RepoTakendown', 'RepoDeactivated'])(
'keeps a space whose authority answers %s, since that only suspends it',
async (error) => {
await host.put(DJ, NOTE, 'a', note('one'));
await (await syncer()).sync(SPACE);

host.overrides.set('com.atproto.space.listRepos', () => Response.json({ error }, { status: 400 }));
await expect((await syncer()).sync(SPACE)).rejects.toThrow(error);
host.overrides.clear();
host.credentialError = error;
// A new syncer has no credential cached, so it asks the authority for one.
await expect((await syncer()).sync(SPACE)).rejects.toThrow(error);

expect(dropped).toEqual([]);
expect(await records()).toEqual([['a', 'one']]);
expect(await db.selectFrom('syncRepoRetry').selectAll().execute()).toEqual([]);
expect(await db.selectFrom('spaceWatch').select('lastError').execute()).toEqual([{ lastError: error }]);
}
);

it('keeps a failure that is not an Error as its text on the repo', async () => {
const s = await syncer();
await host.put(DJ, NOTE, 'a', note('one'));
Expand Down
Loading
Loading