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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0

Spaces follow the October 29 alpha

Merged chadtmiller.com asked to merge into main from chadtmiller.com/pds.js:spaces-alpha-20261029
.changeset/git-spaces-alpha-20261029.mdnew+8−0
@@ -0,0 +1,8 @@
---
'@pdsjs/git': patch
---
The README creates a private repository's space with
`com.atproto.space.createSpace` and a memberspace policy, and adds
collaborators with `com.atproto.memberspace.putMember`, as the spaces alpha of
2026-10-29 names them.
.changeset/spaces-alpha-20261029.mdnew+39−0
@@ -0,0 +1,39 @@
---
'@pdsjs/spaces': patch
'@pdsjs/core': minor
'@pdsjs/storage-sqlite': patch
'@pdsjs/cloudflare': patch
---
Spaces follow the reference's `0.0.0-spaces-alpha-20261029171913`.
`com.atproto.simplespace.*` is gone, with every older spelling it accepted.
- **Policies.** A space holds one policy:
`com.atproto.personalspace.policy`, `com.atproto.memberspace.policy`
(`publicRead`, `publicWrite`, `allowedApps`, `publishPolicy`,
`publishMembers`) or `com.atproto.managedspace.policy` (`managingApp`,
`publishPolicy`). `com.atproto.space.createSpace`, `updateSpace`,
`deleteSpace` and `getSpace` manage it; `updateSpace` replaces it whole and
a change of type drops every member. The authority is no longer a member of
its own space and is always admitted.
- **Members.** `com.atproto.memberspace.putMember` (`read` and `write`
required), `removeMember` and `listMembers` answer `PolicyMismatch` outside a
memberspace. Removing a member, or taking its `write`, drops it from the
writer set, as `com.atproto.space.removeWriter` does for any writer.
- **Credentials.** `getSpaceCredential` checks the user before the app. A user
the policy refuses gets `SpaceNotFound`; an app the space does not allow gets
`AppNotAuthorized`. A managedspace asks its app through
`com.atproto.managedspace.checkUserAccess`.
- **Published records.** `publishPolicy` writes `com.atproto.space.policy/self`
and `publishMembers` writes one `com.atproto.memberspace.member` per member
into the authority's repo in the space. Direct writes to either collection are
refused.
- **clearRecords.** `com.atproto.space.clearRecords` deletes every record the
caller holds in a space.
- **Storage.** `SpaceRow` carries `policy` in place of the six read, write and
app fields, and `SpaceStoragePort` gains `removeAllMembers` and
`removeSpaceWriter`. SQLite and Durable Object storage convert existing spaces
on start: one with a managing app becomes a managedspace and loses its
members, and any other becomes a memberspace that keeps them.
- Account migration of spaces (`importRepo`, `exportSpaceMeta`,
`importSpaceMeta`) is not implemented.
docs/permissioned-data.md+74−37
@@ -10,15 +10,11 @@ the credential that lets a reader pull every member repo. The PDS stores and
serves those repos. It does not assemble them. That job belongs to the
application.
Reads and writes are governed separately. A space names a `readPolicy`, which
decides who may hold a credential, and a `writePolicy`, which decides whose
write notices the authority records and forwards. Under a member-list policy
each member carries the two rights on their own, so a space can admit a reader
who may not write into it.
`@pdsjs/spaces` provides LtHash commits, MACed reads, space credentials,
`space:` OAuth scopes, and the `com.atproto.simplespace` management endpoints.
Node, Deno, and Cloudflare Workers all support it.
A space's policy decides who may hold a credential and whose writes the
authority records. `@pdsjs/spaces` provides LtHash commits, MACed reads, space
credentials, `space:` OAuth scopes, and the `com.atproto.space`,
`com.atproto.memberspace` and `com.atproto.managedspace` methods. Node, Deno,
and Cloudflare Workers all support it.
## Telling a client spaces are here
@@ -28,31 +24,70 @@ Node, Deno, and Cloudflare Workers all support it.
every XRPC method it routes, so the space methods appear in that list exactly
when the feature is on.
## Two spellings of the same request
## Policies
A space holds exactly one policy, set by `createSpace` and replaced whole by
`updateSpace`. The authority always reads and writes its own space, whatever
the policy says.
| Policy | Who reads | Whose writes count |
| --- | --- | --- |
| `com.atproto.personalspace.policy` | the authority alone | the authority alone |
| `com.atproto.memberspace.policy` | anyone under `publicRead`, else members with `read` | anyone under `publicWrite`, else members with `write` |
| `com.atproto.managedspace.policy` | whoever `managingApp` admits | whoever `managingApp` admits |
A memberspace may also name `allowedApps`, the client_ids that may receive a
credential. Absent, any client may. A managedspace asks its managing app on
each credential request, through `com.atproto.managedspace.checkUserAccess`
with service auth for that method. A managing app that does not answer, or
answers no, admits nobody.
`com.atproto.memberspace.putMember`, `removeMember` and `listMembers` work only
on a memberspace. On any other policy they answer `PolicyMismatch`. A member
added without `write`, removed, or changed to lose `write` leaves the writer
set at once, and `com.atproto.space.removeWriter` does the same for any writer.
A change of policy type drops every member.
`com.atproto.space.getSpace` answers the authority with the space's URI and its
policy. Nobody else may read the policy this way.
### Published policy and members
A space may publish its policy to its readers. With `publishPolicy` set, the
authority writes the policy as `com.atproto.space.policy/self` into its own
repo in the space. A memberspace with `publishMembers` set also writes one
`com.atproto.memberspace.member` record per member, keyed by the member's DID.
The server keeps these records in step with the policy and the member list.
Nobody writes those two collections directly: `createRecord`, `putRecord`,
`deleteRecord` and `applyWrites` refuse them with `InvalidRequest`.
### Clearing a repo
`com.atproto.space.clearRecords` deletes every record the caller holds in a
space, in commits of at most 200 writes. It needs a delete scope for each
collection it touches. In the authority's own repo it leaves the published
policy and member records in place.
### Spaces made before policies
The proposal renamed things after implementations had shipped, so a client may
know either spelling and this server answers to both. A client that guesses the
wrong one otherwise reads the whole feature as absent.
A database from the October 22 alpha holds each space's read and write rules
in separate columns. On first start the server converts each space it is the
authority of. A space with a managing app on either side becomes a
managedspace, and its members are dropped, since its app decides access. Any
other becomes a memberspace: public read or write carries over, an app allow
list becomes `allowedApps`, and members keep their rights. Neither kind
publishes anything until its authority asks. The old columns stay in the table
and nothing reads them.
| | Also accepted |
| --- | --- |
| `com.atproto.simplespace.getSpace` | `com.atproto.space.getSpace` |
| `com.atproto.space.getLatestCommit` | `com.atproto.space.getRepoState` |
| `com.atproto.simplespace.putMember` | `com.atproto.simplespace.addMember` |
| `readPolicy` and `writePolicy` | one `policy`, which states both sides |
| `policy` and `appAccess` unions | a nested `config` with a string `policy`, or the older `mintPolicy`, and a sibling `managingApp` |
| no `did` on `createSpace` | a `did` naming the caller |
| `applyWrites` writes tagged `$type` | writes named by an `action` string |
| `spaceType` on `createSpace` and `listSpaces` | `type` |
### Not implemented
Every config field is optional. Absent, a space gets `member-list` on both
policies and `#open`. `getSpace` answers with the two policy unions, the single
`policy` union, and the `#spaceConfig` object together; the single one states
the read side. `addMember` states no rights, so the member it adds reads and
writes.
The reference also moves spaces between servers with an account:
`importRepo` restores space records, and `exportSpaceMeta` and
`importSpaceMeta` carry a space's policy and members. pds.js implements none of
the three.
These pairs stay until the draft settles. `scripts/space-reference/` tracks the
atproto PR; `ngerakines.me/atproto-crates` is the other implementation in use.
`scripts/space-reference/` tracks the atproto PR; `ngerakines.me/atproto-crates`
is the other implementation in use.
## Getting a credential
@@ -74,10 +109,12 @@ holds the private half of. The authority puts that did:key in the credential's
The delegation token comes from the user's own PDS through
`getDelegationToken`, addressed to `<authority DID>#atproto_pds` and signed
with the user's `#atproto` key. It says which user the application acts for,
and a space whose read policy admits only some users needs one. A space with a
public read policy issues a credential without one. Without a token, a space
that is not public answers `SpaceNotFound`, the same as one that does not
exist, so the request cannot learn that it does. A deleted space answers
and a space whose policy admits only some users needs one. A memberspace with
`publicRead` issues a credential without one. The authority checks the user
before the application. A user the policy does not admit, or a missing token on
a space that is not public, answers `SpaceNotFound`, the same as a space that
does not exist, so the request cannot learn that it does. An admitted user
through an application the space does not allow answers `AppNotAuthorized`. A deleted space answers
`SpaceDeleted` either way. A token that does not verify, or one already spent,
is `InvalidDelegationToken`.
@@ -177,7 +214,7 @@ says who: a JWT the client signs with its own authentication key. The authority
resolves the `iss` to its `client-metadata.json`, fetches the key set that
document publishes, and checks the signature against the key its `kid` names.
An `appAccess` allow list rests on that check. Without it the client_id would be
A memberspace's `allowedApps` list rests on that check. Without it the client_id would be
a claim the caller writes for itself, and the list would state a rule the
authority could not enforce.
@@ -209,7 +246,7 @@ space revision, every page with entries ends on one, and only an empty page has
none. A syncer that sees a gap in `prevSpaceRev` catches up from its last
`spaceRev` through `listRepos`.
The authority applies the space's write policy to each notice it receives. A
The authority applies the space's policy to each notice it receives. A
writer the space does not admit stays out of the writer set, so `listRepos`
never names them and no syncer pulls their repo.
@@ -274,7 +311,7 @@ Without the flag the space endpoints do not exist, and token issuance refuses
The proposal and its reference implementation (atproto, published as
`@atproto/space@0.0.0-spaces-alpha-*`; pds.js tracks
`0.0.0-spaces-alpha-20261022141228`) are both drafts. Wire formats may change,
`0.0.0-spaces-alpha-20261029171913`) are both drafts. Wire formats may change,
and a change may need a repo rebuild.
`scripts/space-reference/` tests both directions against the reference. pds.js
examples/runclub/index.js+6−7
@@ -164,21 +164,20 @@ async function main() {
step('Maya creates the club and adds members');
const { uri: space } = await xrpcPost(
maya.base,
'com.atproto.simplespace.createSpace',
'com.atproto.space.createSpace',
{
type: CLUB_TYPE,
spaceType: CLUB_TYPE,
skey: CLUB_SKEY,
readPolicy: { $type: 'com.atproto.simplespace.defs#memberListPolicy' },
writePolicy: { $type: 'com.atproto.simplespace.defs#memberListPolicy' },
appAccess: { $type: 'com.atproto.simplespace.defs#open' },
policy: { $type: 'com.atproto.memberspace.policy' },
},
maya.jwt,
);
console.log(` ${space}`);
// Maya joined when she created the club, so only Sam is added here.
// Maya is the club's authority, which every policy admits, so only Sam is
// added here.
await xrpcPost(
maya.base,
'com.atproto.simplespace.putMember',
'com.atproto.memberspace.putMember',
{ space, did: sam.did, read: true, write: true },
maya.jwt,
);
examples/runclub/README.md+4−3
@@ -12,9 +12,10 @@ npm run dev:runclub # boots everything, feed at http://localhost:2800
What the demo exercises, over plain HTTP:
1. `com.atproto.simplespace.createSpace` / `addMember` — Maya's PDS is the
space authority (she adds herself too; the authority is not implicitly a
member).
1. `com.atproto.space.createSpace` under the `com.atproto.memberspace.policy`
policy, then `com.atproto.memberspace.putMember` for Sam. Maya's PDS is the
space authority, and every policy admits the authority, so she is not on
the member list.
2. `com.atproto.space.createRecord` — each member's runs land in their own
repo on their own PDS. Sam's PDS notifies the authority, which is how his
repo enters the writer set.
packages/cloudflare/src/space.js+17−15
@@ -11,7 +11,7 @@ import {
spaceRecordsQuery,
} from '@pdsjs/core/space-schema';
/** @typedef {{uri: string, space_did: string, space_type: string, is_owner: number, read_policy: string, read_managing_app: string|null, write_policy: string, write_managing_app: string|null, app_access_type: string, app_allowed: string, created_at: string, deleted_at: string|null}} SpaceDbRow */
/** @typedef {{uri: string, space_did: string, space_type: string, is_owner: number, policy_json: string|null, created_at: string, deleted_at: string|null}} SpaceDbRow */
/** @typedef {{did: string, can_read: number, can_write: number}} SpaceMemberDbRow */
/** @typedef {{collection: string, rkey: string, cid: string, value: ArrayBuffer|Uint8Array, repo_rev: string, indexed_at: string}} SpaceRecordDbRow */
/** @typedef {Omit<SpaceRecordDbRow, 'value'> & {value?: ArrayBuffer|Uint8Array}} SpaceRecordListDbRow */
@@ -85,12 +85,7 @@ function toSpaceRow(row) {
spaceDid: row.space_did,
spaceType: row.space_type,
isOwner: row.is_owner === 1,
readPolicy: row.read_policy,
readManagingApp: row.read_managing_app,
writePolicy: row.write_policy,
writeManagingApp: row.write_managing_app,
appAccessType: row.app_access_type,
appAllowed: JSON.parse(row.app_allowed),
policy: row.policy_json ? JSON.parse(row.policy_json) : null,
createdAt: row.created_at,
deletedAt: row.deleted_at,
};
@@ -186,18 +181,13 @@ export function createSpaceStorage(sql, storage) {
async putSpace(space) {
sql.exec(
`INSERT OR REPLACE INTO space
(uri, space_did, space_type, is_owner, read_policy, read_managing_app, write_policy, write_managing_app, app_access_type, app_allowed, created_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(uri, space_did, space_type, is_owner, policy_json, created_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
space.uri,
space.spaceDid,
space.spaceType,
space.isOwner ? 1 : 0,
space.readPolicy,
space.readManagingApp,
space.writePolicy,
space.writeManagingApp,
space.appAccessType,
JSON.stringify(space.appAllowed),
space.policy ? JSON.stringify(space.policy) : null,
space.createdAt,
space.deletedAt,
);
@@ -286,6 +276,10 @@ export function createSpaceStorage(sql, storage) {
: null;
},
async removeAllMembers(space) {
sql.exec('DELETE FROM space_member WHERE space = ?', space);
},
async getSpaceRecord(space, collection, rkey) {
const row = one(
'SELECT collection, rkey, cid, value, repo_rev, indexed_at FROM space_record WHERE space = ? AND collection = ? AND rkey = ?',
@@ -439,6 +433,14 @@ export function createSpaceStorage(sql, storage) {
return row?.head ?? null;
},
async removeSpaceWriter(space, did) {
sql.exec(
'DELETE FROM space_writer WHERE space = ? AND did = ?',
space,
did,
);
},
async putSpaceWriter(space, did, repoRev, data, spaceRev) {
sql.exec(
'INSERT OR REPLACE INTO space_writer (space, did, rev, data, space_rev) VALUES (?, ?, ?, ?, ?)',
packages/core/src/handlers/account.js+3−2
@@ -2721,8 +2721,9 @@ export function createAccountHandlers(ctx) {
spaceType: space.spaceType,
authority: space.spaceDid,
isOwner: space.isOwner,
readPolicy: space.readPolicy,
writePolicy: space.writePolicy,
// The policy's name, as `memberspace` for
// `com.atproto.memberspace.policy`.
policy: space.policy?.$type.split('.').at(-2) ?? null,
createdAt: space.createdAt.slice(0, 10),
deleted: Boolean(space.deletedAt),
members: await browser.countMembers(space.uri),
packages/core/src/account-ui.js+2−2
@@ -686,7 +686,7 @@ ${
/**
* Render the spaces page (permissioned data, proposal 0016).
* @param {{ hostname: string, spaces: Array<{uri: string, spaceType: string, authority: string, isOwner: boolean, readPolicy: string, writePolicy: string, createdAt: string, deleted: boolean, members: number, collections: Array<{collection: string, count: number}>}>, enabled: boolean }} params
* @param {{ hostname: string, spaces: Array<{uri: string, spaceType: string, authority: string, isOwner: boolean, policy: string|null, createdAt: string, deleted: boolean, members: number, collections: Array<{collection: string, count: number}>}>, enabled: boolean }} params
* @returns {string} HTML page content
*/
export function renderSpacesPage({ hostname, spaces, enabled }) {
@@ -701,7 +701,7 @@ export function renderSpacesPage({ hostname, spaces, enabled }) {
return `<div class="row"><div>
<div class="title">${escapeHtml(space.spaceType)}${space.isOwner ? '<span class="tag">yours</span>' : ''}${space.deleted ? '<span class="tag">deleted</span>' : ''}</div>
<div class="meta">${escapeHtml(space.uri)}<br>
Authority ${escapeHtml(space.authority)} · reads ${escapeHtml(space.readPolicy)} · writes ${escapeHtml(space.writePolicy)} · created ${escapeHtml(space.createdAt)}<br>
Authority ${escapeHtml(space.authority)}${space.policy ? ` · ${escapeHtml(space.policy)}` : ''} · created ${escapeHtml(space.createdAt)}<br>
${space.members} member${space.members === 1 ? '' : 's'}${records ? ` · ${records}` : ' · no records'}</div>
</div></div>`;
})
packages/core/src/ports.js+14−7
@@ -607,19 +607,24 @@
* @property {string} spaceDid - the space authority's DID, as carried in `uri`
* @property {string} spaceType - the space type NSID, as carried in `uri`
* @property {boolean} isOwner - whether this actor is the space authority
* @property {string} readPolicy - 'public' | 'member-list' | 'managing-app'
* @property {string|null} readManagingApp - service identifier for a managing-app read policy
* @property {string} writePolicy - 'public' | 'member-list' | 'managing-app'
* @property {string|null} writeManagingApp - service identifier for a managing-app write policy
* @property {string} appAccessType - 'open' | 'allowList'
* @property {string[]} appAllowed - client ids when appAccessType is 'allowList'
* @property {SpacePolicy|null} policy - the policy governing the space, with
* its defaults filled in. Null when this actor is not the authority.
* @property {string} createdAt - ISO timestamp
* @property {string|null} deletedAt - ISO timestamp, set by soft delete
*/
/**
* A space policy, as `com.atproto.space.createSpace` takes it: an open union
* whose `$type` names the policy (`com.atproto.personalspace.policy`,
* `com.atproto.memberspace.policy` or `com.atproto.managedspace.policy`) and
* whose other fields configure it.
*
* @typedef {{$type: string} & Record<string, unknown>} SpacePolicy
*/
/**
* What a member of a space may do. Both flags are host-internal state the
* authority consults when a member-list policy governs the space.
* authority consults when the memberspace policy governs the space.
*
* @typedef {Object} SpaceMemberAccess
* @property {boolean} read - whether the member may hold a space credential
@@ -728,6 +733,7 @@
* @property {(space: string, did: string) => Promise<void>} removeMember
* @property {(space: string, cursor: string|null, limit: number) => Promise<{members: SpaceMemberRow[], cursor: string|null}>} listMembers
* @property {(space: string, did: string) => Promise<SpaceMemberAccess|null>} getMember
* @property {(space: string) => Promise<void>} removeAllMembers
* @property {(space: string, collection: string, rkey: string) => Promise<SpaceRecordRow|null>} getSpaceRecord
* @property {(space: string, collection: string, rkey: string) => Promise<string|null>} getSpaceRecordCid
* @property {(space: string, opts: {collection?: string|null, cursor?: string|null, limit: number, reverse?: boolean, excludeValues?: boolean}) => Promise<{records: SpaceRecordListRow[], cursor: string|null}>} listSpaceRecords
@@ -738,6 +744,7 @@
* @property {(space: string, did: string) => Promise<SpaceWriterRow|null>} getSpaceWriter
* @property {(space: string) => Promise<string|null>} getSpaceRevHead - the latest spaceRev allocated in the space
* @property {(space: string, did: string, repoRev: string, data: Uint8Array, spaceRev: string) => Promise<void>} putSpaceWriter
* @property {(space: string, did: string) => Promise<void>} removeSpaceWriter
* @property {(space: string, cursor: string|null, limit: number) => Promise<{writers: SpaceWriterRow[], cursor: string|null}>} listSpaceWriters - ascending spaceRev after `cursor`, exclusive; `cursor` is the last entry's spaceRev, null on an empty page
* @property {(space: string, jtis: string[], expiresAt: string) => Promise<void>} putCredentialRevocations - idempotent; also drops expired revocations
* @property {(space: string, jti: string) => Promise<boolean>} isCredentialRevoked
packages/core/src/space-schema.js+32−1
@@ -23,7 +23,8 @@ export const SPACE_SCHEMA_SQL = `
app_access_type TEXT NOT NULL DEFAULT 'open',
app_allowed TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
deleted_at TEXT
deleted_at TEXT,
policy_json TEXT
);
-- space_did and space_type are the URI's authority and type components,
-- supplied by the domain rather than re-derived here: parsing space URIs is
@@ -152,6 +153,36 @@ export const SPACE_MIGRATIONS = [
// A commit's digest is its `data`.
'ALTER TABLE space_writer RENAME COLUMN hash TO data',
'ALTER TABLE space_notify_outbox RENAME COLUMN hash TO data',
// A space is governed by one policy object. The six simplespace columns
// above hold a space's configuration from before policies, and only these
// statements read them. They convert it as the reference's migration 006
// does. A managing app on either side makes a managedspace, which keeps no
// member list and restricts no apps. Any other configuration makes a
// memberspace.
'ALTER TABLE space ADD COLUMN policy_json TEXT',
// Before the conversion below, so it touches only the spaces it converts.
`DELETE FROM space_member WHERE space IN (
SELECT uri FROM space
WHERE policy_json IS NULL AND is_owner = 1
AND COALESCE(read_managing_app, write_managing_app) IS NOT NULL)`,
`UPDATE space SET policy_json = CASE
WHEN COALESCE(read_managing_app, write_managing_app) IS NOT NULL THEN
json_object(
'$type', 'com.atproto.managedspace.policy',
'managingApp', COALESCE(read_managing_app, write_managing_app),
'publishPolicy', json('false'))
ELSE json_patch(
json_object(
'$type', 'com.atproto.memberspace.policy',
'publicRead', json(CASE WHEN read_policy = 'public' THEN 'true' ELSE 'false' END),
'publicWrite', json(CASE WHEN write_policy = 'public' THEN 'true' ELSE 'false' END),
'publishPolicy', json('false'),
'publishMembers', json('false')),
CASE WHEN app_access_type = 'allowList'
THEN json_object('allowedApps', json(app_allowed))
ELSE '{}' END)
END
WHERE policy_json IS NULL AND is_owner = 1`,
];
// A record's position in its repo. The reference orders by the record's AT-URI,
packages/core/test/include-scope.test.js+1−1
packages/git/test/oauth-remote.test.js+10−3
packages/git/test/space-remote.test.js+8−4
packages/git/README.md+1−1
packages/git-ui/src/lib/spaceSupport.js+1−1
packages/spaces/src/handlers/auth.js+44−49
packages/spaces/src/handlers/manage.js+208−349
557 lines changed: not loaded until asked for.
packages/spaces/src/handlers/write.js+86−54
packages/spaces/src/account.js+1−2
packages/spaces/src/admin.js+50−12
packages/spaces/src/attestation.js+2−2
packages/spaces/src/memory-storage.js+20−3
packages/spaces/src/notify.js+57−0
packages/spaces/src/policy.jsnew+272−0
packages/spaces/src/space-row.js+7−6
packages/spaces/test/account-api.test.js+8−4
packages/spaces/test/auth.test.js+98−21
packages/spaces/test/clear-records.test.jsnew+207−0
packages/spaces/test/manage.test.js+305−264
569 lines changed: not loaded until asked for.
packages/spaces-ui/src/lib/format.js+19−4
packages/spaces-ui/src/lib/policy.js+70−78
packages/spaces-ui/src/lib/spaces.js+24−29
packages/spaces-ui/src/pages/space.jsx+141−89
packages/spaces-ui/test/format.test.js+26−14
packages/spaces-ui/test/policy.test.js+99−102
packages/spaces-ui/test/render.test.jsx+2−3
packages/spaces-ui/test/spaces.test.js+23−17
packages/storage-sqlite/src/space.js+17−15
packages/storage-sqlite/test/space.test.js+148−0
scripts/space-reference/README.md+3−3
scripts/space-reference/run.sh+2−2
scripts/dev-spaces-demo.mjs+6−3
test/fixtures/space-reference-manifest.json+17−17
test/helpers/space-storage-conformance.js+57−41
test/account-page.test.js+3−12
test/service-describe.test.js+2−2