diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,23 +8,23 @@ branches: [main, dev] workflow_dispatch: inputs: server: - description: 'Build server (unit tests, e2e, frontend, lint, release)' + description: "Build server (unit tests, e2e, frontend, lint, release)" type: boolean default: true oauth-client: - description: 'Build oauth-client' + description: "Build oauth-client" type: boolean default: false oauth-client-browser: - description: 'Build oauth-client-browser' + description: "Build oauth-client-browser" type: boolean default: false oauth-client-node: - description: 'Build oauth-client-node' + description: "Build oauth-client-node" type: boolean default: false lex-agent: - description: 'Build lex-agent' + description: "Build lex-agent" type: boolean default: false delete: @@ -83,6 +83,46 @@ # --------------------------------------------------------------------------- # Server — unit tests, e2e tests, frontend build, lint # --------------------------------------------------------------------------- + # --------------------------------------------------------------------------- + # Build server binary — shared by playwright and pr-build (amd64) + # --------------------------------------------------------------------------- + build-server: + needs: changes + if: always() && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) + runs-on: depot-ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-release-amd64-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-release-amd64- + + - name: Build release binary + run: | + docker run --rm \ + -v "$PWD:/app" \ + -v "$HOME/.cargo/registry:/usr/local/cargo/registry" \ + -v "$HOME/.cargo/git:/usr/local/cargo/git" \ + -w /app \ + -e SQLX_OFFLINE=true \ + rust:1.93-bookworm \ + cargo build --release + + - name: Upload server binary + uses: actions/upload-artifact@v4 + with: + name: happyview-server + path: target/release/happyview + unit-tests: needs: changes if: always() && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) @@ -176,15 +216,16 @@ steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Cache cargo dependencies + - name: Cache cargo uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git - key: cargo-deps-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + target + key: cargo-lint-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} restore-keys: | - cargo-deps-${{ runner.os }}- + cargo-lint-${{ runner.os }}- - name: Check formatting run: cargo fmt -- --check @@ -193,8 +234,12 @@ - name: Clippy run: cargo clippy --all-targets -- -D warnings playwright: - needs: [changes, frontend] - if: always() && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) && needs.frontend.result == 'success' + needs: [changes, frontend, build-server] + if: >- + always() + && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) + && needs.frontend.result == 'success' + && needs.build-server.result == 'success' runs-on: depot-ubuntu-24.04 steps: - name: Checkout repository @@ -213,28 +258,11 @@ - name: Install Playwright browsers working-directory: web run: npx playwright install chromium --with-deps - - name: Cache cargo - uses: actions/cache@v5 + - name: Download server binary + uses: actions/download-artifact@v4 with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: cargo-release-amd64-${{ hashFiles('Cargo.lock') }} - restore-keys: | - cargo-release-amd64- - - - name: Build server binary - run: | - docker run --rm \ - -v "$PWD:/app" \ - -v "$HOME/.cargo/registry:/usr/local/cargo/registry" \ - -v "$HOME/.cargo/git:/usr/local/cargo/git" \ - -w /app \ - -e SQLX_OFFLINE=true \ - rust:1.93-bookworm \ - cargo build --release - mkdir -p .builder-override/app/target/release - cp target/release/happyview .builder-override/app/target/release/happyview + name: happyview-server + path: .builder-override/app/target/release - name: Log in to ATCR run: echo "${{ secrets.ATCR_PASSWORD }}" | docker login atcr.io -u "${{ secrets.ATCR_USERNAME }}" --password-stdin @@ -269,7 +297,7 @@ # --------------------------------------------------------------------------- # PR builds — compile + Docker on every PR push so reviewers can test # --------------------------------------------------------------------------- pr-build: - needs: changes + needs: [changes, build-server] if: >- github.event_name == 'pull_request' && github.head_ref != 'dev' @@ -294,17 +322,27 @@ steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Cache cargo + - name: Download server binary (amd64) + if: matrix.arch == 'amd64' + uses: actions/download-artifact@v4 + with: + name: happyview-server + path: target/release + + - name: Cache cargo (arm64) + if: matrix.arch != 'amd64' uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git + target key: cargo-release-${{ matrix.arch }}-${{ hashFiles('Cargo.lock') }} restore-keys: | cargo-release-${{ matrix.arch }}- - - name: Build release binary + - name: Build release binary (arm64) + if: matrix.arch != 'amd64' run: | docker run --rm \ -v "$PWD:/app" \ @@ -491,6 +529,7 @@ with: path: | ~/.cargo/registry ~/.cargo/git + target key: cargo-release-${{ matrix.arch }}-${{ hashFiles('Cargo.lock') }} restore-keys: | cargo-release-${{ matrix.arch }}- diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -123,6 +123,18 @@ checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "assert-json-diff" @@ -457,6 +469,20 @@ "serde_core", ] [[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -723,6 +749,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" [[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -783,6 +815,15 @@ "libc", ] [[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] name = "cranelift-bforest" version = "0.116.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1666,6 +1707,7 @@ "atrium-xrpc", "axum", "axum-extra", "base64 0.22.1", + "blake3", "bytes", "chrono", "ciborium", @@ -1677,6 +1719,8 @@ "futures", "futures-util", "hex", "hickory-resolver", + "hkdf", + "hmac", "http-body-util", "ipnet", "jose-jwk", @@ -2925,7 +2969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3618,7 +3662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3629,7 +3673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ wasmtime-wasi = "29" regex = "1.12.3" semver = "1.0" async-stream = "0.3.6" +blake3 = "1" +hkdf = "0.12" +hmac = "0.12" [[bin]] name = "migrate-lua-sql" diff --git a/migrations/postgres/20260626000000_prefix_table_names.sql b/migrations/postgres/20260626000000_prefix_table_names.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260626000000_prefix_table_names.sql @@ -0,0 +1,47 @@ +ALTER TABLE IF EXISTS account_delegates RENAME TO happyview_account_delegates; +ALTER TABLE IF EXISTS admin_api_keys RENAME TO happyview_admin_api_keys; +ALTER TABLE IF EXISTS admins RENAME TO happyview_admins; +ALTER TABLE IF EXISTS api_keys RENAME TO happyview_api_keys; +ALTER TABLE IF EXISTS users RENAME TO happyview_users; +ALTER TABLE IF EXISTS api_clients RENAME TO happyview_api_clients; +ALTER TABLE IF EXISTS auth_login_redirects RENAME TO happyview_auth_login_redirects; +ALTER TABLE IF EXISTS backfill_jobs RENAME TO happyview_backfill_jobs; +ALTER TABLE IF EXISTS backfill_repos RENAME TO happyview_backfill_repos; +ALTER TABLE IF EXISTS dead_letter_hooks RENAME TO happyview_dead_letter_hooks; +ALTER TABLE IF EXISTS dead_letter_scripts RENAME TO happyview_dead_letter_scripts; +ALTER TABLE IF EXISTS delegated_accounts RENAME TO happyview_delegated_accounts; +ALTER TABLE IF EXISTS domains RENAME TO happyview_domains; +ALTER TABLE IF EXISTS dpop_keys RENAME TO happyview_dpop_keys; +ALTER TABLE IF EXISTS dpop_sessions RENAME TO happyview_dpop_sessions; +ALTER TABLE IF EXISTS event_logs RENAME TO happyview_event_logs; +ALTER TABLE IF EXISTS external_account_tokens RENAME TO happyview_external_account_tokens; +ALTER TABLE IF EXISTS external_auth_state RENAME TO happyview_external_auth_state; +ALTER TABLE IF EXISTS instance_settings RENAME TO happyview_instance_settings; +ALTER TABLE IF EXISTS labeler_subscriptions RENAME TO happyview_labeler_subscriptions; +ALTER TABLE IF EXISTS labels RENAME TO happyview_labels; +ALTER TABLE IF EXISTS lexicons RENAME TO happyview_lexicons; +ALTER TABLE IF EXISTS network_lexicons RENAME TO happyview_network_lexicons; +ALTER TABLE IF EXISTS oauth_sessions RENAME TO happyview_oauth_sessions; +ALTER TABLE IF EXISTS oauth_state RENAME TO happyview_oauth_state; +ALTER TABLE IF EXISTS plugin_configs RENAME TO happyview_plugin_configs; +ALTER TABLE IF EXISTS plugin_dedup_keys RENAME TO happyview_plugin_dedup_keys; +ALTER TABLE IF EXISTS plugin_kv RENAME TO happyview_plugin_kv; +ALTER TABLE IF EXISTS plugins RENAME TO happyview_plugins; +ALTER TABLE IF EXISTS rate_limit_allowlist RENAME TO happyview_rate_limit_allowlist; +ALTER TABLE IF EXISTS rate_limit_settings RENAME TO happyview_rate_limit_settings; +ALTER TABLE IF EXISTS rate_limits RENAME TO happyview_rate_limits; +ALTER TABLE IF EXISTS record_refs RENAME TO happyview_record_refs; +ALTER TABLE IF EXISTS records RENAME TO happyview_records; +ALTER TABLE IF EXISTS script_variables RENAME TO happyview_script_variables; +ALTER TABLE IF EXISTS scripts RENAME TO happyview_scripts; +ALTER TABLE IF EXISTS service_entries RENAME TO happyview_service_entries; +ALTER TABLE IF EXISTS service_entry_xrpcs RENAME TO happyview_service_entry_xrpcs; +ALTER TABLE IF EXISTS service_identity RENAME TO happyview_service_identity; +ALTER TABLE IF EXISTS space_credentials RENAME TO happyview_space_credentials; +ALTER TABLE IF EXISTS space_dids RENAME TO happyview_space_dids; +ALTER TABLE IF EXISTS space_invites RENAME TO happyview_space_invites; +ALTER TABLE IF EXISTS space_members RENAME TO happyview_space_members; +ALTER TABLE IF EXISTS space_records RENAME TO happyview_space_records; +ALTER TABLE IF EXISTS space_sync_state RENAME TO happyview_space_sync_state; +ALTER TABLE IF EXISTS spaces RENAME TO happyview_spaces; +ALTER TABLE IF EXISTS user_permissions RENAME TO happyview_user_permissions; diff --git a/migrations/postgres/20260627000000_proposal_0016_alignment.sql b/migrations/postgres/20260627000000_proposal_0016_alignment.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260627000000_proposal_0016_alignment.sql @@ -0,0 +1,71 @@ +-- Proposal 0016: Permissioned Data alignment +-- Restructures spaces for the formal AT Protocol permissioned data spec. + +-- 1. Rename owner_did → authority_did, add creator_did +ALTER TABLE happyview_spaces RENAME COLUMN owner_did TO authority_did; +ALTER TABLE happyview_spaces ADD COLUMN creator_did TEXT; +UPDATE happyview_spaces SET creator_did = authority_did WHERE creator_did IS NULL; +ALTER TABLE happyview_spaces ALTER COLUMN creator_did SET NOT NULL; + +-- 2. Replace access_mode + allowlist/denylist with mint_policy + app_access +-- mint_policy: 'member-list' (default) | 'public' | 'managing-app' +ALTER TABLE happyview_spaces ADD COLUMN mint_policy TEXT NOT NULL DEFAULT 'member-list'; +-- app_access: JSON open union, e.g. {"type": "open"} or {"type": "allowList", "allowed": [...]} +ALTER TABLE happyview_spaces ADD COLUMN app_access TEXT NOT NULL DEFAULT '{"type":"open"}'; +-- Migrate existing data +UPDATE happyview_spaces +SET app_access = '{"type":"allowList","allowed":' || COALESCE(app_allowlist, '[]') || '}' +WHERE access_mode = 'default_deny' AND app_allowlist IS NOT NULL; +-- Drop old columns +ALTER TABLE happyview_spaces DROP COLUMN access_mode; +ALTER TABLE happyview_spaces DROP COLUMN app_allowlist; +ALTER TABLE happyview_spaces DROP COLUMN app_denylist; + +-- 3. Per-user repo state: LtHash state + signed commit +CREATE TABLE happyview_space_repo_state ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + lthash_state BYTEA NOT NULL DEFAULT decode(repeat('00', 2048), 'hex'), + rev TEXT, + hash BYTEA, + ikm BYTEA, + sig BYTEA, + mac BYTEA, + updated_at TEXT NOT NULL, + UNIQUE (space_id, author_did) +); +CREATE INDEX idx_space_repo_state_space ON happyview_space_repo_state(space_id); + +-- 4. Record operation log +CREATE TABLE happyview_space_record_oplog ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + rev TEXT NOT NULL, + idx INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + cid TEXT, + prev TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_oplog_space_author ON happyview_space_record_oplog(space_id, author_did); +CREATE INDEX idx_space_oplog_rev ON happyview_space_record_oplog(space_id, author_did, rev); + +-- 5. Write notification registrations +CREATE TABLE happyview_space_notify_registrations ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT, + endpoint TEXT NOT NULL, + registered_by TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_notify_space ON happyview_space_notify_registrations(space_id); +CREATE INDEX idx_space_notify_repo ON happyview_space_notify_registrations(space_id, author_did); + +-- 6. Drop old sync state table +DROP TABLE IF EXISTS happyview_space_sync_state; diff --git a/migrations/postgres/20260627000001_verification_methods.sql b/migrations/postgres/20260627000001_verification_methods.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260627000001_verification_methods.sql @@ -0,0 +1,8 @@ +CREATE TABLE happyview_verification_methods ( + id TEXT PRIMARY KEY, + fragment_id TEXT NOT NULL UNIQUE, + key_type TEXT NOT NULL DEFAULT 'Multikey', + public_key_multibase TEXT NOT NULL, + private_key_enc BYTEA NOT NULL, + created_at TEXT NOT NULL +); diff --git a/migrations/sqlite/20260626000000_prefix_table_names.sql b/migrations/sqlite/20260626000000_prefix_table_names.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260626000000_prefix_table_names.sql @@ -0,0 +1,47 @@ +ALTER TABLE account_delegates RENAME TO happyview_account_delegates; +ALTER TABLE admin_api_keys RENAME TO happyview_admin_api_keys; +ALTER TABLE admins RENAME TO happyview_admins; +ALTER TABLE api_keys RENAME TO happyview_api_keys; +ALTER TABLE users RENAME TO happyview_users; +ALTER TABLE api_clients RENAME TO happyview_api_clients; +ALTER TABLE auth_login_redirects RENAME TO happyview_auth_login_redirects; +ALTER TABLE backfill_jobs RENAME TO happyview_backfill_jobs; +ALTER TABLE backfill_repos RENAME TO happyview_backfill_repos; +ALTER TABLE dead_letter_hooks RENAME TO happyview_dead_letter_hooks; +ALTER TABLE dead_letter_scripts RENAME TO happyview_dead_letter_scripts; +ALTER TABLE delegated_accounts RENAME TO happyview_delegated_accounts; +ALTER TABLE domains RENAME TO happyview_domains; +ALTER TABLE dpop_keys RENAME TO happyview_dpop_keys; +ALTER TABLE dpop_sessions RENAME TO happyview_dpop_sessions; +ALTER TABLE event_logs RENAME TO happyview_event_logs; +ALTER TABLE external_account_tokens RENAME TO happyview_external_account_tokens; +ALTER TABLE external_auth_state RENAME TO happyview_external_auth_state; +ALTER TABLE instance_settings RENAME TO happyview_instance_settings; +ALTER TABLE labeler_subscriptions RENAME TO happyview_labeler_subscriptions; +ALTER TABLE labels RENAME TO happyview_labels; +ALTER TABLE lexicons RENAME TO happyview_lexicons; +ALTER TABLE network_lexicons RENAME TO happyview_network_lexicons; +ALTER TABLE oauth_sessions RENAME TO happyview_oauth_sessions; +ALTER TABLE oauth_state RENAME TO happyview_oauth_state; +ALTER TABLE plugin_configs RENAME TO happyview_plugin_configs; +ALTER TABLE plugin_dedup_keys RENAME TO happyview_plugin_dedup_keys; +ALTER TABLE plugin_kv RENAME TO happyview_plugin_kv; +ALTER TABLE plugins RENAME TO happyview_plugins; +ALTER TABLE rate_limit_allowlist RENAME TO happyview_rate_limit_allowlist; +ALTER TABLE rate_limit_settings RENAME TO happyview_rate_limit_settings; +ALTER TABLE rate_limits RENAME TO happyview_rate_limits; +ALTER TABLE record_refs RENAME TO happyview_record_refs; +ALTER TABLE records RENAME TO happyview_records; +ALTER TABLE script_variables RENAME TO happyview_script_variables; +ALTER TABLE scripts RENAME TO happyview_scripts; +ALTER TABLE service_entries RENAME TO happyview_service_entries; +ALTER TABLE service_entry_xrpcs RENAME TO happyview_service_entry_xrpcs; +ALTER TABLE service_identity RENAME TO happyview_service_identity; +ALTER TABLE space_credentials RENAME TO happyview_space_credentials; +ALTER TABLE space_dids RENAME TO happyview_space_dids; +ALTER TABLE space_invites RENAME TO happyview_space_invites; +ALTER TABLE space_members RENAME TO happyview_space_members; +ALTER TABLE space_records RENAME TO happyview_space_records; +ALTER TABLE space_sync_state RENAME TO happyview_space_sync_state; +ALTER TABLE spaces RENAME TO happyview_spaces; +ALTER TABLE user_permissions RENAME TO happyview_user_permissions; diff --git a/migrations/sqlite/20260627000000_proposal_0016_alignment.sql b/migrations/sqlite/20260627000000_proposal_0016_alignment.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260627000000_proposal_0016_alignment.sql @@ -0,0 +1,84 @@ +-- Proposal 0016: Permissioned Data alignment + +-- 1+2. Rebuild happyview_spaces with renamed/new columns +CREATE TABLE happyview_spaces_new ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + authority_did TEXT NOT NULL, + creator_did TEXT NOT NULL, + type_nsid TEXT NOT NULL, + skey TEXT NOT NULL, + display_name TEXT, + description TEXT, + mint_policy TEXT NOT NULL DEFAULT 'member-list', + app_access TEXT NOT NULL DEFAULT '{"type":"open"}', + managing_app_did TEXT, + config TEXT NOT NULL DEFAULT '{}', + revision TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (did, type_nsid, skey) +); + +INSERT INTO happyview_spaces_new (id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at) +SELECT id, did, owner_did, owner_did, type_nsid, skey, display_name, description, + 'member-list', + CASE + WHEN access_mode = 'default_deny' AND app_allowlist IS NOT NULL + THEN '{"type":"allowList","allowed":' || app_allowlist || '}' + ELSE '{"type":"open"}' + END, + managing_app_did, config, revision, created_at, updated_at +FROM happyview_spaces; + +DROP TABLE happyview_spaces; +ALTER TABLE happyview_spaces_new RENAME TO happyview_spaces; + +-- 3. Per-user repo state +CREATE TABLE happyview_space_repo_state ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + lthash_state BLOB NOT NULL DEFAULT (zeroblob(2048)), + rev TEXT, + hash BLOB, + ikm BLOB, + sig BLOB, + mac BLOB, + updated_at TEXT NOT NULL, + UNIQUE (space_id, author_did) +); +CREATE INDEX idx_space_repo_state_space ON happyview_space_repo_state(space_id); + +-- 4. Record operation log +CREATE TABLE happyview_space_record_oplog ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + rev TEXT NOT NULL, + idx INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + cid TEXT, + prev TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_oplog_space_author ON happyview_space_record_oplog(space_id, author_did); +CREATE INDEX idx_space_oplog_rev ON happyview_space_record_oplog(space_id, author_did, rev); + +-- 5. Write notification registrations +CREATE TABLE happyview_space_notify_registrations ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT, + endpoint TEXT NOT NULL, + registered_by TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_notify_space ON happyview_space_notify_registrations(space_id); +CREATE INDEX idx_space_notify_repo ON happyview_space_notify_registrations(space_id, author_did); + +-- 6. Drop old sync state table +DROP TABLE IF EXISTS happyview_space_sync_state; diff --git a/migrations/sqlite/20260627000001_verification_methods.sql b/migrations/sqlite/20260627000001_verification_methods.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260627000001_verification_methods.sql @@ -0,0 +1,8 @@ +CREATE TABLE happyview_verification_methods ( + id TEXT PRIMARY KEY, + fragment_id TEXT NOT NULL UNIQUE, + key_type TEXT NOT NULL DEFAULT 'Multikey', + public_key_multibase TEXT NOT NULL, + private_key_enc BLOB NOT NULL, + created_at TEXT NOT NULL +); diff --git a/packages/docs/content/blog/happyview-2.10.md b/packages/docs/content/blog/happyview-2.10.md new file mode 100644 --- /dev/null +++ b/packages/docs/content/blog/happyview-2.10.md @@ -0,0 +1,94 @@ +--- +title: "HappyView v2.10" +description: "Service identity, permissioned spaces, and new blob utilities." +date: 2026-06-27 +author: + name: "Trezy" + avatar: "/authors/trezy.webp" +tags: + - announcements +--- + +This one's been a long time coming. HappyView finally has a real AT Protocol identity, can do service proxying, and permissioned spaces got a big update to match the official spec. + +## Service identity + +When a user's PDS routes a request to your AppView, it resolves the destination by looking up your DID — without a service identity, that lookup fails and standard atproto routing can't reach you. + +There are three modes: + +- **Domain identity (did:web)** - Your domain name becomes your identity. HappyView generates a signing keypair and serves a DID document at `/.well-known/did.json` automatically. The simplest option. +- **Network identity (did:plc)** - Registers a new identity in the PLC directory. This is the most durable option — it survives domain changes if you ever need to migrate. +- **Linked account** - Link your AppView to an existing AT Protocol account. + +With a service identity in place, HappyView can act as a service proxy. A PDS sends a request with an `atproto-proxy` header pointing at your AppView, HappyView verifies the caller via service auth, runs your XRPC handler, and responds. This is how atproto apps are _supposed_ to work — until now HappyView only supported direct connections via DPoP. + +Full docs: [Service Identity](/docs/getting-started/service-identity). + +## Permissioned spaces alignment + +This is the big one. The spaces implementation now aligns with [Dan's proposal](https://github.com/bluesky-social/proposals/pull/94), and if you were using the experimental spaces API before, this is a breaking change. + +### Namespace split + +Endpoints moved from `dev.happyview.space.*` to two namespaces: + +- **`com.atproto.space.*`** — protocol-level routes (queries, data access, credentials) +- **`com.atproto.simplespace.*`** — management routes (create/update/delete spaces, membership) + +The old `dev.happyview.space.*` endpoints will work as aliases until HappyView v3. + +### Access model rewrite + +The old `accessMode` / `appAllowlist` / `appDenylist` system is gone. + +**Mint policy** controls who can create permissioned repos in a space: + +- `member-list` (default) — only members +- `public` — anyone +- `managing-app` — only the managing app + +**App access** controls which third-party apps can interact with the space: + +- `open` (default) — any app +- `allowList` — only explicitly listed apps + +Also, `getMemberGrant` is now `getDelegationToken` (and it's a `GET`, not a `POST`). + +### New concepts + +- **Authority DID** replaces `owner_did`. There's also a new `creator_did` for tracking who originally created the space +- **`read_self` access level** — members can only read their own data within the space +- **Deniable commit signatures** — per-user repo state uses LtHash (homomorphic set-hash) with deniable signatures. The user signs context (space + rev + random input keying material), not content +- **Record operation log** — `listRepoOps` returns the oplog for sync +- **Write notifications** — `registerNotify`, `notifyWrite`, `notifySpaceDeleted` + +Full docs: [Permissioned Spaces](/docs/experimental/spaces/). + +## Blob utilities for Lua + +Two new Lua functions — `atproto.blob_download` and `atproto.blob_upload` — let scripts perform some new magic. For example, you can migrate a blob one PDS to another in just a couple of lines: + +```lua +local downloaded = atproto.blob_download(source_did, old_cid) +local uploaded = atproto.blob_upload(downloaded.handle, downloaded.mimeType) +local new_blob_ref = uploaded.blob +``` + +Full docs: [atproto API — `blob_download` / `blob_upload`](/docs/api-reference/lua/atproto-api#atprotoblob_download). + +## Prefixed database tables + +All HappyView tables are now prefixed with `happyview_` (e.g. `records` → `happyview_records`) so they won't collide with your own tables if you're sharing a database. Existing databases are migrated automatically. + +If you use `db.raw()` in Lua scripts to query HappyView tables directly, you'll need to update your queries to use the prefixed names. + +## Everything else + +- **Setup wizard hardening** — the setup flow handles edge cases better, especially around re-auth and preventing unauthenticated redirects +- **Dynamic cookie security** — cookies now set their security flags based on the request context, which fixes some issues with service proxying behind a reverse proxy +- **Bluesky PDS scope handling** — fixed a compat issue with the scope format Bluesky's PDS returns during OAuth + +## Go play + +Full changelog is on [GitHub](https://github.com/gamesgamesgamesgamesgames/happyview/releases/tag/v2.10.0). If you have questions, feature requests, or just need a little help, join the [Cartridge](https://cartridge.dev) [Discord Server](https://discord.gg/BUPnjaBwRZ) and hop into the `#happyview` channel. diff --git a/packages/docs/content/blog/happyview-2.9.md b/packages/docs/content/blog/happyview-2.9.md --- a/packages/docs/content/blog/happyview-2.9.md +++ b/packages/docs/content/blog/happyview-2.9.md @@ -65,11 +65,9 @@ ```lua local result = db.query({ collection = "com.example.post", filter = { - op = "AND", - conditions = { - { field = "status", value = "published" }, - { field = "views", op = ">", value = 100 }, - }, + combine = "AND", + { field = "status", value = "published" }, + { field = "views", op = ">", value = 100 }, }, }) ``` diff --git a/packages/docs/content/docs/api-reference/admin/admin-api.md b/packages/docs/content/docs/api-reference/admin/admin-api.md --- a/packages/docs/content/docs/api-reference/admin/admin-api.md +++ b/packages/docs/content/docs/api-reference/admin/admin-api.md @@ -6,10 +6,11 @@ The admin API lets you manage lexicons, monitor records, run backfill jobs, and control user access. All endpoints live under `/admin` and require authentication from a DID that exists in the `users` table, with the appropriate [permissions](../../guides/permissions.md) for the endpoint being called. You can also manage all of this through the [web dashboard](../../getting-started/dashboard.md). ## Auth -The admin API supports two authentication methods: +The admin API supports three authentication methods: 1. **API keys** — read/write tokens starting with `hv_`, passed as `Authorization: Bearer hv_...`. See the [API Keys guide](../../guides/api-keys.md) for details. 2. **Service auth JWT** — atproto inter-service authentication via signed JWTs. +3. **Cookie-based session auth** — signed session cookies set during the dashboard OAuth login flow. The [web dashboard](../../getting-started/dashboard.md) uses this method. In all cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request. @@ -53,6 +54,7 @@ | [Labelers](labelers.md) | Manage external labeler subscriptions | | [Records](records.md) | List and delete indexed records | | [Instance Settings](settings.md) | Configure app name, logo, policy URLs, and concurrency settings | | [Domains](domains.md) | Manage domains and their OAuth client identities | +| [Scripts](scripts.md) | Create, list, update, and delete Lua scripts | | [Script Variables](script-variables.md) | Encrypted key/value pairs for Lua scripts | | [API Clients](api-clients.md) | Register and manage third-party XRPC clients | | [Plugins](plugins.md) | Install, configure, and manage WASM plugins | @@ -94,6 +96,11 @@ | `POST /admin/users/transfer-super` | Super user only | | `GET /admin/script-variables` | `script-variables:read` | | `POST /admin/script-variables` | `script-variables:create` | | `DELETE /admin/script-variables/{key}` | `script-variables:delete` | +| `GET /admin/scripts` | `scripts:read` | +| `POST /admin/scripts` | `scripts:manage` | +| `GET /admin/scripts/{id}` | `scripts:read` | +| `PATCH /admin/scripts/{id}` | `scripts:manage` | +| `DELETE /admin/scripts/{id}` | `scripts:manage` | | `POST /admin/labelers` | `labelers:create` | | `GET /admin/labelers` | `labelers:read` | | `PATCH /admin/labelers/{did}` | `labelers:create` | diff --git a/packages/docs/content/docs/api-reference/admin/script-variables.md b/packages/docs/content/docs/api-reference/admin/script-variables.md --- a/packages/docs/content/docs/api-reference/admin/script-variables.md +++ b/packages/docs/content/docs/api-reference/admin/script-variables.md @@ -2,7 +2,7 @@ --- title: "Script Variables" --- -Script variables are encrypted key/value pairs available to Lua scripts via the `vars` global. Use them for secrets like API tokens. +Script variables are encrypted key/value pairs available to Lua scripts via the `env` global. Use them for secrets like API tokens. ```ts tab="TypeScript" tab-group="language" const TOKEN = "hv_..."; // your API key diff --git a/packages/docs/content/docs/api-reference/admin/settings.md b/packages/docs/content/docs/api-reference/admin/settings.md --- a/packages/docs/content/docs/api-reference/admin/settings.md +++ b/packages/docs/content/docs/api-reference/admin/settings.md @@ -74,6 +74,7 @@ | `backfill_concurrent_dids_per_pds` | `BACKFILL_CONCURRENT_DIDS_PER_PDS` | `3` | How many repos to fetch concurrently from each PDS | | `backfill_concurrent_resolution` | `BACKFILL_CONCURRENT_RESOLUTION` | `100` | How many DID document lookups to run in parallel during PDS resolution | | `backfill_retention_days` | `BACKFILL_RETENTION_DAYS` | `28` | Days to keep per-repo detail data from completed backfill jobs. `0` = keep indefinitely | | `verbose_event_logging` | `VERBOSE_EVENT_LOGGING` | `false` | Log every record index, hook execution, and hook skip to the event log. High write volume — recommended only for debugging | +| `feature.spaces_enabled` | `FEATURE_SPACES_ENABLED` | --- | Enables the experimental Permissioned Spaces API. When `"true"`, space endpoints are available. When absent or any other value, space endpoints return `404 FeatureDisabled` | ## Upsert a setting diff --git a/packages/docs/content/docs/api-reference/lua/atproto-api.md b/packages/docs/content/docs/api-reference/lua/atproto-api.md --- a/packages/docs/content/docs/api-reference/lua/atproto-api.md +++ b/packages/docs/content/docs/api-reference/lua/atproto-api.md @@ -108,6 +108,104 @@ end end ``` +## atproto.blob_download + +```lua +local result = atproto.blob_download(did, cid) +``` + +Downloads a blob from any DID's PDS via the public `com.atproto.sync.getBlob` endpoint. No authentication is required. The blob bytes are held on the Rust side as an opaque `BlobHandle` — binary data never enters the Lua VM. + +| Parameter | Type | Description | +| --------- | ------ | ---------------------------------- | +| `did` | string | DID of the repo that owns the blob | +| `cid` | string | CID of the blob to download | + +**Returns:** A table with: + +| Field | Type | Description | +| ---------- | ---------- | -------------------------------------------------------- | +| `handle` | BlobHandle | Opaque handle to the blob bytes (pass to `blob_upload`) | +| `mimeType` | string | Content type from the PDS response (e.g. `"image/png"`) | +| `size` | number | Size of the blob in bytes | + +If the content-type header is missing from the PDS response, `mimeType` defaults to `"application/octet-stream"`. + +**Throws** on any non-2xx response from the PDS, including 404 (blob not found) and 429 (rate limited). Retry logic is the script's responsibility. + +**Availability:** All script contexts (queries, procedures, record scripts). + +### BlobHandle methods + +The `BlobHandle` userdata exposes two methods: + +| Method | Returns | Description | +| ------------- | ------- | --------------------------------- | +| `:size()` | number | Size of the blob in bytes | +| `:mime_type()` | string | MIME type of the blob | + +### Examples + +```lua +-- Download a blob and inspect it +local result = atproto.blob_download("did:plc:abc123", "bafyreie...") +log("downloaded " .. result.size .. " bytes, type: " .. result.mimeType) + +-- The handle can also be queried directly +log("handle size: " .. result.handle:size()) +log("handle mime: " .. result.handle:mime_type()) +``` + +## atproto.blob_upload + +```lua +local response = atproto.blob_upload(handle, content_type) +``` + +Uploads blob bytes to the caller's PDS via authenticated `com.atproto.repo.uploadBlob`. The `handle` must be a `BlobHandle` from `blob_download`. + +| Parameter | Type | Description | +| -------------- | ---------- | -------------------------------------------- | +| `handle` | BlobHandle | Opaque blob handle from `blob_download` | +| `content_type` | string | MIME type for the upload (e.g. `"image/png"`) | + +**Returns:** The PDS `uploadBlob` response, which contains a `blob` field with the new blob reference: + +```lua +{ + blob = { + ["$type"] = "blob", + ref = { ["$link"] = "" }, + mimeType = "image/png", + size = 12345 + } +} +``` + +**Throws** on any error, including 429 (rate limited) and authentication failures. Retry logic is the script's responsibility. + +**Availability:** Procedure scripts only. Returns `nil` in query and record script contexts (no PDS auth available). + +### Examples + +```lua +-- Copy a blob from one repo to another +local downloaded = atproto.blob_download(source_did, old_cid) +local uploaded = atproto.blob_upload(downloaded.handle, downloaded.mimeType) + +-- Use the new blob ref in a record +local new_cid = uploaded.blob.ref["$link"] + +-- Migrate all blobs in a media array +for _, item in ipairs(record.media) do + if item.blob and item.blob.ref then + local dl = atproto.blob_download(source_did, item.blob.ref["$link"]) + local ul = atproto.blob_upload(dl.handle, dl.mimeType) + item.blob = ul.blob + end +end +``` + ## atproto.sign ```lua diff --git a/packages/docs/content/docs/api-reference/lua/database-api.md b/packages/docs/content/docs/api-reference/lua/database-api.md --- a/packages/docs/content/docs/api-reference/lua/database-api.md +++ b/packages/docs/content/docs/api-reference/lua/database-api.md @@ -171,7 +171,7 @@ Parameters are passed as an array and bound to `$1`, `$2`, etc. Supported parameter types: strings, integers, numbers, booleans, and nil. ### SQL dialect -Write SQL in **SQLite syntax** — HappyView translates it to Postgres at runtime if you're using Postgres. See [Database Setup](../../guides/database/database-setup.md) for details on what gets translated. If you need database-specific SQL that can't be translated, check `db.backend()` at runtime. +Unlike the structured API methods (`db.query`, `db.get`, etc.), `db.raw` does **not** translate SQL between backends. Write native SQL for the database you're running against — `$1`/`$2` placeholders for Postgres, `?` for SQLite. Use `db.backend()` to branch when you need to support both. ### Column type mapping diff --git a/packages/docs/content/docs/api-reference/lua/record-api.md b/packages/docs/content/docs/api-reference/lua/record-api.md --- a/packages/docs/content/docs/api-reference/lua/record-api.md +++ b/packages/docs/content/docs/api-reference/lua/record-api.md @@ -2,7 +2,7 @@ --- title: "Record API" --- -The `Record` API is only available in **procedure** scripts. It handles creating, updating, loading, and deleting atproto records. Writes are proxied to the caller's PDS and indexed locally. +The `Record` API is available in **procedure**, **query**, and **record/label** scripts. In procedure scripts the full API is available — writes are proxied to the caller's PDS and indexed locally. In query and record/label scripts it runs in **no-auth mode**: `Record.load`, `r:save_local()`, `r:delete_local()`, and `Record.delete_local()` work, but PDS-touching methods (`r:save()`, `r:delete()`) raise an error. ## Constructor @@ -25,6 +25,10 @@ -- Load multiple records in parallel local records = Record.load_all({ uri1, uri2 }) -- Returns nil entries for URIs not found + +-- Delete a record from the local database only (no PDS call) +local ok = Record.delete_local("at://did:plc:abc/xyz.statusphere.status/abc123") +-- Returns true if deleted, false if not found ``` ## Instance methods @@ -36,6 +40,15 @@ -- Delete from PDS and local database r:delete() +-- Save directly to the local database (no PDS call) +r:save_local() + +-- Delete from the local database only (no PDS call) +r:delete_local() + +-- Set the repo DID (for no-auth contexts like record/label scripts) +r:set_repo("did:plc:abc") + -- Set the record key type (tid, any, nsid, or literal:*) r:set_key_type("tid") @@ -65,8 +78,9 @@ | `_uri` | string? | AT URI — set after `save()`, cleared after `delete()` | | `_cid` | string? | Content hash — set after `save()`, cleared after `delete()` | | `_key_type` | string? | Record key type from the lexicon definition | | `_rkey` | string? | Record key — set via `set_rkey()` or `generate_rkey()` | -| `_collection` | string | Collection NSID (always set) | -| `_schema` | table? | Schema definition from the lexicon (used for validation) | +| `_collection` | string | Collection NSID (always set) | +| `_schema` | table? | Schema definition from the lexicon (used for validation) | +| `_repo_override` | string? | DID set by `set_repo()`, used in no-auth contexts to target a repo | ## Schema validation diff --git a/packages/docs/content/docs/api-reference/lua/utility-globals.md b/packages/docs/content/docs/api-reference/lua/utility-globals.md --- a/packages/docs/content/docs/api-reference/lua/utility-globals.md +++ b/packages/docs/content/docs/api-reference/lua/utility-globals.md @@ -20,7 +20,7 @@ log("processing record: " .. uri) log("count: " .. tostring(n)) ``` -Writes a message to the server logs at debug level. Useful for debugging scripts during development. Log output appears in HappyView's stdout — check your platform's log viewer (Railway logs, `docker logs`, terminal output) to see it. +Writes a message to the server logs at debug level and records a `script.log` event in the [event logs](../../api-reference/admin/events.md). Useful for debugging scripts during development. Log output appears in HappyView's stdout and is also accessible via `GET /admin/events`. ## TID diff --git a/packages/docs/content/docs/api-reference/xrpc-api.md b/packages/docs/content/docs/api-reference/xrpc-api.md --- a/packages/docs/content/docs/api-reference/xrpc-api.md +++ b/packages/docs/content/docs/api-reference/xrpc-api.md @@ -8,8 +8,20 @@ If a query or procedure lexicon has a [Lua script](../guides/lua-scripting.md) attached, the script handles the request. Otherwise, HappyView uses built-in default behavior (described below). ## Auth -- **Queries** (`GET /xrpc/{method}`): unauthenticated -- **Procedures** (`POST /xrpc/{method}`): require DPoP authentication (`Authorization: DPoP` + `DPoP` proof header + `X-Client-Key`) +XRPC routes accept several authentication methods: + +- **DPoP auth** — `Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key` +- **Space credentials** — `Authorization: Bearer ` (space-scoped routes only) +- **Service auth JWTs** — `Authorization: Bearer ` (inter-service calls) +- **Cookie-based session auth** — signed session cookies (used by the dashboard, falls back when no `Authorization` header is present) +- **Anonymous** — no auth headers (identity is `nil` in Lua scripts) + +Bearer API keys (`hv_*`) are rejected on XRPC routes — they are only accepted on the [admin API](admin/admin-api.md). + +Default auth behavior: + +- **Queries** (`GET /xrpc/{method}`): unauthenticated by default (identity available if provided) +- **Procedures** (`POST /xrpc/{method}`): require authentication (DPoP, session cookie, or service auth) - **getProfile**: requires auth - **uploadBlob**: requires auth diff --git a/packages/docs/content/docs/experimental/spaces/changelog.md b/packages/docs/content/docs/experimental/spaces/changelog.md --- a/packages/docs/content/docs/experimental/spaces/changelog.md +++ b/packages/docs/content/docs/experimental/spaces/changelog.md @@ -2,7 +2,64 @@ --- title: "Changelog" --- -## Latest +## Latest — Proposal 0016 Alignment + +Major restructuring to align with [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data). + +### Namespace split + +- **Protocol routes** now live under `com.atproto.space.*` (queries, data, credentials) +- **Management routes** now live under `com.atproto.simplespace.*` (create/update/delete spaces, membership, config) +- **`dev.happyview.space.*`** endpoints remain as backward-compatible aliases until v3 +- Invite endpoints remain under `dev.happyview.space.*` as HappyView extensions + +### New terminology + +- **`owner_did` → `authority_did`** — the DID that controls the space. A separate `creator_did` tracks who originally created it. +- **`accessMode` → `mintPolicy`** — controls who can create permissioned repos: `member-list` (default), `public`, or `managing-app` +- **`appAllowlist`/`appDenylist` → `appAccess`** — controls third-party app access: `open` (default) or `allowList` +- **`getMemberGrant` → `getDelegationToken`** — renamed and changed from POST to GET. Returns a delegation token (JWT with `typ: atproto-space-delegation+jwt`, ES256K, 60-second TTL) +- **`redeemInvite` → `acceptInvite`** — renamed for clarity +- **Space credential `typ`** — changed from `space_credential` to `atproto-space-credential+jwt` +- **Space credential TTL** — reduced from 4 hours to 2 hours + +### New access level + +- **`read_self`** — a new membership access level that restricts reads to only the member's own records within the space + +### New endpoints + +- **`com.atproto.space.getRepoState`** (GET) — returns per-user repo state including LtHash state and signed commit +- **`com.atproto.space.listRepoOps`** (GET) — returns the record operation log for sync +- **`com.atproto.space.listRepos`** (GET) — lists repos (authors) in a space +- **`com.atproto.space.getBlob`** (GET) — retrieves a blob from a space +- **`com.atproto.space.registerNotify`** (POST) — registers for write notifications +- **`com.atproto.space.notifyWrite`** (POST) — pushes a write notification +- **`com.atproto.space.notifySpaceDeleted`** (POST) — pushes a space-deleted notification +- **`com.atproto.simplespace.getConfig`** (GET) — gets space configuration (mint policy, app access, managing app) +- **`com.atproto.simplespace.updateConfig`** (POST) — updates space configuration + +### Cryptographic primitives + +- **LtHash** — homomorphic set-hash for per-user repo state. 2048-byte state with 1024 little-endian uint16 lanes using BLAKE3 XOF. Supports insert/remove operations for incremental record tracking. +- **Deniable commit signatures** — users sign context (space DID + rev + random IKM) rather than content hash, producing a MAC that proves authorship without binding the user to specific content. + +### Data model changes + +- New `happyview_space_repo_state` table — per-user LtHash state + signed commit per space +- New `happyview_space_record_oplog` table — ordered record operation log per space +- New `happyview_space_notify_registrations` table — write notification registrations +- Spaces now use `authority_did` and `creator_did` instead of `owner_did` +- `mint_policy` and `app_access` columns replace `access_mode`, `app_allowlist`, `app_denylist` + +### Breaking changes + +- Feature flag disabled response changed from `501 Not Implemented` to `404` with `FeatureDisabled` error code +- Deleting a space now cascades to all associated data (records, members, repo state, oplog, notifications, credentials) + +--- + +## v2.6.0 ### New endpoints diff --git a/packages/docs/content/docs/experimental/spaces/credentials.md b/packages/docs/content/docs/experimental/spaces/credentials.md --- a/packages/docs/content/docs/experimental/spaces/credentials.md +++ b/packages/docs/content/docs/experimental/spaces/credentials.md @@ -6,11 +6,11 @@ This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. -Space credentials are short-lived JWTs for cross-service access to space data. A member proves their membership to get a grant, exchanges the grant for a credential JWT, then passes it to an external service that needs to read the space's records. +Space credentials are short-lived JWTs for cross-service access to space data. A member requests a delegation token to prove their membership, exchanges the token for a credential JWT, then passes it to an external service that needs to read the space's records. ## How credentials work -Credential issuance is a two-step process: +Credential issuance is a two-step process. The delegation token is a short-lived proof of membership (60-second TTL), and the credential is the bearer token used for cross-service access (2-hour TTL). ```mermaid sequenceDiagram @@ -18,12 +18,12 @@ participant App as Client App participant HV as HappyView participant Svc as External Service - App->>HV: POST dev.happyview.space.getMemberGrant
(DPoP auth, must be a member) + App->>HV: GET com.atproto.space.getDelegationToken
(DPoP auth, must be a member) HV->>HV: Verify membership - HV-->>App: grant token + expiresAt + HV-->>App: delegation token + expiresAt - App->>HV: POST dev.happyview.space.getSpaceCredential
(DPoP auth, grant token) - HV->>HV: Verify grant
Check app access (allow/deny list)
Sign credential with space keypair + App->>HV: POST com.atproto.space.getSpaceCredential
(DPoP auth, delegation token) + HV->>HV: Verify delegation token
Check app access
Sign credential with space keypair HV-->>App: credential JWT + expiresAt App->>Svc: Request with Authorization: Bearer credential @@ -34,93 +34,84 @@ ``` Credentials are ES256 JWTs signed with a P-256 keypair unique to each space. The keypair is generated on first credential request and stored encrypted (AES-256-GCM). -## Step 1: Get a member grant +## Step 1: Get a delegation token -The caller must be an authenticated member of the space. The grant is a short-lived token (5 minutes) that proves membership. +The caller must be an authenticated member of the space. The delegation token is a short-lived proof of membership (60-second TTL). + +Note: this endpoint is a GET request (not POST). The previous `getMemberGrant` endpoint (POST) is available as a legacy alias via `dev.happyview.space.getMemberGrant`. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", { - method: "POST", +const params = new URLSearchParams({ + space: "ats://did:plc:abc123/com.example.forum/main", +}); +const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, - "Content-Type": "application/json", }, - body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - }), }); -interface GrantResponse { - grant: string; +interface DelegationTokenResponse { + delegationToken: string; expiresAt: string; } -const data: GrantResponse = await response.json(); +const data: DelegationTokenResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", { - method: "POST", +const params = new URLSearchParams({ + space: "ats://did:plc:abc123/com.example.forum/main", +}); +const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, - "Content-Type": "application/json", }, - body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant") + .get("https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken") + .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) - .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main" - })) .send() .await?; let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" -body := bytes.NewBufferString(`{"space": "ats://did:plc:abc123/com.example.forum/main"}`) -req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", body) +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain", + nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) -req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ - -H 'DPoP: ' \ - -H 'Content-Type: application/json' \ - -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main" - }' + -H 'DPoP: ' ``` **Response:** ```json { - "grant": "eyJhbGciOiJIUzI1NiJ9...", - "expiresAt": "2026-05-09T12:05:00Z" + "delegationToken": "eyJhbGciOiJFUzI1NktFWSJ9...", + "expiresAt": "2026-05-09T12:01:00Z" } ``` ## Step 2: Get a space credential -Exchange the grant for a space credential JWT. The credential is signed by the space's keypair and has a 4-hour TTL. +Exchange the delegation token for a space credential JWT. The credential is signed by the space's keypair and has a 2-hour TTL. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -129,7 +120,7 @@ "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ - grant: "eyJhbGciOiJIUzI1NiJ9...", + grant: "eyJhbGciOiJFUzI1NktFWSJ9...", }), }); interface CredentialResponse { @@ -139,7 +130,7 @@ } const data: CredentialResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -148,28 +139,28 @@ "DPoP": DPOP_PROOF, "Content-Type": "application/json", }, body: JSON.stringify({ - grant: "eyJhbGciOiJIUzI1NiJ9...", + grant: "eyJhbGciOiJFUzI1NktFWSJ9...", }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential") + .post("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "grant": "eyJhbGciOiJIUzI1NiJ9..." + "grant": "eyJhbGciOiJFUzI1NktFWSJ9..." })) .send() .await?; let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" -body := bytes.NewBufferString(`{"grant": "eyJhbGciOiJIUzI1NiJ9..."}`) +body := bytes.NewBufferString(`{"grant": "eyJhbGciOiJFUzI1NktFWSJ9..."}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", body) + "https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -177,13 +168,13 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "grant": "eyJhbGciOiJIUzI1NiJ9..." + "grant": "eyJhbGciOiJFUzI1NktFWSJ9..." }' ``` @@ -192,7 +183,7 @@ ```json { "credential": "eyJhbGciOiJFUzI1NiJ9...", - "expiresAt": "2026-05-09T16:00:00Z" + "expiresAt": "2026-05-09T14:00:00Z" } ``` @@ -202,20 +193,19 @@ The JWT payload contains: | Claim | Description | |---|---| -| `iss` | The space's DID (who signed it) | -| `sub` | The member's DID (who it was issued to) | -| `space` | The full `ats://` space URI | -| `scope` | Access level (`read`) | +| `iss` | The space authority's DID (who signed it) | +| `sub` | The full `ats://` space URI | | `iat` | Issued at (Unix timestamp) | | `exp` | Expiry (Unix timestamp) | +| `jti` | Random nonce for replay protection | ## Using a credential -Pass the credential as a standard Bearer token in the `Authorization` header. HappyView distinguishes space credentials from other tokens by checking the JWT header's `typ` field (`space_credential`). +Pass the credential as a standard Bearer token in the `Authorization` header. HappyView distinguishes space credentials from other tokens by checking the JWT header's `typ` field (`atproto-space-credential+jwt`). ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -226,7 +216,7 @@ const data = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -237,7 +227,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[("space", "..."), ("collection", "..."), ("rkey", "...")]) .header("Authorization", format!("Bearer {}", space_credential)) .send() @@ -246,28 +236,28 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", nil) req.Header.Set("Authorization", "Bearer "+spaceCredential) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...' \ -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' ``` -No DPoP auth or client key is needed when authenticating via space credential — the credential itself is sufficient. The user's identity comes from the `sub` claim in the JWT. +No DPoP auth or client key is needed when authenticating via space credential — the credential itself is sufficient. The `sub` claim identifies the space being accessed. -HappyView verifies the credential by resolving the issuer's DID document, extracting the signing key, and validating the JWT signature and expiry. If valid, the request is treated as if the credential's `sub` is a member of the space. +HappyView verifies the credential by resolving the issuer's DID document, extracting the `#atproto_space` signing key, and validating the JWT signature and expiry. If valid, the request is granted read access to the space identified by `sub`. ## App access control Before issuing a credential, HappyView checks whether the calling app (identified by its DPoP client key) is allowed to access the space: -- **`default_allow` mode**: any app can get credentials unless it's on the `appDenylist` -- **`default_deny` mode**: only apps on the `appAllowlist` can get credentials +- **`open` (default)**: any app can get credentials +- **`allowList`**: only apps whose client metadata URL appears in the `allowed` array can get credentials -If no client key is present in the DPoP claims, the check is skipped (direct user access without an app intermediary). +For `open` spaces, requests without a client key are allowed. For `allowList` spaces, a client key is required — requests without one are rejected. ## External credential verification @@ -275,8 +265,8 @@ HappyView can also verify credentials issued by *other* HappyView instances or space-aware services. When a Bearer space credential is presented, HappyView: 1. Decodes the JWT without verification to extract the `iss` (issuer DID) 2. Resolves the issuer's DID document -3. Extracts the signing key from the DID doc +3. Extracts the `#atproto_space` signing key from the DID doc 4. Verifies the JWT signature and expiry -5. Checks that the `space` claim matches the requested space +5. Checks that the `sub` claim matches the requested space A credential issued by one instance can be used to read from another instance that hosts the same space's data. diff --git a/packages/docs/content/docs/experimental/spaces/index.md b/packages/docs/content/docs/experimental/spaces/index.md --- a/packages/docs/content/docs/experimental/spaces/index.md +++ b/packages/docs/content/docs/experimental/spaces/index.md @@ -3,7 +3,7 @@ title: "Overview" --- -Permissioned Spaces are experimental and the API will change. This implementation follows Daniel Holmgren's [Permissioned Data Diaries](https://dholms.leaflet.pub/3meluqcwky22a) and aligns structurally with the `permissioned-data` branch on `bluesky-social/atproto`, but uses a `dev.happyview` namespace to allow iteration while the official spec stabilizes. +Permissioned Spaces are experimental and the API will change. This implementation follows [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data). HappyView uses the `com.atproto.space.*` and `com.atproto.simplespace.*` namespaces. The previous `dev.happyview.space.*` endpoints remain available as backward-compatible aliases until v3. Spaces are containers for permissioned data in atproto. Unlike regular public records that live in a user's repo, space records are gated by membership — only members can read or write data within a space. @@ -71,65 +71,100 @@ -H "Content-Type: application/json" \ -d '{"value": "true"}' ``` -When disabled, all `/xrpc/dev.happyview.space.*` endpoints return `501 Not Implemented`. +When disabled, all space endpoints return a `404` error with `FeatureDisabled` as the error code. ## Endpoints -All space endpoints live under the `dev.happyview.space` namespace and require [DPoP authentication](../../getting-started/authentication.md). +Space endpoints are split across two namespaces: -| Endpoint | Method | Description | -| ---------------------------------------- | ------ | ------------------------------------- | -| `dev.happyview.space.createSpace` | POST | Create a space | -| `dev.happyview.space.getSpace` | GET | Get a space by URI | -| `dev.happyview.space.listSpaces` | GET | List spaces by membership | -| `dev.happyview.space.updateSpace` | POST | Update space metadata | -| `dev.happyview.space.deleteSpace` | POST | Delete a space | -| `dev.happyview.space.createRecord` | POST | Create a record (auto-generated rkey) | -| `dev.happyview.space.putRecord` | POST | Write a record | -| `dev.happyview.space.getRecord` | GET | Get a record | -| `dev.happyview.space.listRecords` | GET | List records | -| `dev.happyview.space.deleteRecord` | POST | Delete a record | -| `dev.happyview.space.applyWrites` | POST | Batch write operations | -| `dev.happyview.space.addMember` | POST | Add a member | -| `dev.happyview.space.removeMember` | POST | Remove a member | -| `dev.happyview.space.listMembers` | GET | List resolved members | -| `dev.happyview.space.createInvite` | POST | Create an invite | -| `dev.happyview.space.redeemInvite` | POST | Redeem an invite | -| `dev.happyview.space.revokeInvite` | POST | Revoke an invite | -| `dev.happyview.space.listInvites` | GET | List invites | -| `dev.happyview.space.getMemberGrant` | POST | Prove membership (step 1) | -| `dev.happyview.space.getSpaceCredential` | POST | Get a space credential (step 2) | +- **`com.atproto.space.*`** — protocol-level routes (queries, data, credentials) +- **`com.atproto.simplespace.*`** — management routes (create/update/delete spaces, membership) + +The previous `dev.happyview.space.*` endpoints remain as backward-compatible aliases until v3. All endpoints require [DPoP authentication](../../getting-started/authentication.md) or cookie-based session auth. + +| Endpoint | Method | Description | +| --------------------------------------------- | ------ | ----------------------------------------------- | +| `com.atproto.simplespace.createSpace` | POST | Create a space | +| `com.atproto.space.getSpace` | GET | Get a space by URI | +| `com.atproto.space.listSpaces` | GET | List spaces by membership | +| `com.atproto.simplespace.updateSpace` | POST | Update space metadata | +| `com.atproto.simplespace.deleteSpace` | POST | Delete a space | +| `com.atproto.simplespace.getConfig` | GET | Get space configuration | +| `com.atproto.simplespace.updateConfig` | POST | Update space configuration | +| `com.atproto.space.createRecord` | POST | Create a record (auto-generated rkey) | +| `com.atproto.space.putRecord` | POST | Write a record | +| `com.atproto.space.getRecord` | GET | Get a record | +| `com.atproto.space.listRecords` | GET | List records | +| `com.atproto.space.deleteRecord` | POST | Delete a record | +| `com.atproto.space.applyWrites` | POST | Batch write operations | +| `com.atproto.simplespace.addMember` | POST | Add a member | +| `com.atproto.simplespace.removeMember` | POST | Remove a member | +| `com.atproto.simplespace.listMembers` | GET | List resolved members | +| `com.atproto.space.getRepoState` | GET | Get per-user repo state (LtHash + commit) | +| `com.atproto.space.listRepoOps` | GET | List record operation log entries | +| `com.atproto.space.listRepos` | GET | List repos (authors) in a space | +| `com.atproto.space.getDelegationToken` | GET | Get a delegation token (step 1 of credentials) | +| `com.atproto.space.getSpaceCredential` | POST | Get a space credential (step 2) | +| `com.atproto.space.getBlob` | GET | Get a blob from a space | +| `com.atproto.space.registerNotify` | POST | Register for write notifications | +| `com.atproto.space.notifyWrite` | POST | Push a write notification | +| `com.atproto.space.notifySpaceDeleted` | POST | Push a space-deleted notification | +| `dev.happyview.space.createInvite` | POST | Create an invite (HappyView extension) | +| `dev.happyview.space.acceptInvite` | POST | Accept an invite (HappyView extension) | +| `dev.happyview.space.revokeInvite` | POST | Revoke an invite (HappyView extension) | +| `dev.happyview.space.listInvites` | GET | List invites (HappyView extension) | ## Access model -Spaces have an **access mode** that controls third-party app access: +Spaces use two independent controls for access: + +**Mint policy** controls who can create permissioned repos in the space: + +- **`member-list`** (default) — only members can create repos +- **`public`** — anyone can create repos +- **`managing-app`** — only the managing app can create repos + +**App access** controls which third-party apps can interact with the space: + +- **`open`** (default) — any app can access +- **`allowList`** — only explicitly listed apps can access + +Individual users access spaces through **membership**. Members have one of three access levels: -- **`default_allow`** — any app can access (with optional denylist) -- **`default_deny`** — only explicitly allowed apps can access +- **`write`** — can read and write data +- **`read`** — can read all data in the space +- **`read_self`** — can only read their own data within the space -Individual users access spaces through **membership**. Members have either `read` or `write` access. Write access implies read. The space creator is automatically added as a write member. +Write access implies read. The space creator is automatically added as a write member. Spaces also support **delegation** — adding another space as a member, which transitively grants access to all members of the delegated space. -## Divergences from the reference spec +## Alignment with Proposal 0016 -HappyView mostly mirrors [Daniel Holmgren's `permissioned-data` branch](https://github.com/bluesky-social/atproto/tree/permissioned-data) but diverges in some areas. These will narrow as the official spec stabilizes. +HappyView implements [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data) with some HappyView-specific extensions. -### HappyView extensions (not in the reference branch) +### Protocol features implemented -- **`isDelegation` on members** allows spaces to be members of other spaces -- **`displayName`, `description`, `accessMode` on spaces** — the reference space model is minimal (`uri`, `isOwner`, `isMember`, `createdAt`) -- **`appAllowlist` / `appDenylist` / `managingAppDid`** — app-level access control layer -- **`config` object** on spaces (e.g. `membershipPublic`, `recordsPublic`) -- **Invite system** — `createInvite`, `redeemInvite`, `revokeInvite`, `listInvites` -- **`read` / `write` access levels** — the reference branch treats membership as binary +- **Namespace split** — `com.atproto.space.*` for protocol routes, `com.atproto.simplespace.*` for management +- **Mint policy** — `member-list`, `public`, `managing-app` (replaces `accessMode`) +- **App access** — `open`, `allowList` (replaces `appAllowlist`/`appDenylist`) +- **Delegation tokens** — `getDelegationToken` (GET, 60-second TTL) replaces `getMemberGrant` +- **Space credentials** — `atproto-space-credential+jwt` typ, ES256, 2-hour TTL +- **Deniable commit signatures** — user signs context (space + rev + random IKM), not content hash +- **LtHash** — homomorphic set-hash (2048-byte state, 1024 uint16 lanes, BLAKE3 XOF) +- **Record operation log** — `listRepoOps` returns the oplog for sync +- **Repo state** — `getRepoState` returns LtHash state + signed commit +- **Write notifications** — `registerNotify`, `notifyWrite`, `notifySpaceDeleted` +- **Space-scoped blobs** — `getBlob` +- **Authority DID** — spaces use `authority_did` (not `owner_did`) with a separate `creator_did` -### Reference features not yet implemented +### HappyView extensions (not in the protocol spec) -- **Oplogs** — `getRepoOplog`, `getMemberOplog`, `getRepoState`, `getMemberState` (sync primitives for space data) -- **Push notifications** — `notifyWrite`, `notifyMembership` (service-to-service event delivery) -- **Space-scoped blobs** — `uploadBlob` for blobs within a space context -- **Owner record deletion** — in the reference branch the space owner can delete any record; HappyView restricts `deleteRecord` to the record's author only +- **Invite system** — `createInvite`, `acceptInvite`, `revokeInvite`, `listInvites` (under `dev.happyview.space.*`) +- **`isDelegation` on members** — allows spaces to be members of other spaces +- **`displayName`, `description` on spaces** — human-readable metadata +- **`config` object** — `membershipPublic`, `recordsPublic`, plus arbitrary extra fields +- **`read_self` access level** — restricts reads to the member's own data ## Next steps @@ -138,3 +173,4 @@ - [Members](./members.md) — manage membership and delegation - [Records](./records.md) — read and write permissioned data - [Credentials](./credentials.md) — cross-service authentication for spaces - [Invites](./invites.md) — invite-based membership +- [Changelog](./changelog.md) — version history diff --git a/packages/docs/content/docs/experimental/spaces/invites.md b/packages/docs/content/docs/experimental/spaces/invites.md --- a/packages/docs/content/docs/experimental/spaces/invites.md +++ b/packages/docs/content/docs/experimental/spaces/invites.md @@ -109,7 +109,7 @@ | Field | Type | Required | Default | Description | |---|---|---|---|---| | `space` | string | Yes | | The space this invite is for | -| `access` | string | No | `read` | Access level granted on redemption (`read` or `write`) | +| `access` | string | No | `read` | Access level granted on acceptance (`read`, `read_self`, or `write`) | | `maxUses` | integer | No | unlimited | Maximum number of times the invite can be redeemed | | `expiresAt` | string (datetime) | No | never | When the invite expires | @@ -129,12 +129,12 @@ The `token` is only returned once. It is stored as a SHA-256 hash — HappyView cannot recover the plaintext. -## Redeeming an invite +## Accepting an invite -Any authenticated user can redeem an invite token to join the space. +Any authenticated user can accept an invite token to join the space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", { +const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -146,14 +146,14 @@ body: JSON.stringify({ token: "a1b2c3d4e5f6...", }), }); -interface RedeemInviteResponse { +interface AcceptInviteResponse { uri: string; access: string; } -const data: RedeemInviteResponse = await response.json(); +const data: AcceptInviteResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", { +const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -169,7 +169,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite") + .post("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -183,7 +183,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{"token": "a1b2c3d4e5f6..."}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", body) + "https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -191,7 +191,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite' \ +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -210,7 +210,7 @@ "access": "write" } ``` -Redemption fails if: +Acceptance fails if: - The token is invalid (no matching hash found) - The invite has been revoked diff --git a/packages/docs/content/docs/experimental/spaces/managing-spaces.md b/packages/docs/content/docs/experimental/spaces/managing-spaces.md --- a/packages/docs/content/docs/experimental/spaces/managing-spaces.md +++ b/packages/docs/content/docs/experimental/spaces/managing-spaces.md @@ -9,7 +9,7 @@ ## Creating a space ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -22,7 +22,7 @@ type: "com.example.forum", skey: "main", displayName: "My Forum", description: "A place for discussion", - accessMode: "default_allow", + mintPolicy: "member-list", }), }); interface CreateSpaceResponse { @@ -31,7 +31,7 @@ } const data: CreateSpaceResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -44,14 +44,14 @@ type: "com.example.forum", skey: "main", displayName: "My Forum", description: "A place for discussion", - accessMode: "default_allow", + mintPolicy: "member-list", }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.createSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -60,7 +60,7 @@ "type": "com.example.forum", "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" })) .send() .await?; @@ -72,10 +72,10 @@ "type": "com.example.forum", "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.createSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -83,7 +83,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -93,7 +93,7 @@ "type": "com.example.forum", "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" }' ``` @@ -105,7 +105,8 @@ | `type` | string (NSID) | Yes | The space type; describes what this space is for | | `skey` | string | Yes | Space key; differentiates spaces of the same type | | `displayName` | string | No | Human-readable name | | `description` | string | No | Description of the space | -| `accessMode` | string | No | `default_allow` (default) or `default_deny` | +| `mintPolicy` | string | No | `member-list` (default), `public`, or `managing-app` | +| `appAccess` | object | No | `{"type": "open"}` (default) or `{"type": "allowList", "allowed": [...]}` | | `managingAppDid` | string | No | DID of the application that manages this space | | `config` | object | No | Space configuration (see below) | @@ -117,7 +118,7 @@ "uri": "ats://did:plc:abc123/com.example.forum/main" } ``` -The creator is automatically added as a write member. Use [`dev.happyview.space.getSpace`](#getting-a-space) to retrieve the full space object. +The creator is automatically added as a write member. Use [`com.atproto.space.getSpace`](#getting-a-space) to retrieve the full space object. ### Space configuration @@ -134,7 +135,7 @@ ## Getting a space ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -151,7 +152,7 @@ const data: Space = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -164,7 +165,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getSpace") + .get("https://happyview.example.com/xrpc/com.atproto.space.getSpace") .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -175,7 +176,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -183,7 +184,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -197,7 +198,7 @@ Returns spaces where the authenticated user is a member. ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, @@ -218,7 +219,7 @@ const data: ListSpacesResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, @@ -231,7 +232,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listSpaces") + .get("https://happyview.example.com/xrpc/com.atproto.space.listSpaces") .query(&[("limit", "20")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -242,7 +243,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -250,7 +251,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -258,10 +259,11 @@ ``` **Parameters:** -| Field | Type | Required | Default | Description | -| -------- | ------- | -------- | ------- | ---------------------------- | -| `limit` | integer | No | 50 | Max spaces to return (1-100) | -| `cursor` | string | No | | Pagination cursor | +| Field | Type | Required | Default | Description | +| -------- | ------- | -------- | -------------- | ---------------------------- | +| `did` | string | No | authenticated user | Filter by DID | +| `limit` | integer | No | 50 | Max spaces to return (1-100) | +| `cursor` | string | No | | Pagination cursor | **Response:** @@ -279,10 +281,10 @@ ``` ## Updating a space -Only the space owner or a HappView super admin can update a space. +Only the space authority or a HappyView super admin can update a space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -293,13 +295,12 @@ }, body: JSON.stringify({ space: "ats://did:plc:abc123/com.example.forum/main", displayName: "Updated Forum Name", - accessMode: "default_deny", - appAllowlist: ["did:web:myapp.example.com"], + mintPolicy: "public", }), }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -310,22 +311,20 @@ }, body: JSON.stringify({ space: "ats://did:plc:abc123/com.example.forum/main", displayName: "Updated Forum Name", - accessMode: "default_deny", - appAllowlist: ["did:web:myapp.example.com"], + mintPolicy: "public", }), }); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" })) .send() .await?; @@ -334,11 +333,10 @@ ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -346,7 +344,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.updateSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -354,8 +352,7 @@ -H 'Content-Type: application/json' \ -d '{ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" }' ``` @@ -363,10 +360,10 @@ All fields except `space` are optional. Only provided fields are updated. To clear an optional field, pass `null`. ## Deleting a space -Only the space owner or a HappyView super admin can delete a space. +Only the space authority or a HappyView super admin can delete a space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -380,7 +377,7 @@ }), }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -395,7 +392,7 @@ }); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -408,7 +405,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{"space": "ats://did:plc:abc123/com.example.forum/main"}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -416,7 +413,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -425,5 +422,5 @@ -d '{"space": "ats://did:plc:abc123/com.example.forum/main"}' ``` -Deleting a space does not currently cascade to records, members, or credentials. This behavior may change. +Deleting a space cascades to all associated records, members, repo state, oplog entries, notification registrations, and credentials. diff --git a/packages/docs/content/docs/experimental/spaces/members.md b/packages/docs/content/docs/experimental/spaces/members.md --- a/packages/docs/content/docs/experimental/spaces/members.md +++ b/packages/docs/content/docs/experimental/spaces/members.md @@ -6,14 +6,14 @@ This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. -Membership determines who can read and write within a space. Members have either `read` or `write` access — write implies read. +Membership determines who can read and write within a space. Members have one of three access levels — `write`, `read`, or `read_self`. Write implies read. `read_self` restricts the member to reading only their own records within the space. ## Adding a member -Only the space owner or a super admin can add members. +Only the space authority or a super admin can add members. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -40,7 +40,7 @@ } const data: { member: Member } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -59,7 +59,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.addMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -81,7 +81,7 @@ "access": "write", "isDelegation": false }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.addMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -89,7 +89,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.addMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -108,7 +108,7 @@ | Field | Type | Required | Default | Description | |---|---|---|---|---| | `space` | string | Yes | | The space to add the member to | | `did` | string | Yes | | DID of the member (or space for delegation) | -| `access` | string | No | `read` | `read` or `write` | +| `access` | string | No | `read` | `read`, `read_self`, or `write` | | `isDelegation` | boolean | No | `false` | Whether this member is a delegated space | **Response (201):** @@ -130,7 +130,7 @@ ## Removing a member ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.removeMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -145,7 +145,7 @@ }), }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.removeMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -161,7 +161,7 @@ }); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.removeMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -178,7 +178,7 @@ "space": "ats://did:plc:abc123/com.example.forum/main", "did": "did:plc:newmember" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.removeMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -186,7 +186,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.removeMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -201,7 +201,7 @@ ## Listing members ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -218,7 +218,7 @@ const data: { members: ResolvedMember[] } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -231,7 +231,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listMembers") + .get("https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers") .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -242,7 +242,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -250,7 +250,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -275,7 +275,7 @@ A space can be added as a member of another space by setting `isDelegation: true`. This transitively grants access to all members of the delegated space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -292,7 +292,7 @@ }), }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -310,7 +310,7 @@ }); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.addMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -331,7 +331,7 @@ "access": "read", "isDelegation": true }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.addMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -339,7 +339,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.addMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ diff --git a/packages/docs/content/docs/experimental/spaces/records.md b/packages/docs/content/docs/experimental/spaces/records.md --- a/packages/docs/content/docs/experimental/spaces/records.md +++ b/packages/docs/content/docs/experimental/spaces/records.md @@ -18,7 +18,7 @@ Requires `write` membership in the space. The rkey is auto-generated using a TID. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.createRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -43,7 +43,7 @@ } const data: CreateRecordResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.createRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -65,7 +65,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.createRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.createRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -93,7 +93,7 @@ "createdAt": "2026-05-09T12:00:00Z" } }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.createRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.createRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -101,7 +101,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.createRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -141,7 +141,7 @@ Requires `write` membership in the space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.putRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.putRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -167,7 +167,7 @@ } const data: PutRecordResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.putRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.putRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -190,7 +190,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.putRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.putRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -220,7 +220,7 @@ "createdAt": "2026-05-09T12:00:00Z" } }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.putRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.putRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -228,7 +228,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.putRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.putRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -272,6 +272,8 @@ ## Getting a record Requires `read` membership (or a valid [space credential](credentials.md)). +Members with `read_self` access can only retrieve their own records. Attempting to read another user's record returns `403 Forbidden`. + ```ts tab="TypeScript" tab-group="language" const params = new URLSearchParams({ space: "ats://did:plc:abc123/com.example.forum/main", @@ -279,7 +281,7 @@ collection: "com.example.forum.post", rkey: "3k2abc", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.getRecord?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.getRecord?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -302,7 +304,7 @@ collection: "com.example.forum.post", rkey: "3k2abc", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.getRecord?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.getRecord?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -315,7 +317,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[ ("space", "ats://did:plc:abc123/com.example.forum/main"), ("collection", "com.example.forum.post"), @@ -330,7 +332,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -338,7 +340,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -375,7 +377,7 @@ collection: "com.example.forum.post", limit: "20", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.listRecords?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.listRecords?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -402,7 +404,7 @@ collection: "com.example.forum.post", limit: "20", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.listRecords?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.listRecords?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -415,7 +417,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listRecords") + .get("https://happyview.example.com/xrpc/com.atproto.space.listRecords") .query(&[ ("space", "ats://did:plc:abc123/com.example.forum/main"), ("collection", "com.example.forum.post"), @@ -430,7 +432,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -438,7 +440,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -475,7 +477,7 @@ You can only delete your own records. Requires `write` membership. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -491,7 +493,7 @@ }), }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -508,7 +510,7 @@ }); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -527,7 +529,7 @@ "collection": "com.example.forum.post", "rkey": "3k2abc" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -535,7 +537,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.deleteRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -563,7 +565,7 @@ `applyWrites` performs multiple create, update, and delete operations in a single request. Requires `write` membership. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.applyWrites", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -601,7 +603,7 @@ } const data: { results: ApplyWritesResult[] } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.applyWrites", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -636,7 +638,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites") + .post("https://happyview.example.com/xrpc/com.atproto.space.applyWrites") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -690,7 +692,7 @@ } ] }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", body) + "https://happyview.example.com/xrpc/com.atproto.space.applyWrites", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -698,7 +700,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.applyWrites' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.applyWrites' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -779,7 +781,7 @@ ### swapCommit Pass the `swapCommit` field on `applyWrites` to assert the space's current revision. If another client has written to the space since you last read its state, the operation fails with `409 Conflict` before any writes are applied. -The space's current revision is available as `revision` in the space object returned by `dev.happyview.space.getSpace`. +The space's current revision is available as `revision` in the space object returned by `com.atproto.space.getSpace`. ```json { @@ -795,7 +797,7 @@ Records can also be read using a [space credential](credentials.md) instead of direct membership. Pass the credential as a Bearer token: ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -806,7 +808,7 @@ const data = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -817,7 +819,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[("space", "..."), ("collection", "..."), ("rkey", "...")]) .header("Authorization", format!("Bearer {}", space_credential)) .send() @@ -826,13 +828,13 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", nil) req.Header.Set("Authorization", "Bearer "+spaceCredential) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?...' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?...' \ -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' ``` diff --git a/packages/docs/content/docs/getting-started/authentication.md b/packages/docs/content/docs/getting-started/authentication.md --- a/packages/docs/content/docs/getting-started/authentication.md +++ b/packages/docs/content/docs/getting-started/authentication.md @@ -130,7 +130,15 @@ ### Authenticating users for procedures Queries that don't care who is calling need nothing more than the client key. Procedures — and queries whose Lua scripts read the caller's DID — need a real atproto OAuth session. -XRPC routes only accept **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`). Bearer tokens and service auth JWTs are not accepted on XRPC endpoints. +XRPC routes accept several auth methods, resolved in this order: + +1. **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`) — used by third-party apps that went through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow. +2. **Bearer space credential** (`Authorization: Bearer `) — a signed JWT granting access to a specific space; accepted on space routes. +3. **Bearer service auth JWT** (`Authorization: Bearer `) — a standard atproto inter-service JWT signed by a DID's atproto signing key; the caller is identified as the issuer DID. +4. **Cookie session** — when no `Authorization` header is present, HappyView falls back to the signed session cookie set after dashboard login. +5. **Anonymous** — if none of the above is present, the request proceeds with no identity. The endpoint's Lua script determines whether that is acceptable. + +Bearer API keys (`hv_*`) are **not** accepted on XRPC endpoints — those are for admin API access only. Third-party apps authenticate users through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow: your app gets a DPoP keypair from HappyView, runs a standard OAuth flow with the user's PDS using that keypair, then registers the resulting tokens back with HappyView. diff --git a/packages/docs/content/docs/getting-started/dashboard.md b/packages/docs/content/docs/getting-started/dashboard.md --- a/packages/docs/content/docs/getting-started/dashboard.md +++ b/packages/docs/content/docs/getting-started/dashboard.md @@ -97,6 +97,18 @@ ### Event Logs View the audit log of admin actions. Events include user creation, lexicon uploads, permission changes, backfill starts, and more. Each entry shows the event type, severity, actor, subject, and timestamp. Events are retained for the number of days configured by `EVENT_LOG_RETENTION_DAYS` (default 30). +### Service Identity + +Configure the AT Protocol service identity for your HappyView instance — either a `did:web` derived from your public URL, a `did:plc` you control, or a linked atproto account. This determines the DID that signs service-level interactions on the network. + +### Experiments + +Toggle experimental feature flags for your instance. Flags like `feature.spaces_enabled` can be enabled here before they are promoted to stable configuration options. + +### Scripts + +Manage script variables that are injected into Lua scripts at runtime. Variables defined here are available to all scripts and can be used to store shared configuration without hardcoding values in individual scripts. + ## About The **About** page shows the current HappyView version and instance configuration: public URL, database backend, Jetstream URL, relay URL, and PLC directory URL. diff --git a/packages/docs/content/docs/guides/lua-scripting.md b/packages/docs/content/docs/guides/lua-scripting.md --- a/packages/docs/content/docs/guides/lua-scripting.md +++ b/packages/docs/content/docs/guides/lua-scripting.md @@ -2,7 +2,7 @@ --- title: "Lua Scripting" --- -Without Lua scripts, HappyView's query endpoints return raw records and procedure endpoints proxy simple creates and updates. Lua scripts let you go much further: +Without Lua scripts, HappyView's query endpoints return raw records and procedure endpoints proxy simple creates and updates. To attach a script to an XRPC endpoint, create a script with trigger `xrpc.query:` or `xrpc.procedure:` — see [trigger grammar](label-scripts#trigger-grammar). Lua scripts let you go much further: - Add filtering logic - Transform responses @@ -65,6 +65,28 @@ | `collection` | string | Target collection NSID | | `caller_did` | string? | DID of the authenticated user (nil if unauthenticated) | | `env` | table | Script variables configured in the dashboard | +### Space globals + +When a script handles a space-scoped request, the `space` global is set to a table with the space's metadata. For non-space requests, `space` is `nil`. + +| Field | Type | Description | +| ----------- | ------ | -------------------------------------------------------- | +| `space` | string | The full `ats://` space URI | +| `space_id` | string | Internal space identifier | +| `did` | string | The space's DID | +| `authority_did` | string | The space authority's DID | +| `type_nsid` | string | Space type NSID | +| `skey` | string | Space key | + +```lua +function handle() + if space then + log("handling request for space: " .. space.space) + log("space type: " .. space.type_nsid) + end +end +``` + ## Utility globals Available in both queries and procedures: @@ -90,7 +112,7 @@ You don't need `toarray()` on results from `db.query`, `db.search`, `db.backlinks`, or `db.raw` — those already return properly marked arrays. Use it when you build a table yourself with `table.insert()`. ## Record API -The `Record` API is only available in **procedure** scripts. It handles creating, updating, loading, and deleting atproto records. Writes are proxied to the caller's PDS and indexed locally. +The `Record` API is available in **procedure**, **query**, and **record/label** scripts. In procedure scripts the full API is available — writes are proxied to the caller's PDS and indexed locally. In query and record/label scripts it runs in **no-auth mode**: `Record.load`, `r:save_local()`, `r:delete_local()`, and `Record.delete_local()` work, but PDS-touching methods (`r:save()`, `r:delete()`) raise an error. See the full [Record API reference](../api-reference/lua/record-api.md) for constructor, static methods, instance methods, fields, schema validation, and save behavior. @@ -161,7 +183,7 @@ ## Debugging ### Logging -Use `log()` to trace script execution. Output appears in the server logs at **debug** level with the field `lua_log`: +Use `log()` to trace script execution. Output appears in the server logs at **debug** level with the field `lua_log`, and is also recorded as a `script.log` event in the [event logs](../api-reference/admin/events.md) (accessible via `GET /admin/events`): ```lua function handle() @@ -172,7 +194,7 @@ return result end ``` -To see log output, make sure your `RUST_LOG` environment variable includes debug level for HappyView (the default `happyview=debug` works). See [Configuration](../getting-started/configuration.md). +To see log output in stdout, make sure your `RUST_LOG` environment variable includes debug level for HappyView (the default `happyview=debug` works). See [Configuration](../getting-started/configuration.md). ### Error messages diff --git a/packages/docs/content/docs/guides/permissions.md b/packages/docs/content/docs/guides/permissions.md --- a/packages/docs/content/docs/guides/permissions.md +++ b/packages/docs/content/docs/guides/permissions.md @@ -6,57 +6,120 @@ HappyView uses a granular permission system to control access to the admin API. Each user has a set of permissions that determine which endpoints they can access. Permissions can be assigned individually, via templates, or both. ## Permission list -HappyView defines 20 permissions organized by category: +HappyView defines 44 permissions organized by category: ### Lexicons -| Permission | Description | -| ----------------- | ---------------------------------------------- | -| `lexicons:create` | Upload and upsert lexicons (local and network) | -| `lexicons:read` | List and view lexicon details | -| `lexicons:delete` | Delete lexicons | +| Permission | Description | +| ----------------- | ------------------------------------ | +| `lexicons:create` | Upload and register new lexicon schemas | +| `lexicons:read` | View registered lexicon schemas | +| `lexicons:delete` | Remove lexicon schemas | ### Records | Permission | Description | | --------------------------- | --------------------------------------- | -| `records:read` | List and view indexed records | -| `records:delete` | Delete individual records | +| `records:read` | Browse indexed AT Protocol records | +| `records:delete` | Delete individual records from the index | | `records:delete-collection` | Bulk-delete all records in a collection | +### Scripts + +| Permission | Description | +| ---------------- | ------------------------------------------------- | +| `scripts:read` | View trigger-keyed scripts | +| `scripts:manage` | Create, update, and delete trigger-keyed scripts | + ### Script Variables -| Permission | Description | -| ------------------------- | ----------------------------------------- | -| `script-variables:create` | Create and update script variables | -| `script-variables:read` | List script variables (values are masked) | -| `script-variables:delete` | Delete script variables | +| Permission | Description | +| ------------------------- | -------------------------------------------------- | +| `script-variables:create` | Add or update environment variables for Lua scripts | +| `script-variables:read` | View script environment variable keys and values | +| `script-variables:delete` | Remove script environment variables | ### Users -| Permission | Description | -| -------------- | -------------------------- | -| `users:create` | Add new users | -| `users:read` | List and view user details | -| `users:update` | Modify user permissions | -| `users:delete` | Remove users | +| Permission | Description | +| -------------- | -------------------------------------- | +| `users:create` | Add new dashboard users | +| `users:read` | View the user list and their permissions | +| `users:update` | Modify user permissions | +| `users:delete` | Remove dashboard users | ### API Keys -| Permission | Description | -| ----------------- | ------------------- | -| `api-keys:create` | Create new API keys | -| `api-keys:read` | List API keys | -| `api-keys:delete` | Revoke API keys | +| Permission | Description | +| ----------------- | ---------------------------------------- | +| `api-keys:create` | Generate new API keys for admin access | +| `api-keys:read` | View existing API keys | +| `api-keys:delete` | Revoke existing API keys | + +### Backfill + +| Permission | Description | +| ----------------- | ----------------------------------------- | +| `backfill:create` | Trigger historical record backfill jobs | +| `backfill:read` | View backfill job status and progress | + +### Labelers + +| Permission | Description | +| ----------------- | ------------------------------------- | +| `labelers:create` | Subscribe to external labeler services | +| `labelers:read` | View subscribed labeler services | +| `labelers:delete` | Unsubscribe from labeler services | + +### Settings + +| Permission | Description | +| ----------------- | --------------------------------------------------- | +| `settings:manage` | Modify instance settings, logo, and configuration | + +### Plugins + +| Permission | Description | +| ----------------- | -------------------------------------------- | +| `plugins:read` | View installed plugins and their configuration | +| `plugins:create` | Install and configure new plugins | +| `plugins:delete` | Uninstall plugins | + +### API Clients + +| Permission | Description | +| -------------------- | ---------------------------------------- | +| `api-clients:view` | View registered OAuth API clients | +| `api-clients:create` | Register new OAuth API clients | +| `api-clients:edit` | Modify API client settings and credentials | +| `api-clients:delete` | Remove registered API clients | + +### Dead Letters + +| Permission | Description | +| --------------------- | -------------------------------------- | +| `dead-letters:read` | View failed hook executions | +| `dead-letters:manage` | Retry, re-index, or dismiss dead letters | + +### Spaces + +| Permission | Description | +| --------------------------- | ------------------------------------------ | +| `spaces:create` | Create new permissioned data spaces | +| `spaces:read` | View space details and metadata | +| `spaces:update` | Modify space settings | +| `spaces:delete` | Remove spaces and their data | +| `spaces:manage-members` | Add or remove space members and roles | +| `spaces:manage-invites` | Create and revoke space invitations | +| `spaces:manage-records` | Read and write records within spaces | +| `spaces:manage-credentials` | Issue and revoke space access credentials | -### Operations +### System -| Permission | Description | -| ----------------- | ------------------------ | -| `backfill:create` | Start backfill jobs | -| `backfill:read` | View backfill job status | -| `stats:read` | View record statistics | -| `events:read` | Query the event log | +| Permission | Description | +| ------------ | ---------------------------------------- | +| `stats:read` | View collection statistics and record counts | +| `events:read` | View the event log | ## Permission templates @@ -64,25 +127,25 @@ Templates are predefined sets of permissions that simplify user creation. Pass a `template` value when creating a user via `POST /admin/users`. ### Viewer -Read-only access. Can browse lexicons, records, stats, events, and user lists but cannot modify anything. +Read-only access. Can browse lexicons, records, scripts, stats, events, dead letters, and user lists but cannot modify anything. -Includes: `lexicons:read`, `records:read`, `script-variables:read`, `users:read`, `api-keys:read`, `backfill:read`, `stats:read`, `events:read` +Includes: `lexicons:read`, `records:read`, `scripts:read`, `script-variables:read`, `users:read`, `api-keys:read`, `backfill:read`, `stats:read`, `events:read`, `dead-letters:read` ### Operator -Everything in Viewer, plus the ability to run backfill jobs and manage API keys. +Everything in Viewer, plus the ability to run backfill jobs, manage API keys, and manage dead letters. -Adds: `backfill:create`, `api-keys:create`, `api-keys:delete` +Adds: `backfill:create`, `api-keys:create`, `api-keys:delete`, `dead-letters:manage` ### Manager -Everything in Operator, plus the ability to manage lexicons, records, and script variables. +Everything in Operator, plus the ability to manage lexicons, records, scripts, labelers, settings, plugins, API clients, and spaces. -Adds: `lexicons:create`, `lexicons:delete`, `script-variables:create`, `script-variables:delete`, `records:delete` +Adds: `lexicons:create`, `lexicons:delete`, `scripts:manage`, `script-variables:create`, `script-variables:delete`, `records:delete`, `labelers:create`, `labelers:read`, `labelers:delete`, `settings:manage`, `plugins:read`, `plugins:create`, `plugins:delete`, `api-clients:view`, `api-clients:create`, `api-clients:edit`, `api-clients:delete`, `spaces:create`, `spaces:read`, `spaces:update`, `spaces:delete`, `spaces:manage-members`, `spaces:manage-invites`, `spaces:manage-records`, `spaces:manage-credentials` ### Full Access -All 20 permissions. Equivalent to granting every permission individually (but still not a super user). +All 44 permissions. Equivalent to granting every permission individually (but still not a super user). ## Super user diff --git a/packages/docs/content/docs/guides/record-scripts.md b/packages/docs/content/docs/guides/record-scripts.md --- a/packages/docs/content/docs/guides/record-scripts.md +++ b/packages/docs/content/docs/guides/record-scripts.md @@ -23,6 +23,15 @@ | `record.delete:` | A record is deleted | **Cascade rule:** When a record event occurs, the dispatcher tries the action-specific trigger first (e.g. `record.create:`), then falls back to `record.index:` if no action-specific script exists. This means you can use `record.index` as a catch-all and override individual actions when needed. +### XRPC triggers + +| Trigger | Fires when | +| -------------------------- | --------------------------------------------- | +| `xrpc.query:` | An XRPC query endpoint is called | +| `xrpc.procedure:` | An XRPC procedure endpoint is called | + +XRPC scripts handle the request and return the response. Without a script, HappyView uses [default query/procedure behavior](../api-reference/xrpc-api.md). See [Lua Scripting](./lua-scripting.md) for the full query/procedure scripting reference. + ### Label event triggers | Trigger | Fires when | @@ -30,7 +39,7 @@ | -------------------------- | -------------------------------------------------- | | `labeler.apply:` | A label arrives whose subject is `at:////` | | `labeler.apply:_actor` | A label arrives whose subject is a bare DID (actor-level label) | -There is no cascade for label triggers -- each trigger string must match exactly. +There is no cascade for label or XRPC triggers -- each trigger string must match exactly. ## Creating scripts @@ -111,12 +120,13 @@ | `cts` | string | Creation timestamp (ISO 8601) | | `exp` | string? | Expiration timestamp (nil if the label does not expire) | | `event` | table | The full label event as a table (same fields) | -Record and label scripts do **not** have access to `caller_did`, `input`, `params`, `method`, or the `Record` API. They run from the event stream, not from a user request. +Record and label scripts do **not** have access to `caller_did`, `input`, `params`, or `method`. They run from the event stream, not from a user request. ## Available APIs Record and label scripts have access to: +- **[Record API](../api-reference/lua/record-api.md)** (no-auth mode) -- `Record.load`, `r:save_local()`, `r:delete_local()`, `Record.delete_local()`. PDS-touching methods (`r:save()`, `r:delete()`) raise an error. - **[Database API](../api-reference/lua/database-api.md)** -- `db.query`, `db.get`, `db.search`, `db.backlinks`, `db.count`, `db.raw` - **[HTTP API](../api-reference/lua/http-api.md)** -- `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head` - **[XRPC Lua API](../api-reference/lua/xrpc-lua-api.md)** -- `xrpc.query`, `xrpc.procedure` @@ -143,17 +153,18 @@ ### Dead letter table The `dead_letter_scripts` table stores events that failed all retry attempts: -| Column | Type | Description | -| ------------ | ----------- | ----------------------------------------------------- | -| `id` | BIGSERIAL | Primary key | -| `script_ref` | text | The trigger id of the script that failed | -| `host_kind` | text | `'record'` or `'label'` | -| `host_id` | text | Identifies the specific event source | -| `payload` | jsonb | The full event payload | -| `error` | text | The error message from the last attempt | -| `attempts` | int | Total number of attempts made | -| `created_at` | timestamptz | When the failure was recorded | -| `resolved_at`| timestamptz | When the failure was resolved (null until resolved) | +| Column | Type | Description | +| ------------ | --------- | ----------------------------------------------------- | +| `id` | BIGSERIAL | Primary key | +| `script_ref` | text | The trigger id of the script that failed | +| `host_kind` | text | `'record'` or `'label'` | +| `host_id` | text | Identifies the specific event source | +| `collection` | text | The collection NSID of the failed event | +| `payload` | jsonb | The full event payload | +| `error` | text | The error message from the last attempt | +| `attempts` | int | Total number of attempts made | +| `created_at` | text | When the failure was recorded (ISO 8601) | +| `resolved_at`| text | When the failure was resolved (null until resolved) | ## Examples diff --git a/packages/docs/content/docs/index.md b/packages/docs/content/docs/index.md --- a/packages/docs/content/docs/index.md +++ b/packages/docs/content/docs/index.md @@ -16,6 +16,8 @@ - **Customize with Lua scripts and plugins:** Trigger-keyed [Lua scripts](guides/lua-scripting.md) for XRPC query/procedure logic and [record/label event handling](guides/label-scripts), WASM [plugins](guides/plugins.md) for external platform integration, and [labeler](guides/labelers.md) subscriptions for content moderation. - **Protocol-native:** Works with any PDS, resolves DIDs through the directory, and fetches [network lexicons](guides/lexicons.md#network-lexicons) via DNS authority resolution. +- **Permissioned Spaces:** Experimental support for [AT Protocol Proposal 0016](experimental/spaces/index.md) — membership-gated data containers with per-user repo state, cross-service credentials, and write notifications. + - **Full admin surface:** Built-in [dashboard](getting-started/dashboard.md) and [admin API](api-reference/admin/admin-api.md) for managing lexicons, users, API keys, API clients, backfill jobs, and plugins. ## Design Principles @@ -36,4 +38,5 @@ - [Lua Scripting](guides/lua-scripting.md): Write custom query and procedure logic - [Record & Label Scripts](guides/label-scripts): React to record changes and label events in real time - [Labelers](guides/labelers.md): Subscribe to external labelers and manage content labels - [Plugins](guides/plugins.md): Integrate with external platforms using WASM plugins +- [Permissioned Spaces](experimental/spaces/index.md): Create membership-gated data containers with the AT Protocol spaces API - [Event Logs](guides/event-logs.md): Monitor system activity, debug script errors, and audit admin actions diff --git a/packages/docs/content/docs/reference/architecture.md b/packages/docs/content/docs/reference/architecture.md --- a/packages/docs/content/docs/reference/architecture.md +++ b/packages/docs/content/docs/reference/architecture.md @@ -16,14 +16,16 @@ subgraph HappyView Query["Query Handler
Lua Script (Optional)"] Procedure["Procedure Handler
Lua Script (Optional)"] + Spaces["Spaces
Permissioned Data"] end Procedure --> DB Query --> DB + Spaces --> DB Procedure -->|proxy write| PDS["User PDS"] - DB[("SQLite / PostgreSQL
records · lexicons")] + DB[("SQLite / PostgreSQL
records · lexicons · spaces")] Jetstream["Jetstream
WebSocket"] -->|record events| DB Relay["Relay
listReposByCollection"] -->|repo discovery| Backfill @@ -33,7 +35,7 @@ Labeler["Labeler
WebSocket (out-of-band)"] -->|label events| DB ``` -Queries go through the query handler to the database (SQLite by default, or Postgres). Writes go through the procedure handler to the user's PDS, then HappyView indexes the record locally. Real-time record events stream in via [Jetstream](https://github.com/bluesky-social/jetstream); historical records are backfilled in-process by discovering repos via the relay's `listReposByCollection` and fetching records directly from each PDS. [Labelers](../guides/labelers.md) are external services that emit content labels over a direct WebSocket connection — they operate out-of-band, outside the relay/repo system. +Queries go through the query handler to the database (SQLite by default, or Postgres). Writes go through the procedure handler to the user's PDS, then HappyView indexes the record locally. Real-time record events stream in via [Jetstream](https://github.com/bluesky-social/jetstream); historical records are backfilled in-process by discovering repos via the relay's `listReposByCollection` and fetching records directly from each PDS. [Labelers](../guides/labelers.md) are external services that emit content labels over a direct WebSocket connection — they operate out-of-band, outside the relay/repo system. [Spaces](../experimental/spaces/index.md) provide permissioned data containers with membership-gated access, per-user repo state tracking (LtHash + signed commits), and cross-service credential-based authentication. ## Request flow @@ -286,6 +288,175 @@ | `key` | text (PK) | Variable name | | `value` | text | Variable value (encrypted at rest) | | `created_at` | timestamptz | | | `updated_at` | timestamptz | | + +### `spaces` + +| Column | Type | Description | +| ----------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | Internal space identifier | +| `did` | text | The space's own DID | +| `authority_did` | text | DID that controls the space | +| `creator_did` | text | DID of the user who created the space | +| `type_nsid` | text | Space type as an NSID | +| `skey` | text | Space key (differentiates spaces of the same type) | +| `display_name` | text | Human-readable name (optional) | +| `description` | text | Description (optional) | +| `mint_policy` | text | `member-list`, `public`, or `managing-app` | +| `app_access` | text (JSON) | `{"type":"open"}` or `{"type":"allowList","allowed":[...]}` | +| `managing_app_did`| text | DID of the managing app (optional) | +| `config` | text (JSON) | Space config (`membershipPublic`, `recordsPublic`, extras) | +| `revision` | text | Current revision TID | +| `created_at` | text | | +| `updated_at` | text | | + +### `space_members` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `did` | text | Member's DID (or space URI for delegation) | +| `access` | text | `read`, `read_self`, or `write` | +| `is_delegation`| boolean | Whether this member is a delegated space | +| `granted_by` | text | DID of who granted membership | +| `created_at` | text | | + +### `space_records` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `uri` | text (PK) | `ats://` URI of the record | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the record author | +| `collection` | text | Lexicon NSID | +| `rkey` | text | Record key | +| `record` | jsonb | Record value | +| `cid` | text | Content identifier | +| `indexed_at` | text | | + +### `space_repo_state` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the repo author | +| `lthash_state` | bytea | 2048-byte LtHash state | +| `rev` | text | Current revision | +| `hash` | bytea | Content hash | +| `ikm` | bytea | Input keying material for deniable signatures | +| `sig` | bytea | Signature | +| `mac` | bytea | Message authentication code | +| `updated_at` | text | | + +### `space_record_oplog` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the operation author | +| `rev` | text | Revision this operation belongs to | +| `idx` | integer | Index within the revision | +| `action` | text | `create`, `update`, or `delete` | +| `collection` | text | Lexicon NSID | +| `rkey` | text | Record key | +| `cid` | text | Content identifier (for create/update) | +| `prev` | text | Previous CID (for update/delete) | +| `created_at` | text | | + +### `space_notify_registrations` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | Filter by author DID (optional) | +| `endpoint` | text | Notification endpoint URL | +| `registered_by`| text | DID of who registered | +| `expires_at` | text | When the registration expires | +| `created_at` | text | | + +### `space_invites` + +| Column | Type | Description | +| ------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `token_hash` | text | SHA-256 hash of the invite token | +| `created_by` | text | DID of the user who created the invite | +| `access` | text | Access level granted: `read`, `read_self`, `write` | +| `max_uses` | integer? | Maximum number of uses (null = unlimited) | +| `uses` | integer | Current use count | +| `expires_at` | text? | Expiry timestamp (null = never) | +| `revoked` | boolean | Whether the invite has been revoked | +| `created_at` | text | | + +### `space_credentials` + +| Column | Type | Description | +| ------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `issued_to` | text | DID the credential was issued to | +| `token_hash` | text | Hash of the credential token | +| `expires_at` | text | When the credential expires | +| `created_at` | text | | + +### `space_dids` + +| Column | Type | Description | +| ------------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `did` | text | The space's DID | +| `space_id` | text (FK) | References `spaces.id` | +| `signing_key_enc` | text | Encrypted signing key (AES-256-GCM) | +| `rotation_key_enc` | text | Encrypted rotation key (AES-256-GCM) | +| `created_by` | text | DID of who provisioned the key | +| `created_at` | text | | + +### `service_identity` + +| Column | Type | Description | +| --------------------- | ----------- | ------------------------------------------------ | +| `id` | integer (PK)| Always 1 (singleton) | +| `mode` | text | `did_web`, `did_plc`, or `linked_account` | +| `did` | text | The service's DID | +| `signing_key_enc` | text | Encrypted signing key | +| `rotation_key_enc` | text? | Encrypted rotation key (did:plc only) | +| `attached_account_did`| text? | Linked account DID (linked_account mode) | +| `setup_complete` | boolean | Whether setup has been finalized | +| `created_at` | text | | +| `updated_at` | text | | + +### `service_entries` + +| Column | Type | Description | +| ------------- | ----------- | ------------------------------------------------ | +| `id` | integer (PK)| | +| `fragment_id` | text | DID document fragment identifier | +| `service_type`| text | Service type (e.g. `AtprotoAppView`) | +| `access_mode` | text | `all` or scoped to specific XRPCs | +| `created_at` | text | | +| `updated_at` | text | | + +### `service_entry_xrpcs` + +| Column | Type | Description | +| ------------------ | ----------- | ------------------------------------------------ | +| `service_entry_id` | integer (FK)| References `service_entries.id` | +| `lexicon_id` | text | Lexicon NSID this entry handles | + +### `verification_methods` + +| Column | Type | Description | +| ----------------------- | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `fragment_id` | text | DID document fragment (e.g. `#atproto_space`) | +| `key_type` | text | Always `Multikey` | +| `public_key_multibase` | text | Public key in multibase encoding | +| `private_key_enc` | text | Encrypted private key (AES-256-GCM) | +| `created_at` | text | | ### `backfill_jobs` diff --git a/packages/docs/content/docs/reference/glossary.md b/packages/docs/content/docs/reference/glossary.md --- a/packages/docs/content/docs/reference/glossary.md +++ b/packages/docs/content/docs/reference/glossary.md @@ -36,13 +36,29 @@ **Jetstream** — A [filtered firehose](https://github.com/bluesky-social/jetstream) that delivers atproto record commit events as JSON over WebSocket. Not part of the core atproto spec, but widely used. HappyView subscribes to Jetstream with a collection filter built from its indexed record lexicons, and persists a cursor for resume on reconnect. ## HappyView-specific terms +**App Access** — Controls which third-party apps can interact with a space. Either `open` (any app) or `allowList` (only specified apps). Set via `com.atproto.simplespace.updateConfig`. + +**Authority DID** — The DID that controls a space. Distinct from the creator DID (who originally created it). Replaces the earlier `owner_did` concept. + **Backfill** — The process of bulk-indexing existing records from the network. HappyView discovers repos via the relay and fetches each repo's records directly from its PDS. Runs when a new record-type lexicon is uploaded or triggered manually. See [Backfill](../guides/backfill.md). + +**Delegation Token** — A short-lived JWT (`typ: atproto-space-delegation+jwt`, ES256K, 60-second TTL) that proves a user is a member of a space. Used as step 1 of the credential issuance flow. Obtained via `com.atproto.space.getDelegationToken`. + +**LtHash** — A homomorphic set-hash used for per-user repo state in spaces. Uses a 2048-byte state with 1024 little-endian uint16 lanes and BLAKE3 XOF. Supports incremental insert/remove operations. + +**Mint Policy** — Controls who can create permissioned repos in a space: `member-list` (only members), `public` (anyone), or `managing-app` (only the managing app). **Network lexicon** — A lexicon fetched directly from the atproto network via DNS authority resolution, rather than uploaded manually. See [Lexicons - Network lexicons](../guides/lexicons.md#network-lexicons). -**Permission** — A granular access control right that authorizes a specific action in the admin API. HappyView defines 20 permissions organized by category (e.g. `lexicons:create`, `users:read`). See [Permissions](../guides/permissions.md). +**Permission** — A granular access control right that authorizes a specific action in the admin API. HappyView defines 44 permissions organized by category (e.g. `lexicons:create`, `users:read`). See [Permissions](../guides/permissions.md). + +**Permissioned Data** — AT Protocol data that is gated by membership in a space, as opposed to public repo data. Defined by AT Protocol Proposal 0016. -**Permission template** — A predefined set of permissions that can be applied when creating a user. Templates are: **Viewer** (read-only access), **Operator** (viewer + backfill and API key management), **Manager** (operator + lexicon and record management), and **Full Access** (all 20 permissions). +**Permission template** — A predefined set of permissions that can be applied when creating a user. Templates are: **Viewer** (read-only access), **Operator** (viewer + backfill and API key management), **Manager** (operator + lexicon, record, spaces, and plugin management), and **Full Access** (all 44 permissions). + +**Space** — A container for permissioned data in AT Protocol. Identified by a space DID, type NSID, and space key (skey), forming an `ats://` URI. + +**Space Credential** — A short-lived JWT (`typ: atproto-space-credential+jwt`, ES256, 2-hour TTL) for cross-service read access to space data. Signed by the space's P-256 keypair. Obtained by exchanging a delegation token via `com.atproto.space.getSpaceCredential`. **Super user** — The bootstrapped user created on first login to a fresh HappyView instance. The super user has unrestricted access to all endpoints regardless of permissions, can transfer super status to another user, and cannot be deleted. diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -99,7 +99,7 @@ if (!resp.ok) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to provision DPoP key: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to provision DPoP key: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); @@ -149,7 +149,7 @@ if (!resp.ok) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to register session: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to register session: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); @@ -203,7 +203,7 @@ if (!resp.ok && resp.status !== 404) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to delete session: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to delete session: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); @@ -254,7 +254,7 @@ if (!resp.ok) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to get session: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to get session: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); diff --git a/src/admin/api_clients.rs b/src/admin/api_clients.rs --- a/src/admin/api_clients.rs +++ b/src/admin/api_clients.rs @@ -66,7 +66,7 @@ .as_ref() .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); let insert_sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", state.db_backend, ); @@ -173,7 +173,7 @@ let (select_sql, parent_filter) = if let Some(ref parent_id) = query.parent_id { ( adapt_sql( - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients WHERE parent_client_id = ? ORDER BY created_at DESC", + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM happyview_api_clients WHERE parent_client_id = ? ORDER BY created_at DESC", state.db_backend, ), Some(parent_id.clone()), @@ -181,7 +181,7 @@ ) } else { ( adapt_sql( - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients ORDER BY created_at DESC", + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM happyview_api_clients ORDER BY created_at DESC", state.db_backend, ), None, @@ -245,7 +245,7 @@ ) -> Result, AppError> { auth.require(Permission::ApiClientsView).await?; let select_sql = adapt_sql( - "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM api_clients WHERE id = ?", + "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active, created_by, created_at, updated_at, parent_client_id, owner_did FROM happyview_api_clients WHERE id = ?", state.db_backend, ); @@ -299,7 +299,7 @@ auth.require(Permission::ApiClientsEdit).await?; // Read current values let select_sql = adapt_sql( - "SELECT client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active FROM api_clients WHERE id = ?", + "SELECT client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, allowed_origins, rate_limit_capacity, rate_limit_refill_rate, is_active FROM happyview_api_clients WHERE id = ?", state.db_backend, ); @@ -362,7 +362,7 @@ .unwrap_or(cur_active); let now = now_rfc3339(); let update_sql = adapt_sql( - "UPDATE api_clients SET name = ?, client_uri = ?, redirect_uris = ?, scopes = ?, allowed_origins = ?, rate_limit_capacity = ?, rate_limit_refill_rate = ?, is_active = ?, updated_at = ? WHERE id = ?", + "UPDATE happyview_api_clients SET name = ?, client_uri = ?, redirect_uris = ?, scopes = ?, allowed_origins = ?, rate_limit_capacity = ?, rate_limit_refill_rate = ?, is_active = ?, updated_at = ? WHERE id = ?", state.db_backend, ); @@ -435,7 +435,7 @@ state.rate_limiter.remove_client_config(&client_key); // Cascade deactivation to child clients. let deactivate_children_sql = adapt_sql( - "UPDATE api_clients SET is_active = 0, updated_at = ? WHERE parent_client_id = ? AND is_active = 1", + "UPDATE happyview_api_clients SET is_active = 0, updated_at = ? WHERE parent_client_id = ? AND is_active = 1", state.db_backend, ); let _ = sqlx::query(&deactivate_children_sql) @@ -445,7 +445,7 @@ .execute(&state.db) .await; let children_sql = adapt_sql( - "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", + "SELECT client_id_url, client_key FROM happyview_api_clients WHERE parent_client_id = ?", state.db_backend, ); if let Ok(children) = sqlx::query_as::<_, (String, String)>(&children_sql) @@ -487,7 +487,7 @@ auth.require(Permission::ApiClientsDelete).await?; // Look up client_id_url and client_key before deleting so we can remove from registries. let lookup_sql = adapt_sql( - "SELECT client_id_url, client_key FROM api_clients WHERE id = ?", + "SELECT client_id_url, client_key FROM happyview_api_clients WHERE id = ?", state.db_backend, ); let client_info: Option<(String, String)> = sqlx::query_as(&lookup_sql) @@ -498,7 +498,7 @@ .map_err(|e| AppError::Internal(format!("failed to look up api client: {e}")))?; // Look up child clients before deleting (ON DELETE CASCADE will remove DB rows). let children_sql = adapt_sql( - "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", + "SELECT client_id_url, client_key FROM happyview_api_clients WHERE parent_client_id = ?", state.db_backend, ); let children: Vec<(String, String)> = sqlx::query_as(&children_sql) @@ -507,7 +507,10 @@ .fetch_all(&state.db) .await .unwrap_or_default(); - let delete_sql = adapt_sql("DELETE FROM api_clients WHERE id = ?", state.db_backend); + let delete_sql = adapt_sql( + "DELETE FROM happyview_api_clients WHERE id = ?", + state.db_backend, + ); let result = sqlx::query(&delete_sql) .bind(&id) diff --git a/src/admin/api_keys.rs b/src/admin/api_keys.rs --- a/src/admin/api_keys.rs +++ b/src/admin/api_keys.rs @@ -56,7 +56,7 @@ let permissions_json = serde_json::to_string(&body.permissions).unwrap_or_else(|_| "[]".to_string()); let insert_sql = adapt_sql( - "INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", state.db_backend, ); @@ -105,7 +105,7 @@ ) -> Result>, AppError> { auth.require(Permission::ApiKeysRead).await?; let select_sql = adapt_sql( - "SELECT k.id, k.name, k.key_prefix, k.permissions, k.created_at, k.last_used_at, k.revoked_at FROM api_keys k JOIN users u ON u.id = k.user_id WHERE u.did = ? ORDER BY k.created_at DESC", + "SELECT k.id, k.name, k.key_prefix, k.permissions, k.created_at, k.last_used_at, k.revoked_at FROM happyview_api_keys k JOIN happyview_users u ON u.id = k.user_id WHERE u.did = ? ORDER BY k.created_at DESC", state.db_backend, ); @@ -156,7 +156,7 @@ auth.require(Permission::ApiKeysDelete).await?; let now = now_rfc3339(); let update_sql = adapt_sql( - "UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = (SELECT id FROM users WHERE did = ?) AND revoked_at IS NULL", + "UPDATE happyview_api_keys SET revoked_at = ? WHERE id = ? AND user_id = (SELECT id FROM happyview_users WHERE did = ?) AND revoked_at IS NULL", state.db_backend, ); diff --git a/src/admin/auth.rs b/src/admin/auth.rs --- a/src/admin/auth.rs +++ b/src/admin/auth.rs @@ -54,7 +54,7 @@ user_id: &str, backend: DatabaseBackend, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT permission FROM user_permissions WHERE user_id = ?", + "SELECT permission FROM happyview_user_permissions WHERE user_id = ?", backend, ); let rows: Vec<(String,)> = sqlx::query_as(&sql) @@ -110,7 +110,7 @@ let claims = Claims::from_request_parts(parts, state).await?; let did = claims.did().to_string(); let backend = state.db_backend; - let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM happyview_users") .fetch_one(&state.db) .await .map_err(|e| AppError::Internal(format!("user count query failed: {e}")))?; @@ -121,7 +121,7 @@ let id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let insert_sql = adapt_sql( - "INSERT INTO users (id, did, is_super, created_at) VALUES (?, ?, ?, ?)", + "INSERT INTO happyview_users (id, did, is_super, created_at) VALUES (?, ?, ?, ?)", backend, ); @@ -135,7 +135,7 @@ .await; if result.is_ok() { let perm_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_at) VALUES (?, ?, ?)", + "INSERT INTO happyview_user_permissions (user_id, permission, granted_at) VALUES (?, ?, ?)", backend, ); for perm in Permission::all() { @@ -164,7 +164,10 @@ .await; } } - let select_sql = adapt_sql("SELECT id, is_super FROM users WHERE did = ?", backend); + let select_sql = adapt_sql( + "SELECT id, is_super FROM happyview_users WHERE did = ?", + backend, + ); let found: Option<(String, i32)> = sqlx::query_as(&select_sql) .bind(&did) .fetch_optional(&state.db) @@ -185,7 +188,10 @@ let db = state.db.clone(); let uid = user_id.clone(); let now = now_rfc3339(); - let update_sql = adapt_sql("UPDATE users SET last_used_at = ? WHERE id = ?", backend); + let update_sql = adapt_sql( + "UPDATE happyview_users SET last_used_at = ? WHERE id = ?", + backend, + ); tokio::spawn(async move { let _ = sqlx::query(&update_sql) .bind(&now) @@ -229,7 +235,7 @@ let hash = hex::encode(Sha256::digest(token.as_bytes())); let backend = state.db_backend; let select_sql = adapt_sql( - "SELECT k.id, u.id, u.did, u.is_super, k.permissions FROM api_keys k JOIN users u ON u.id = k.user_id WHERE k.key_hash = ? AND k.revoked_at IS NULL", + "SELECT k.id, u.id, u.did, u.is_super, k.permissions FROM happyview_api_keys k JOIN happyview_users u ON u.id = k.user_id WHERE k.key_hash = ? AND k.revoked_at IS NULL", backend, ); @@ -256,7 +262,10 @@ }; let db = state.db.clone(); let now = now_rfc3339(); - let update_sql = adapt_sql("UPDATE api_keys SET last_used_at = ? WHERE id = ?", backend); + let update_sql = adapt_sql( + "UPDATE happyview_api_keys SET last_used_at = ? WHERE id = ?", + backend, + ); tokio::spawn(async move { let _ = sqlx::query(&update_sql) .bind(&now) diff --git a/src/admin/backfill.rs b/src/admin/backfill.rs --- a/src/admin/backfill.rs +++ b/src/admin/backfill.rs @@ -59,7 +59,7 @@ // --------------------------------------------------------------------------- async fn set_stage(state: &AppState, job_id: &str, stage: &str) { let sql = adapt_sql( - "UPDATE backfill_jobs SET stage = ? WHERE id = ?", + "UPDATE happyview_backfill_jobs SET stage = ? WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -78,10 +78,10 @@ } async fn update_job_counter(state: &AppState, job_id: &str, column: &str, value: i32) { let query = match column { - "total_repos" => "UPDATE backfill_jobs SET total_repos = ? WHERE id = ?", - "resolved_repos" => "UPDATE backfill_jobs SET resolved_repos = ? WHERE id = ?", - "processed_repos" => "UPDATE backfill_jobs SET processed_repos = ? WHERE id = ?", - "total_records" => "UPDATE backfill_jobs SET total_records = ? WHERE id = ?", + "total_repos" => "UPDATE happyview_backfill_jobs SET total_repos = ? WHERE id = ?", + "resolved_repos" => "UPDATE happyview_backfill_jobs SET resolved_repos = ? WHERE id = ?", + "processed_repos" => "UPDATE happyview_backfill_jobs SET processed_repos = ? WHERE id = ?", + "total_records" => "UPDATE happyview_backfill_jobs SET total_records = ? WHERE id = ?", other => { tracing::error!( column = other, @@ -100,7 +100,7 @@ } async fn count_repos(state: &AppState, job_id: &str) -> i32 { let sql = adapt_sql( - "SELECT COUNT(*) FROM backfill_repos WHERE job_id = ?", + "SELECT COUNT(*) FROM happyview_backfill_repos WHERE job_id = ?", state.db_backend, ); sqlx::query_as::<_, (i32,)>(&sql) @@ -160,7 +160,7 @@ async fn fail_job(state: &AppState, job_id: &str, error: &str) { let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'failed', completed_at = ?, error = ? WHERE id = ?", + "UPDATE happyview_backfill_jobs SET status = 'failed', completed_at = ?, error = ? WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -181,7 +181,7 @@ } async fn should_stop(state: &AppState, job_id: &str) -> Option<&'static str> { let sql = adapt_sql( - "SELECT status FROM backfill_jobs WHERE id = ?", + "SELECT status FROM happyview_backfill_jobs WHERE id = ?", state.db_backend, ); let status = sqlx::query_as::<_, (String,)>(&sql) @@ -204,7 +204,7 @@ } async fn request_cancel(state: &AppState, job_id: &str) { let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'cancelling' WHERE id = ? AND status IN ('running', 'paused')", + "UPDATE happyview_backfill_jobs SET status = 'cancelling' WHERE id = ? AND status IN ('running', 'paused')", state.db_backend, ); let _ = sqlx::query(&sql) @@ -216,7 +216,7 @@ async fn finalise_cancel(state: &AppState, job_id: &str) { let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'cancelled', completed_at = ?, error = 'cancelled by user' WHERE id = ?", + "UPDATE happyview_backfill_jobs SET status = 'cancelled', completed_at = ?, error = 'cancelled by user' WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -236,7 +236,7 @@ } async fn request_pause(state: &AppState, job_id: &str) { let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'pausing' WHERE id = ? AND status = 'running'", + "UPDATE happyview_backfill_jobs SET status = 'pausing' WHERE id = ? AND status = 'running'", state.db_backend, ); let _ = sqlx::query(&sql) @@ -247,7 +247,7 @@ } async fn finalise_pause(state: &AppState, job_id: &str) { let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'paused' WHERE id = ?", + "UPDATE happyview_backfill_jobs SET status = 'paused' WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -273,7 +273,7 @@ error: Option<&str>, ) { let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'completed', stage = 'completed', completed_at = ?, processed_repos = ?, total_records = ?, error = ? WHERE id = ?", + "UPDATE happyview_backfill_jobs SET status = 'completed', stage = 'completed', completed_at = ?, processed_repos = ?, total_records = ?, error = ? WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -308,7 +308,7 @@ set_stage(state, job_id, "discovering_repos").await; if let Some(did) = specific_did { let sql = adapt_sql( - "INSERT INTO backfill_repos (job_id, did) VALUES (?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO happyview_backfill_repos (job_id, did) VALUES (?, ?) ON CONFLICT DO NOTHING", state.db_backend, ); let _ = sqlx::query(&sql) @@ -405,7 +405,7 @@ 1000 }; for chunk in body.repos.chunks(chunk_size) { - let base_sql = "INSERT INTO backfill_repos (job_id, did) VALUES "; + let base_sql = "INSERT INTO happyview_backfill_repos (job_id, did) VALUES "; let placeholders: Vec = chunk .iter() .enumerate() @@ -472,7 +472,7 @@ // Count already-resolved and already-completed repos for accurate progress let already_resolved: i32 = { let sql = adapt_sql( - "SELECT COUNT(*) FROM backfill_repos WHERE job_id = ? AND pds_endpoint IS NOT NULL", + "SELECT COUNT(*) FROM happyview_backfill_repos WHERE job_id = ? AND pds_endpoint IS NOT NULL", state.db_backend, ); sqlx::query_as::<_, (i32,)>(&sql) @@ -485,7 +485,7 @@ }; let already_completed: i32 = { let sql = adapt_sql( - "SELECT COUNT(*) FROM backfill_repos WHERE job_id = ? AND status = 'completed'", + "SELECT COUNT(*) FROM happyview_backfill_repos WHERE job_id = ? AND status = 'completed'", state.db_backend, ); sqlx::query_as::<_, (i32,)>(&sql) @@ -501,7 +501,7 @@ update_job_counter(state, job_id, "processed_repos", already_completed).await; let existing_records: i32 = { let sql = adapt_sql( - "SELECT total_records FROM backfill_jobs WHERE id = ?", + "SELECT total_records FROM happyview_backfill_jobs WHERE id = ?", state.db_backend, ); sqlx::query_as::<_, (Option,)>(&sql) @@ -531,7 +531,7 @@ let resolver_cancelled = Arc::clone(&cancelled); let resolver_handle = tokio::spawn(async move { let sql = adapt_sql( - "SELECT did FROM backfill_repos WHERE job_id = ? AND pds_endpoint IS NULL", + "SELECT did FROM happyview_backfill_repos WHERE job_id = ? AND pds_endpoint IS NULL", resolver_state.db_backend, ); let unresolved: Vec<(String,)> = sqlx::query_as(&sql) @@ -570,7 +570,7 @@ match result { Ok(pds) => { let sql = adapt_sql( - "UPDATE backfill_repos SET pds_endpoint = ? WHERE job_id = ? AND did = ?", + "UPDATE happyview_backfill_repos SET pds_endpoint = ? WHERE job_id = ? AND did = ?", resolver_state.db_backend, ); let _ = sqlx::query(&sql) @@ -644,7 +644,7 @@ }); // --- Also send already-resolved-but-unfetched DIDs to the fetcher --- let pending_sql = adapt_sql( - "SELECT did, pds_endpoint FROM backfill_repos WHERE job_id = ? AND status = 'pending' AND pds_endpoint IS NOT NULL", + "SELECT did, pds_endpoint FROM happyview_backfill_repos WHERE job_id = ? AND status = 'pending' AND pds_endpoint IS NOT NULL", state.db_backend, ); let pending_rows: Vec<(String, String)> = sqlx::query_as(&pending_sql) @@ -827,7 +827,7 @@ let final_records = total_records.load(Ordering::Relaxed); // Persist final counts let sql = adapt_sql( - "UPDATE backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", + "UPDATE happyview_backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -874,7 +874,7 @@ total_records.fetch_add(records, Ordering::Relaxed); // Mark DID as completed let sql = adapt_sql( - "UPDATE backfill_repos SET status = 'completed', records_fetched = ? WHERE job_id = ? AND did = ?", + "UPDATE happyview_backfill_repos SET status = 'completed', records_fetched = ? WHERE job_id = ? AND did = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -895,7 +895,7 @@ let repos = processed_repos.fetch_add(1, Ordering::Relaxed) + 1; let records = total_records.load(Ordering::Relaxed); if repos >= next_flush { let sql = adapt_sql( - "UPDATE backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", + "UPDATE happyview_backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -973,7 +973,7 @@ let (did, records): (String, i32) = result; total_records.fetch_add(records, Ordering::Relaxed); let sql = adapt_sql( - "UPDATE backfill_repos SET status = 'completed', records_fetched = ? WHERE job_id = ? AND did = ?", + "UPDATE happyview_backfill_repos SET status = 'completed', records_fetched = ? WHERE job_id = ? AND did = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -1011,7 +1011,7 @@ set_stage(state, job_id, "fetching_records").await; // Load pending repos grouped by PDS let sql = adapt_sql( - "SELECT did, pds_endpoint FROM backfill_repos WHERE job_id = ? AND status = 'pending' AND pds_endpoint IS NOT NULL", + "SELECT did, pds_endpoint FROM happyview_backfill_repos WHERE job_id = ? AND status = 'pending' AND pds_endpoint IS NOT NULL", state.db_backend, ); let rows: Vec<(String, String)> = sqlx::query_as(&sql) @@ -1027,7 +1027,7 @@ } // Count already-completed repos for accurate progress let sql = adapt_sql( - "SELECT COUNT(*) FROM backfill_repos WHERE job_id = ? AND status = 'completed'", + "SELECT COUNT(*) FROM happyview_backfill_repos WHERE job_id = ? AND status = 'completed'", state.db_backend, ); let already_completed: i32 = sqlx::query_as::<_, (i32,)>(&sql) @@ -1043,7 +1043,7 @@ // Seed total_records from DB so a resumed job doesn't lose its prior count let existing_records: i32 = { let sql = adapt_sql( - "SELECT total_records FROM backfill_jobs WHERE id = ?", + "SELECT total_records FROM happyview_backfill_jobs WHERE id = ?", state.db_backend, ); sqlx::query_as::<_, (Option,)>(&sql) @@ -1127,7 +1127,7 @@ } // Mark DID as completed let sql = adapt_sql( - "UPDATE backfill_repos SET status = 'completed', records_fetched = ? WHERE job_id = ? AND did = ?", + "UPDATE happyview_backfill_repos SET status = 'completed', records_fetched = ? WHERE job_id = ? AND did = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -1146,7 +1146,7 @@ && next_flush.compare_exchange(threshold, repos + random_batch_threshold(10), Ordering::Relaxed, Ordering::Relaxed).is_ok() { let backend = state.db_backend; let sql = adapt_sql( - "UPDATE backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", + "UPDATE happyview_backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", backend, ); let _ = sqlx::query(&sql) @@ -1180,7 +1180,7 @@ let final_records = total_records.load(Ordering::Relaxed); // Persist final counts so they're accurate regardless of batch size let sql = adapt_sql( - "UPDATE backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", + "UPDATE happyview_backfill_jobs SET processed_repos = ?, total_records = ? WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -1215,7 +1215,7 @@ let placeholders: Vec = (0..batch.len()) .map(|_| "(?, ?, ?, ?, ?, ?, ?, ?)".to_string()) .collect(); let raw_sql = format!( - "INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES {} ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at", + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES {} ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at", placeholders.join(", ") ); let sql = adapt_sql(&raw_sql, backend); @@ -1241,7 +1241,7 @@ // Batch sync_refs: delete old refs for all URIs, then insert new ones. let uris: Vec<&str> = batch.iter().map(|r| r.uri.as_str()).collect(); let delete_placeholders: Vec<&str> = (0..uris.len()).map(|_| "?").collect(); let delete_raw = format!( - "DELETE FROM record_refs WHERE source_uri IN ({})", + "DELETE FROM happyview_record_refs WHERE source_uri IN ({})", delete_placeholders.join(", ") ); let delete_sql = adapt_sql(&delete_raw, backend); @@ -1265,7 +1265,7 @@ // Insert refs in chunks to stay within SQLite's param limit (3 params per ref) for chunk in all_refs.chunks(300) { let ref_placeholders: Vec<&str> = (0..chunk.len()).map(|_| "(?, ?, ?)").collect(); let ref_raw = format!( - "INSERT INTO record_refs (source_uri, target_uri, collection) VALUES {} ON CONFLICT DO NOTHING", + "INSERT INTO happyview_record_refs (source_uri, target_uri, collection) VALUES {} ON CONFLICT DO NOTHING", ref_placeholders.join(", ") ); let ref_sql = adapt_sql(&ref_raw, backend); @@ -1279,7 +1279,7 @@ // Queue label backfill only if there are active labeler subscriptions. // Check once per batch instead of spawning a task per record. let has_subscriptions: bool = sqlx::query_as::<_, (i64,)>( - "SELECT COUNT(*) FROM labeler_subscriptions WHERE status = 'active'", + "SELECT COUNT(*) FROM happyview_labeler_subscriptions WHERE status = 'active'", ) .fetch_one(&state.db) .await @@ -1395,7 +1395,7 @@ let backend = state.db_backend; // Load job metadata let sql = adapt_sql( - "SELECT collection, did, stage FROM backfill_jobs WHERE id = ?", + "SELECT collection, did, stage FROM happyview_backfill_jobs WHERE id = ?", backend, ); let job: Option<(Option, Option, String)> = sqlx::query_as(&sql) @@ -1425,7 +1425,7 @@ } vec![col.clone()] } else { let sql = adapt_sql( - "SELECT id FROM lexicons WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record'", + "SELECT id FROM happyview_lexicons WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record'", backend, ); let rows: Vec<(String,)> = match sqlx::query_as(&sql).fetch_all(&state.backfill_db).await { @@ -1553,7 +1553,7 @@ let now = now_rfc3339(); let job_id = Uuid::new_v4().to_string(); let sql = adapt_sql( - "INSERT INTO backfill_jobs (id, collection, did, status, stage, started_at, created_at) VALUES (?, ?, ?, 'running', 'pending', ?, ?) RETURNING id", + "INSERT INTO happyview_backfill_jobs (id, collection, did, status, stage, started_at, created_at) VALUES (?, ?, ?, 'running', 'pending', ?, ?) RETURNING id", backend, ); let row: (String,) = sqlx::query_as(&sql) @@ -1607,7 +1607,7 @@ ) -> Result, AppError> { admin.require(Permission::BackfillCreate).await?; let sql = adapt_sql( - "SELECT status FROM backfill_jobs WHERE id = ?", + "SELECT status FROM happyview_backfill_jobs WHERE id = ?", state.db_backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -1672,7 +1672,7 @@ ) -> Result, AppError> { admin.require(Permission::BackfillCreate).await?; let sql = adapt_sql( - "SELECT status FROM backfill_jobs WHERE id = ?", + "SELECT status FROM happyview_backfill_jobs WHERE id = ?", state.db_backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -1719,7 +1719,7 @@ ) -> Result, AppError> { admin.require(Permission::BackfillCreate).await?; let sql = adapt_sql( - "SELECT status FROM backfill_jobs WHERE id = ?", + "SELECT status FROM happyview_backfill_jobs WHERE id = ?", state.db_backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -1735,7 +1735,7 @@ "job is not paused (status: {status})" ))), Some(_) => { let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'running' WHERE id = ?", + "UPDATE happyview_backfill_jobs SET status = 'running' WHERE id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -1777,7 +1777,7 @@ auth.require(Permission::BackfillRead).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT id, collection, did, status, stage, total_repos, resolved_repos, processed_repos, total_records, error, started_at, completed_at, created_at FROM backfill_jobs ORDER BY created_at DESC", + "SELECT id, collection, did, status, stage, total_repos, resolved_repos, processed_repos, total_records, error, started_at, completed_at, created_at FROM happyview_backfill_jobs ORDER BY created_at DESC", backend, ); #[allow(clippy::type_complexity)] @@ -1921,7 +1921,7 @@ "" }; let sql_str = format!( - "SELECT did, pds_endpoint, status, records_fetched FROM backfill_repos WHERE job_id = ?{phase_filter}{cursor_filter} ORDER BY did ASC LIMIT ?", + "SELECT did, pds_endpoint, status, records_fetched FROM happyview_backfill_repos WHERE job_id = ?{phase_filter}{cursor_filter} ORDER BY did ASC LIMIT ?", ); let sql = adapt_sql(&sql_str, state.db_backend); @@ -1967,7 +1967,7 @@ ) -> Result, AppError> { auth.require(Permission::BackfillRead).await?; let sql = adapt_sql( - "SELECT pds_endpoint, COUNT(*) as total_repos, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_repos, SUM(records_fetched) as total_records FROM backfill_repos WHERE job_id = ? AND pds_endpoint IS NOT NULL GROUP BY pds_endpoint ORDER BY COUNT(*) DESC", + "SELECT pds_endpoint, COUNT(*) as total_repos, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_repos, SUM(records_fetched) as total_records FROM happyview_backfill_repos WHERE job_id = ? AND pds_endpoint IS NOT NULL GROUP BY pds_endpoint ORDER BY COUNT(*) DESC", state.db_backend, ); @@ -2006,7 +2006,7 @@ ) -> Result { auth.require(Permission::BackfillCreate).await?; let sql = adapt_sql( - "DELETE FROM backfill_repos WHERE job_id = ?", + "DELETE FROM happyview_backfill_repos WHERE job_id = ?", state.db_backend, ); let _ = sqlx::query(&sql) @@ -2024,7 +2024,7 @@ ) -> Result { auth.require(Permission::BackfillCreate).await?; let sql = adapt_sql( - "DELETE FROM backfill_repos WHERE job_id IN (SELECT id FROM backfill_jobs WHERE status IN ('completed', 'cancelled', 'failed'))", + "DELETE FROM happyview_backfill_repos WHERE job_id IN (SELECT id FROM happyview_backfill_jobs WHERE status IN ('completed', 'cancelled', 'failed'))", state.db_backend, ); let _ = sqlx::query(&sql).execute(&state.backfill_db).await; @@ -2061,7 +2061,7 @@ let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days); let cutoff_str = cutoff.to_rfc3339(); let sql = adapt_sql( - "DELETE FROM backfill_repos WHERE job_id IN (SELECT id FROM backfill_jobs WHERE completed_at IS NOT NULL AND completed_at < ?)", + "DELETE FROM happyview_backfill_repos WHERE job_id IN (SELECT id FROM happyview_backfill_jobs WHERE completed_at IS NOT NULL AND completed_at < ?)", state.db_backend, ); match sqlx::query(&sql) @@ -2094,7 +2094,7 @@ /// Resume any backfill jobs that were running when the server last stopped. /// Jobs stuck in `cancelling` are finalised immediately. pub async fn resume_backfill_jobs(state: &AppState) { let sql = adapt_sql( - "SELECT id, status FROM backfill_jobs WHERE status IN ('running', 'cancelling', 'pausing')", + "SELECT id, status FROM happyview_backfill_jobs WHERE status IN ('running', 'cancelling', 'pausing')", state.db_backend, ); let rows: Vec<(String, String)> = sqlx::query_as(&sql) diff --git a/src/admin/dead_letters.rs b/src/admin/dead_letters.rs --- a/src/admin/dead_letters.rs +++ b/src/admin/dead_letters.rs @@ -57,8 +57,8 @@ } fn table(self) -> &'static str { match self { - Self::LegacyHooks => "dead_letter_hooks", - Self::Scripts => "dead_letter_scripts", + Self::LegacyHooks => "happyview_dead_letter_hooks", + Self::Scripts => "happyview_dead_letter_scripts", } } } @@ -366,7 +366,7 @@ ) -> Result, AppError> { let backend = state.db_backend; let mut sql = String::from( "SELECT id, lexicon_id, uri, did, collection, rkey, action, error, attempts, created_at, resolved_at - FROM dead_letter_hooks WHERE 1=1", + FROM happyview_dead_letter_hooks WHERE 1=1", ); match resolved { "false" => sql.push_str(" AND resolved_at IS NULL"), @@ -440,7 +440,7 @@ ) -> Result, AppError> { let backend = state.db_backend; let mut sql = String::from( "SELECT id, script_ref, host_kind, host_id, payload, error, attempts, created_at, resolved_at - FROM dead_letter_scripts WHERE 1=1", + FROM happyview_dead_letter_scripts WHERE 1=1", ); match resolved { "false" => sql.push_str(" AND resolved_at IS NULL"), @@ -577,7 +577,7 @@ async fn detail_legacy(state: &AppState, id: &str) -> Result { let backend = state.db_backend; let sql = adapt_sql( "SELECT id, lexicon_id, uri, did, collection, rkey, action, error, attempts, created_at, resolved_at, record - FROM dead_letter_hooks WHERE id = ?", + FROM happyview_dead_letter_hooks WHERE id = ?", backend, ); @@ -624,7 +624,7 @@ async fn detail_scripts(state: &AppState, id: &str) -> Result { let backend = state.db_backend; let sql = adapt_sql( "SELECT id, script_ref, host_kind, host_id, payload, error, attempts, created_at, resolved_at - FROM dead_letter_scripts WHERE id = ?", + FROM happyview_dead_letter_scripts WHERE id = ?", backend, ); let id_int: i64 = id @@ -688,7 +688,7 @@ DeadLetterSource::LegacyHooks => { let backend = state.db_backend; let sql = adapt_sql( "SELECT id, lexicon_id, uri, did, collection, rkey, action, record, error, attempts - FROM dead_letter_hooks WHERE id = ? AND resolved_at IS NULL", + FROM happyview_dead_letter_hooks WHERE id = ? AND resolved_at IS NULL", backend, ); #[allow(clippy::type_complexity)] @@ -727,7 +727,7 @@ DeadLetterSource::Scripts => { let backend = state.db_backend; let id_int: i64 = id.parse().unwrap_or_default(); let sql = adapt_sql( - "SELECT id, host_kind, payload FROM dead_letter_scripts + "SELECT id, host_kind, payload FROM happyview_dead_letter_scripts WHERE id = ? AND resolved_at IS NULL", backend, ); @@ -827,7 +827,8 @@ let backend = state.db_backend; let mut ids: Vec = Vec::new(); // Legacy table — supports the optional collection filter. - let mut sql = String::from("SELECT id FROM dead_letter_hooks WHERE resolved_at IS NULL"); + let mut sql = + String::from("SELECT id FROM happyview_dead_letter_hooks WHERE resolved_at IS NULL"); if body.collection.is_some() { sql.push_str(" AND collection = ?"); } @@ -843,8 +844,9 @@ .map_err(|e| AppError::Internal(format!("failed to resolve bulk ids: {e}")))?; ids.extend(rows.into_iter().map(|r| r.0)); { - let mut sql = - String::from("SELECT id FROM dead_letter_scripts WHERE resolved_at IS NULL"); + let mut sql = String::from( + "SELECT id FROM happyview_dead_letter_scripts WHERE resolved_at IS NULL", + ); if body.collection.is_some() { sql.push_str(" AND collection = ?"); } diff --git a/src/admin/domains.rs b/src/admin/domains.rs --- a/src/admin/domains.rs +++ b/src/admin/domains.rs @@ -30,7 +30,7 @@ ) -> Result>, AppError> { auth.require(Permission::SettingsManage).await?; let sql = adapt_sql( - "SELECT id, url, is_primary, created_at, updated_at FROM domains ORDER BY created_at", + "SELECT id, url, is_primary, created_at, updated_at FROM happyview_domains ORDER BY created_at", state.db_backend, ); let rows: Vec<(String, String, i32, String, String)> = sqlx::query_as(&sql) @@ -85,7 +85,7 @@ } // Check for duplicates let existing: Option<(String,)> = sqlx::query_as(&adapt_sql( - "SELECT id FROM domains WHERE url = ?", + "SELECT id FROM happyview_domains WHERE url = ?", state.db_backend, )) .bind(&url) @@ -103,7 +103,7 @@ let id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO domains (id, url, is_primary, created_at, updated_at) VALUES (?, ?, 0, ?, ?)", + "INSERT INTO happyview_domains (id, url, is_primary, created_at, updated_at) VALUES (?, ?, 0, ?, ?)", state.db_backend, ); sqlx::query(&sql) @@ -195,7 +195,7 @@ ) -> Result { auth.require(Permission::SettingsManage).await?; let sql = adapt_sql( - "SELECT id, url, is_primary, created_at, updated_at FROM domains WHERE id = ?", + "SELECT id, url, is_primary, created_at, updated_at FROM happyview_domains WHERE id = ?", state.db_backend, ); let row: Option<(String, String, i32, String, String)> = sqlx::query_as(&sql) @@ -213,7 +213,10 @@ "cannot delete the primary domain — set a different domain as primary first".into(), )); } - let delete_sql = adapt_sql("DELETE FROM domains WHERE id = ?", state.db_backend); + let delete_sql = adapt_sql( + "DELETE FROM happyview_domains WHERE id = ?", + state.db_backend, + ); sqlx::query(&delete_sql) .bind(&id) .execute(&state.db) @@ -258,7 +261,7 @@ ) -> Result { auth.require(Permission::SettingsManage).await?; let sql = adapt_sql( - "SELECT id, url, is_primary, created_at, updated_at FROM domains WHERE id = ?", + "SELECT id, url, is_primary, created_at, updated_at FROM happyview_domains WHERE id = ?", state.db_backend, ); let row: Option<(String, String, i32, String, String)> = sqlx::query_as(&sql) @@ -272,7 +275,7 @@ let now = now_rfc3339(); let unset_sql = adapt_sql( - "UPDATE domains SET is_primary = 0, updated_at = ? WHERE is_primary = 1", + "UPDATE happyview_domains SET is_primary = 0, updated_at = ? WHERE is_primary = 1", state.db_backend, ); sqlx::query(&unset_sql) @@ -282,7 +285,7 @@ .await .map_err(|e| AppError::Internal(format!("failed to unset primary: {e}")))?; let set_sql = adapt_sql( - "UPDATE domains SET is_primary = 1, updated_at = ? WHERE id = ?", + "UPDATE happyview_domains SET is_primary = 1, updated_at = ? WHERE id = ?", state.db_backend, ); sqlx::query(&set_sql) diff --git a/src/admin/events.rs b/src/admin/events.rs --- a/src/admin/events.rs +++ b/src/admin/events.rs @@ -51,7 +51,7 @@ let limit = query.limit.unwrap_or(50).clamp(1, 100); let mut sql = String::from( "SELECT id, event_type, severity, actor_did, subject, detail, created_at - FROM event_logs WHERE 1=1", + FROM happyview_event_logs WHERE 1=1", ); if query.event_type.is_some() { diff --git a/src/admin/labelers.rs b/src/admin/labelers.rs --- a/src/admin/labelers.rs +++ b/src/admin/labelers.rs @@ -20,7 +20,7 @@ auth.require(Permission::LabelersRead).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT did, status, cursor, created_at, updated_at FROM labeler_subscriptions ORDER BY created_at", + "SELECT did, status, cursor, created_at, updated_at FROM happyview_labeler_subscriptions ORDER BY created_at", backend, ); let rows: Vec<(String, String, Option, String, String)> = sqlx::query_as(&sql) @@ -56,7 +56,7 @@ let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( r#" - INSERT INTO labeler_subscriptions (did, created_at) + INSERT INTO happyview_labeler_subscriptions (did, created_at) VALUES (?, ?) ON CONFLICT (did) DO UPDATE SET status = 'active', updated_at = ? "#, @@ -101,7 +101,7 @@ let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE labeler_subscriptions SET status = ?, updated_at = ? WHERE did = ?", + "UPDATE happyview_labeler_subscriptions SET status = ?, updated_at = ? WHERE did = ?", backend, ); let result = sqlx::query(&sql) @@ -145,7 +145,10 @@ ) -> Result { auth.require(Permission::LabelersDelete).await?; let backend = state.db_backend; - let delete_sql = adapt_sql("DELETE FROM labeler_subscriptions WHERE did = ?", backend); + let delete_sql = adapt_sql( + "DELETE FROM happyview_labeler_subscriptions WHERE did = ?", + backend, + ); let result = sqlx::query(&delete_sql) .bind(&did) .execute(&state.db) @@ -159,7 +162,7 @@ ))); } // Also remove all labels from this labeler. - let delete_labels_sql = adapt_sql("DELETE FROM labels WHERE src = ?", backend); + let delete_labels_sql = adapt_sql("DELETE FROM happyview_labels WHERE src = ?", backend); let _ = sqlx::query(&delete_labels_sql) .bind(&did) .execute(&state.db) diff --git a/src/admin/lexicons.rs b/src/admin/lexicons.rs --- a/src/admin/lexicons.rs +++ b/src/admin/lexicons.rs @@ -71,7 +71,7 @@ // Upsert into database let sql = adapt_sql( r#" - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action, token_cost, source, created_at) + INSERT INTO happyview_lexicons (id, lexicon_json, backfill, target_collection, action, token_cost, source, created_at) VALUES (?, ?, ?, ?, ?, ?, 'manual', ?) ON CONFLICT (id) DO UPDATE SET lexicon_json = EXCLUDED.lexicon_json, @@ -80,7 +80,7 @@ target_collection = EXCLUDED.target_collection, action = EXCLUDED.action, token_cost = EXCLUDED.token_cost, source = 'manual', - revision = lexicons.revision + 1, + revision = happyview_lexicons.revision + 1, updated_at = ? RETURNING revision "#, @@ -161,7 +161,7 @@ ) -> Result>, AppError> { auth.require(Permission::LexiconsRead).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT id, revision, lexicon_json, backfill, action, target_collection, source, authority_did, last_fetched_at, created_at, updated_at, token_cost FROM lexicons ORDER BY id", + "SELECT id, revision, lexicon_json, backfill, action, target_collection, source, authority_did, last_fetched_at, created_at, updated_at, token_cost FROM happyview_lexicons ORDER BY id", backend, ); #[allow(clippy::type_complexity)] @@ -243,7 +243,7 @@ ) -> Result, AppError> { auth.require(Permission::LexiconsRead).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT id, revision, lexicon_json, backfill, action, target_collection, source, authority_did, last_fetched_at, created_at, updated_at, token_cost FROM lexicons WHERE id = ?", + "SELECT id, revision, lexicon_json, backfill, action, target_collection, source, authority_did, last_fetched_at, created_at, updated_at, token_cost FROM happyview_lexicons WHERE id = ?", backend, ); #[allow(clippy::type_complexity)] @@ -318,7 +318,7 @@ Path(id): Path, ) -> Result { auth.require(Permission::LexiconsDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM lexicons WHERE id = ?", backend); + let sql = adapt_sql("DELETE FROM happyview_lexicons WHERE id = ?", backend); let result = sqlx::query(&sql) .bind(&id) .execute(&state.db) diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -21,6 +21,7 @@ pub mod settings; mod stats; pub(crate) mod types; mod users; +mod verification_methods; use axum::Router; use axum::routing::{delete, get, patch, post, put}; @@ -189,5 +190,13 @@ "/service-entries/{id}/xrpcs", get(service_entries::list_xrpcs) .post(service_entries::add_xrpcs) .delete(service_entries::remove_xrpcs), + ) + .route( + "/verification-methods", + get(verification_methods::list).post(verification_methods::create), + ) + .route( + "/verification-methods/{fragment_id}", + delete(verification_methods::delete), ) } diff --git a/src/admin/network_lexicons.rs b/src/admin/network_lexicons.rs --- a/src/admin/network_lexicons.rs +++ b/src/admin/network_lexicons.rs @@ -82,7 +82,7 @@ // Upsert into lexicons table with network source. let sql = adapt_sql( r#" - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at, created_at) + INSERT INTO happyview_lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at, created_at) VALUES (?, ?, 0, ?, 'network', ?, ?, ?) ON CONFLICT (id) DO UPDATE SET lexicon_json = EXCLUDED.lexicon_json, @@ -90,7 +90,7 @@ target_collection = EXCLUDED.target_collection, source = 'network', authority_did = EXCLUDED.authority_did, last_fetched_at = ?, - revision = lexicons.revision + 1, + revision = happyview_lexicons.revision + 1, updated_at = ? RETURNING revision "#, @@ -146,7 +146,7 @@ auth.require(Permission::LexiconsRead).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT id, authority_did, target_collection, last_fetched_at, created_at FROM lexicons WHERE source = 'network' ORDER BY id", + "SELECT id, authority_did, target_collection, last_fetched_at, created_at FROM happyview_lexicons WHERE source = 'network' ORDER BY id", backend, ); #[allow(clippy::type_complexity)] @@ -189,7 +189,7 @@ auth.require(Permission::LexiconsDelete).await?; let backend = state.db_backend; let sql = adapt_sql( - "DELETE FROM lexicons WHERE id = ? AND source = 'network'", + "DELETE FROM happyview_lexicons WHERE id = ? AND source = 'network'", backend, ); let result = sqlx::query(&sql) diff --git a/src/admin/plugins.rs b/src/admin/plugins.rs --- a/src/admin/plugins.rs +++ b/src/admin/plugins.rs @@ -90,7 +90,7 @@ // Query which plugins have secrets configured let configured_plugins: std::collections::HashSet = { let sql = adapt_sql( - "SELECT plugin_id FROM plugin_configs WHERE config IS NOT NULL", + "SELECT plugin_id FROM happyview_plugin_configs WHERE config IS NOT NULL", state.db_backend, ); sqlx::query_scalar::<_, String>(&sql) @@ -308,7 +308,10 @@ ))); } // Remove from database - let sql = adapt_sql("DELETE FROM plugins WHERE id = ?", state.db_backend); + let sql = adapt_sql( + "DELETE FROM happyview_plugins WHERE id = ?", + state.db_backend, + ); sqlx::query(&sql) .bind(&plugin_id) .execute(&state.db) @@ -398,7 +401,7 @@ // Check if reloaded plugin still has its config let secrets_configured = required_secrets.is_empty() || { let sql = adapt_sql( - "SELECT 1 FROM plugin_configs WHERE plugin_id = ?", + "SELECT 1 FROM happyview_plugin_configs WHERE plugin_id = ?", state.db_backend, ); sqlx::query_scalar::<_, i32>(&sql) @@ -429,7 +432,7 @@ }; // Persist the (possibly new) URL so restarts pick it up let persist_sql = adapt_sql( - "UPDATE plugins SET url = ?, sha256 = NULL WHERE id = ?", + "UPDATE happyview_plugins SET url = ?, sha256 = NULL WHERE id = ?", state.db_backend, ); sqlx::query(&persist_sql) @@ -481,7 +484,7 @@ .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; // Get secrets from plugin_configs table let sql = adapt_sql( - "SELECT config FROM plugin_configs WHERE plugin_id = ?", + "SELECT config FROM happyview_plugin_configs WHERE plugin_id = ?", state.db_backend, ); @@ -551,7 +554,7 @@ .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; // Get existing config or create new one let sql = adapt_sql( - "SELECT config FROM plugin_configs WHERE plugin_id = ?", + "SELECT config FROM happyview_plugin_configs WHERE plugin_id = ?", state.db_backend, ); @@ -605,7 +608,7 @@ .map_err(|e| AppError::Internal(format!("Failed to serialize config: {}", e)))?; // Upsert into plugin_configs let sql = adapt_sql( - "INSERT INTO plugin_configs (plugin_id, config, updated_at) VALUES (?, ?, ?) + "INSERT INTO happyview_plugin_configs (plugin_id, config, updated_at) VALUES (?, ?, ?) ON CONFLICT (plugin_id) DO UPDATE SET config = EXCLUDED.config, updated_at = EXCLUDED.updated_at", state.db_backend, ); diff --git a/src/admin/proxy_config.rs b/src/admin/proxy_config.rs --- a/src/admin/proxy_config.rs +++ b/src/admin/proxy_config.rs @@ -49,7 +49,7 @@ let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( r#" - INSERT INTO instance_settings (key, value, updated_at) + INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? "#, diff --git a/src/admin/records.rs b/src/admin/records.rs --- a/src/admin/records.rs +++ b/src/admin/records.rs @@ -77,7 +77,7 @@ .and_then(|c| c.parse().ok()) .unwrap_or(0); let sql = adapt_sql( - "SELECT uri, did, collection, rkey, cid, indexed_at, record FROM records WHERE collection = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", + "SELECT uri, did, collection, rkey, cid, indexed_at, record FROM happyview_records WHERE collection = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", backend, ); let rows: Vec = sqlx::query_as(&sql) @@ -103,7 +103,7 @@ } else { let now = now_rfc3339(); let ph_str = (0..uris.len()).map(|_| "?").collect::>().join(", "); let raw_sql = format!( - "SELECT uri, src, val, cts FROM labels WHERE uri IN ({ph_str}) AND (exp IS NULL OR exp > ?)" + "SELECT uri, src, val, cts FROM happyview_labels WHERE uri IN ({ph_str}) AND (exp IS NULL OR exp > ?)" ); let sql = adapt_sql(&raw_sql, backend); let mut q = sqlx::query_as(&sql); @@ -186,7 +186,10 @@ ) -> Result, AppError> { auth.require(Permission::RecordsDeleteCollection).await?; auth.require(Permission::RecordsDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM records WHERE collection = ?", backend); + let sql = adapt_sql( + "DELETE FROM happyview_records WHERE collection = ?", + backend, + ); let result = sqlx::query(&sql) .bind(¶ms.collection) .execute(&state.db) @@ -206,7 +209,7 @@ Query(params): Query, ) -> Result { auth.require(Permission::RecordsDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); + let sql = adapt_sql("DELETE FROM happyview_records WHERE uri = ?", backend); let result = sqlx::query(&sql) .bind(¶ms.uri) .execute(&state.db) @@ -228,7 +231,7 @@ ) -> Result, AppError> { auth.require(Permission::RecordsRead).await?; let sql = adapt_sql( - "SELECT id FROM lexicons WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record' ORDER BY id", + "SELECT id FROM happyview_lexicons WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record' ORDER BY id", state.db_backend, ); let rows: Vec<(String,)> = sqlx::query_as(&sql) diff --git a/src/admin/script_variables.rs b/src/admin/script_variables.rs --- a/src/admin/script_variables.rs +++ b/src/admin/script_variables.rs @@ -20,7 +20,7 @@ auth.require(Permission::ScriptVariablesRead).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT key, value, created_at, updated_at FROM script_variables ORDER BY key", + "SELECT key, value, created_at, updated_at FROM happyview_script_variables ORDER BY key", backend, ); let rows: Vec<(String, String, String, String)> = sqlx::query_as(&sql) @@ -56,7 +56,7 @@ let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( r#" - INSERT INTO script_variables (key, value, created_at) + INSERT INTO happyview_script_variables (key, value, created_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? "#, @@ -97,7 +97,10 @@ ) -> Result { auth.require(Permission::ScriptVariablesDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM script_variables WHERE key = ?", backend); + let sql = adapt_sql( + "DELETE FROM happyview_script_variables WHERE key = ?", + backend, + ); let result = sqlx::query(&sql) .bind(&key) .execute(&state.db) diff --git a/src/admin/scripts.rs b/src/admin/scripts.rs --- a/src/admin/scripts.rs +++ b/src/admin/scripts.rs @@ -107,7 +107,7 @@ let backend = state.db_backend; let mut sql = String::from( "SELECT id, script_type, body, description, outbound_xrpcs, created_at, updated_at - FROM scripts", + FROM happyview_scripts", ); if query.suffix.is_some() { sql.push_str(" WHERE id LIKE ?"); @@ -207,17 +207,19 @@ let now = now_rfc3339(); let description = body.description.as_deref().filter(|s| !s.is_empty()); // Distinguish create vs update so we can return 201 vs 200. - let pre_exists: Option<(String,)> = - sqlx::query_as(&adapt_sql("SELECT id FROM scripts WHERE id = ?", backend)) - .bind(&body.id) - .fetch_optional(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to check script existence: {e}")))?; + let pre_exists: Option<(String,)> = sqlx::query_as(&adapt_sql( + "SELECT id FROM happyview_scripts WHERE id = ?", + backend, + )) + .bind(&body.id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check script existence: {e}")))?; let was_new = pre_exists.is_none(); let sql = adapt_sql( r#" - INSERT INTO scripts (id, script_type, body, description, outbound_xrpcs, created_at, updated_at) + INSERT INTO happyview_scripts (id, script_type, body, description, outbound_xrpcs, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET script_type = EXCLUDED.script_type, @@ -331,7 +333,7 @@ }; let sql = adapt_sql( r#" - UPDATE scripts + UPDATE happyview_scripts SET script_type = ?, body = ?, description = ?, @@ -381,7 +383,7 @@ ) -> Result { auth.require(Permission::ScriptsManage).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM scripts WHERE id = ?", backend); + let sql = adapt_sql("DELETE FROM happyview_scripts WHERE id = ?", backend); let result = sqlx::query(&sql) .bind(&id) .execute(&state.db) @@ -415,7 +417,7 @@ async fn fetch_one(state: &AppState, id: &str) -> Result { let backend = state.db_backend; let sql = adapt_sql( "SELECT id, script_type, body, description, outbound_xrpcs, created_at, updated_at - FROM scripts WHERE id = ?", + FROM happyview_scripts WHERE id = ?", backend, ); #[allow(clippy::type_complexity)] diff --git a/src/admin/service_entries.rs b/src/admin/service_entries.rs --- a/src/admin/service_entries.rs +++ b/src/admin/service_entries.rs @@ -203,11 +203,18 @@ .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); - let verification_methods = last_op["verificationMethods"] + let mut verification_methods = last_op["verificationMethods"] .as_object() .cloned() .unwrap_or_default(); + // Merge verification methods from the table + let vm_entries = crate::verification_methods::list_methods(&state.db, state.db_backend).await?; + for vm in &vm_entries { + let key = vm.fragment_id.trim_start_matches('#').to_string(); + verification_methods.insert(key, serde_json::json!(vm.public_key_multibase)); + } + // Build services: start from existing, then merge our service entries let mut services_map = last_op["services"].as_object().cloned().unwrap_or_default(); @@ -253,7 +260,7 @@ ); // Decrypt the rotation key for signing let rotation_key_enc_sql = crate::db::adapt_sql( - "SELECT rotation_key_enc FROM service_identity WHERE id = 1", + "SELECT rotation_key_enc FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&rotation_key_enc_sql) @@ -303,7 +310,7 @@ let account_did = match identity.mode { IdentityMode::AttachAccount => { let sql = crate::db::adapt_sql( - "SELECT attached_account_did FROM service_identity WHERE id = 1", + "SELECT attached_account_did FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&sql) @@ -367,7 +374,7 @@ let account_did = match identity.mode { IdentityMode::AttachAccount => { let sql = crate::db::adapt_sql( - "SELECT attached_account_did FROM service_identity WHERE id = 1", + "SELECT attached_account_did FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&sql) @@ -446,11 +453,16 @@ let services: Unknown = serde_json::from_value(serde_json::Value::Object(services_map)) .map_err(|e| AppError::Internal(format!("failed to build services Unknown: {e}")))?; - // Preserve existing verification methods - let vm_map = last_op["verificationMethods"] + // Merge verification methods from the table into existing + let mut vm_map = last_op["verificationMethods"] .as_object() .cloned() .unwrap_or_default(); + let vm_entries = crate::verification_methods::list_methods(&state.db, state.db_backend).await?; + for vm in &vm_entries { + let key = vm.fragment_id.trim_start_matches('#').to_string(); + vm_map.insert(key, serde_json::json!(vm.public_key_multibase)); + } let verification_methods: Unknown = serde_json::from_value(serde_json::Value::Object(vm_map)) .map_err(|e| { AppError::Internal(format!("failed to build verification methods Unknown: {e}")) diff --git a/src/admin/settings.rs b/src/admin/settings.rs --- a/src/admin/settings.rs +++ b/src/admin/settings.rs @@ -36,7 +36,10 @@ ]; /// Resolve a setting value: check the DB first, then fall back to env var. pub async fn get_setting(pool: &AnyPool, key: &str, backend: DatabaseBackend) -> Option { - let sql = adapt_sql("SELECT value FROM instance_settings WHERE key = ?", backend); + let sql = adapt_sql( + "SELECT value FROM happyview_instance_settings WHERE key = ?", + backend, + ); let row: Option<(String,)> = sqlx::query_as(&sql) .bind(key) .fetch_optional(pool) @@ -67,7 +70,7 @@ auth.require(Permission::SettingsManage).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT key, value FROM instance_settings ORDER BY key", + "SELECT key, value FROM happyview_instance_settings ORDER BY key", backend, ); let rows: Vec<(String, String)> = sqlx::query_as(&sql) @@ -115,7 +118,7 @@ let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( r#" - INSERT INTO instance_settings (key, value, updated_at) + INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? "#, @@ -156,7 +159,10 @@ ) -> Result { auth.require(Permission::SettingsManage).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM instance_settings WHERE key = ?", backend); + let sql = adapt_sql( + "DELETE FROM happyview_instance_settings WHERE key = ?", + backend, + ); let result = sqlx::query(&sql) .bind(&key) .execute(&state.db) @@ -277,7 +283,7 @@ let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ?", + "INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ?", backend, ); for (key, value) in [ @@ -319,7 +325,10 @@ ) -> Result { auth.require(Permission::SettingsManage).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM instance_settings WHERE key IN (?, ?)", backend); + let sql = adapt_sql( + "DELETE FROM happyview_instance_settings WHERE key IN (?, ?)", + backend, + ); sqlx::query(&sql) .bind("logo_data") .bind("logo_content_type") @@ -349,7 +358,7 @@ State(state): State, ) -> Result { let backend = state.db_backend; let sql = adapt_sql( - "SELECT key, value FROM instance_settings WHERE key IN (?, ?)", + "SELECT key, value FROM happyview_instance_settings WHERE key IN (?, ?)", backend, ); let rows: Vec<(String, String)> = sqlx::query_as(&sql) diff --git a/src/admin/stats.rs b/src/admin/stats.rs --- a/src/admin/stats.rs +++ b/src/admin/stats.rs @@ -15,7 +15,7 @@ State(state): State, auth: UserAuth, ) -> Result, AppError> { auth.require(Permission::StatsRead).await?; - let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM records") + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM happyview_records") .fetch_one(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to count records: {e}")))?; @@ -24,11 +24,11 @@ let collection_sql = adapt_sql( r#" SELECT c.collection, COALESCE(r.cnt, 0) AS count FROM ( - SELECT id AS collection FROM lexicons + SELECT id AS collection FROM happyview_lexicons WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record' ) c LEFT JOIN ( - SELECT collection, COUNT(*) AS cnt FROM records GROUP BY collection + SELECT collection, COUNT(*) AS cnt FROM happyview_records GROUP BY collection ) r ON r.collection = c.collection ORDER BY c.collection "#, diff --git a/src/admin/users.rs b/src/admin/users.rs --- a/src/admin/users.rs +++ b/src/admin/users.rs @@ -60,7 +60,7 @@ let now = now_rfc3339(); let backend = state.db_backend; let insert_sql = adapt_sql( - "INSERT INTO users (id, did, is_super, created_at) VALUES (?, ?, ?, ?)", + "INSERT INTO happyview_users (id, did, is_super, created_at) VALUES (?, ?, ?, ?)", backend, ); @@ -74,7 +74,7 @@ .await .map_err(|e| AppError::Internal(format!("failed to create user: {e}")))?; let perm_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO happyview_user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); @@ -129,7 +129,7 @@ let backend = state.db_backend; let select_sql = adapt_sql( - "SELECT id, did, is_super, created_at, last_used_at FROM users ORDER BY created_at", + "SELECT id, did, is_super, created_at, last_used_at FROM happyview_users ORDER BY created_at", backend, ); @@ -139,7 +139,7 @@ .await .map_err(|e| AppError::Internal(format!("failed to list users: {e}")))?; let perm_sql = adapt_sql( - "SELECT permission FROM user_permissions WHERE user_id = ? ORDER BY permission", + "SELECT permission FROM happyview_user_permissions WHERE user_id = ? ORDER BY permission", backend, ); @@ -175,7 +175,7 @@ let backend = state.db_backend; let select_sql = adapt_sql( - "SELECT id, did, is_super, created_at, last_used_at FROM users WHERE id = ?", + "SELECT id, did, is_super, created_at, last_used_at FROM happyview_users WHERE id = ?", backend, ); @@ -190,7 +190,7 @@ return Err(AppError::NotFound(format!("user '{id}' not found"))); }; let perm_sql = adapt_sql( - "SELECT permission FROM user_permissions WHERE user_id = ? ORDER BY permission", + "SELECT permission FROM happyview_user_permissions WHERE user_id = ? ORDER BY permission", backend, ); @@ -229,7 +229,7 @@ )); } // Cannot modify super user's permissions - let select_sql = adapt_sql("SELECT is_super FROM users WHERE id = ?", backend); + let select_sql = adapt_sql("SELECT is_super FROM happyview_users WHERE id = ?", backend); let target: Option<(i32,)> = sqlx::query_as(&select_sql) .bind(&id) .fetch_optional(&state.db) @@ -276,7 +276,7 @@ let now = now_rfc3339(); let grant_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO happyview_user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); @@ -292,7 +292,7 @@ .map_err(|e| AppError::Internal(format!("failed to grant permission: {e}")))?; } let revoke_sql = adapt_sql( - "DELETE FROM user_permissions WHERE user_id = ? AND permission = ?", + "DELETE FROM happyview_user_permissions WHERE user_id = ? AND permission = ?", backend, ); @@ -340,7 +340,7 @@ return Err(AppError::Forbidden("Cannot delete yourself".into())); } // Cannot delete super user - let select_sql = adapt_sql("SELECT is_super FROM users WHERE id = ?", backend); + let select_sql = adapt_sql("SELECT is_super FROM happyview_users WHERE id = ?", backend); let target: Option<(i32,)> = sqlx::query_as(&select_sql) .bind(&id) .fetch_optional(&state.db) @@ -359,7 +359,7 @@ // Revoke API keys and delete user let now = now_rfc3339(); let revoke_keys_sql = adapt_sql( - "UPDATE api_keys SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", + "UPDATE happyview_api_keys SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", backend, ); @@ -370,7 +370,7 @@ .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to revoke api keys: {e}")))?; - let delete_sql = adapt_sql("DELETE FROM users WHERE id = ?", backend); + let delete_sql = adapt_sql("DELETE FROM happyview_users WHERE id = ?", backend); let result = sqlx::query(&delete_sql) .bind(&id) @@ -414,7 +414,10 @@ let backend = state.db_backend; let now = now_rfc3339(); // Remove super from current user - let update1_sql = adapt_sql("UPDATE users SET is_super = ? WHERE id = ?", backend); + let update1_sql = adapt_sql( + "UPDATE happyview_users SET is_super = ? WHERE id = ?", + backend, + ); sqlx::query(&update1_sql) .bind(0_i32) .bind(&auth.user_id) @@ -423,7 +426,10 @@ .await .map_err(|e| AppError::Internal(format!("failed to remove super: {e}")))?; // Set super on target user - let update2_sql = adapt_sql("UPDATE users SET is_super = ? WHERE id = ?", backend); + let update2_sql = adapt_sql( + "UPDATE happyview_users SET is_super = ? WHERE id = ?", + backend, + ); let result = sqlx::query(&update2_sql) .bind(1_i32) .bind(&body.target_user_id) @@ -433,7 +439,10 @@ .map_err(|e| AppError::Internal(format!("failed to set super: {e}")))?; if result.rows_affected() == 0 { // Restore super on current user - let restore_sql = adapt_sql("UPDATE users SET is_super = ? WHERE id = ?", backend); + let restore_sql = adapt_sql( + "UPDATE happyview_users SET is_super = ? WHERE id = ?", + backend, + ); let _ = sqlx::query(&restore_sql) .bind(1_i32) .bind(&auth.user_id) @@ -447,7 +456,7 @@ } // Ensure target has all permissions let perm_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO happyview_user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); diff --git a/src/admin/verification_methods.rs b/src/admin/verification_methods.rs new file mode 100644 --- /dev/null +++ b/src/admin/verification_methods.rs @@ -0,0 +1,112 @@ +use axum::{Json, extract::Path, extract::State, http::StatusCode}; + +use crate::AppState; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::verification_methods::{VerificationMethod, create_method, delete_method, list_methods}; + +use super::auth::UserAuth; +use super::permissions::Permission; + +/// GET /admin/verification-methods — list all verification methods. +pub(super) async fn list( + State(state): State, + auth: UserAuth, +) -> Result>, AppError> { + auth.require(Permission::SettingsManage).await?; + + let methods = list_methods(&state.db, state.db_backend).await?; + Ok(Json(methods)) +} + +#[derive(Debug, serde::Deserialize)] +pub(super) struct CreateVerificationMethodBody { + pub fragment_id: String, +} + +/// POST /admin/verification-methods — create a new verification method (generates P-256 keypair). +pub(super) async fn create( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + auth.require(Permission::SettingsManage).await?; + + if !body.fragment_id.starts_with('#') { + return Err(AppError::BadRequest( + "fragment_id must start with '#'".into(), + )); + } + let frag_body = &body.fragment_id[1..]; + if frag_body.is_empty() + || !frag_body + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(AppError::BadRequest( + "fragment_id must contain only alphanumeric characters and underscores after '#'" + .into(), + )); + } + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + let method = create_method( + &state.db, + state.db_backend, + &body.fragment_id, + encryption_key, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "verification_method.created".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(method.fragment_id.clone()), + detail: serde_json::json!({ "fragment_id": &method.fragment_id }), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::CREATED, Json(method))) +} + +/// DELETE /admin/verification-methods/{fragment_id} — delete a verification method. +pub(super) async fn delete( + State(state): State, + auth: UserAuth, + Path(fragment_id): Path, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let deleted = delete_method(&state.db, state.db_backend, &fragment_id).await?; + if !deleted { + return Err(AppError::NotFound(format!( + "verification method '{}' not found", + fragment_id + ))); + } + + log_event( + &state.db, + EventLog { + event_type: "verification_method.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(fragment_id.clone()), + detail: serde_json::json!({ "fragment_id": &fragment_id }), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/auth/client_registry.rs b/src/auth/client_registry.rs --- a/src/auth/client_registry.rs +++ b/src/auth/client_registry.rs @@ -242,7 +242,7 @@ state_store: DbStateStore, session_store_pool: sqlx::AnyPool, ) { let sql = adapt_sql( - "SELECT client_id_url, client_uri, redirect_uris, scopes FROM api_clients WHERE is_active = 1", + "SELECT client_id_url, client_uri, redirect_uris, scopes FROM happyview_api_clients WHERE is_active = 1", db_backend, ); diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -139,7 +139,7 @@ use sha2::{Digest, Sha256}; let hash = hex::encode(Sha256::digest(token.as_bytes())); let sql = adapt_sql( - "SELECT u.did FROM api_keys k JOIN users u ON k.user_id = u.id WHERE k.key_hash = ? AND k.revoked_at IS NULL", + "SELECT u.did FROM happyview_api_keys k JOIN happyview_users u ON k.user_id = u.id WHERE k.key_hash = ? AND k.revoked_at IS NULL", state.db_backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -314,7 +314,29 @@ } } Some(_) => Err(AppError::Auth("invalid Authorization scheme".into())), None => { - // No auth header — anonymous access (client-key only) + // No auth header — try cookie auth, fall back to anonymous + let jar: SignedCookieJar = SignedCookieJar::from_request_parts(parts, state) + .await + .map_err(|_| AppError::Auth("failed to read cookies".into()))?; + + if let Some(cookie) = jar.get(COOKIE_NAME) { + let value = cookie.value().to_string(); + let (did, client_key) = if let Some((d, k)) = value.split_once(COOKIE_SEP) { + (d.to_string(), Some(k.to_string())) + } else { + (value, None) + }; + return Ok(XrpcClaims { + identity: Some(Claims { + did, + client_key, + dpop_key_id: None, + }), + space_credential: None, + service_auth: None, + }); + } + Ok(XrpcClaims { identity: None, space_credential: None, diff --git a/src/auth/oauth_store.rs b/src/auth/oauth_store.rs --- a/src/auth/oauth_store.rs +++ b/src/auth/oauth_store.rs @@ -63,7 +63,7 @@ type Error = StoreError; async fn get(&self, key: &Did) -> Result, Self::Error> { let row: Option<(String,)> = sqlx::query_as(&adapt_sql( - "SELECT session_data FROM oauth_sessions WHERE did = ?", + "SELECT session_data FROM happyview_oauth_sessions WHERE did = ?", self.backend, )) .bind(key.as_ref()) @@ -79,7 +79,7 @@ async fn set(&self, key: Did, value: Session) -> Result<(), Self::Error> { let json = serde_json::to_string(&value)?; sqlx::query(&adapt_sql( - "INSERT INTO oauth_sessions (did, session_data, updated_at) VALUES (?, ?, datetime('now')) + "INSERT INTO happyview_oauth_sessions (did, session_data, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT (did) DO UPDATE SET session_data = EXCLUDED.session_data, updated_at = datetime('now')", self.backend, )) @@ -92,7 +92,7 @@ } async fn del(&self, key: &Did) -> Result<(), Self::Error> { sqlx::query(&adapt_sql( - "DELETE FROM oauth_sessions WHERE did = ?", + "DELETE FROM happyview_oauth_sessions WHERE did = ?", self.backend, )) .bind(key.as_ref()) @@ -102,7 +102,7 @@ Ok(()) } async fn clear(&self) -> Result<(), Self::Error> { - sqlx::query("DELETE FROM oauth_sessions") + sqlx::query("DELETE FROM happyview_oauth_sessions") .execute(&self.pool) .await?; Ok(()) @@ -146,7 +146,7 @@ type Error = StoreError; async fn get(&self, key: &String) -> Result, Self::Error> { let row: Option<(String,)> = sqlx::query_as(&adapt_sql( - "SELECT state_data FROM oauth_state WHERE state_key = ?", + "SELECT state_data FROM happyview_oauth_state WHERE state_key = ?", self.backend, )) .bind(key) @@ -162,7 +162,7 @@ async fn set(&self, key: String, value: InternalStateData) -> Result<(), Self::Error> { let json = serde_json::to_string(&value)?; sqlx::query(&adapt_sql( - "INSERT INTO oauth_state (state_key, state_data) VALUES (?, ?) + "INSERT INTO happyview_oauth_state (state_key, state_data) VALUES (?, ?) ON CONFLICT (state_key) DO UPDATE SET state_data = EXCLUDED.state_data", self.backend, )) @@ -176,7 +176,7 @@ } async fn del(&self, key: &String) -> Result<(), Self::Error> { sqlx::query(&adapt_sql( - "DELETE FROM oauth_state WHERE state_key = ?", + "DELETE FROM happyview_oauth_state WHERE state_key = ?", self.backend, )) .bind(key) @@ -186,7 +186,7 @@ Ok(()) } async fn clear(&self) -> Result<(), Self::Error> { - sqlx::query("DELETE FROM oauth_state") + sqlx::query("DELETE FROM happyview_oauth_state") .execute(&self.pool) .await?; Ok(()) diff --git a/src/auth/routes.rs b/src/auth/routes.rs --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -129,7 +129,7 @@ if let Some(oauth_state) = oauth_state { let now = now_rfc3339(); let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339(); let sql = adapt_sql( - "INSERT INTO auth_login_redirects (state, redirect_uri, client_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO happyview_auth_login_redirects (state, redirect_uri, client_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", state.db_backend, ); let _ = sqlx::query(&sql) @@ -158,7 +158,7 @@ // Look up the redirect URI and client_id from the database before the OAuth library consumes the state let (redirect_url, client_id) = if let Some(oauth_state) = &query.state { let sql = adapt_sql( - "SELECT redirect_uri, client_id FROM auth_login_redirects WHERE state = ? AND expires_at > ?", + "SELECT redirect_uri, client_id FROM happyview_auth_login_redirects WHERE state = ? AND expires_at > ?", state.db_backend, ); let now = now_rfc3339(); @@ -172,7 +172,7 @@ // Clean up the row (one-time use) if row.is_some() { let delete_sql = adapt_sql( - "DELETE FROM auth_login_redirects WHERE state = ?", + "DELETE FROM happyview_auth_login_redirects WHERE state = ?", state.db_backend, ); let _ = sqlx::query(&delete_sql) @@ -218,14 +218,14 @@ // Check if the user is authorized to access the dashboard. // Allow login when no users exist yet (first user will be bootstrapped as admin). // Also allow login for the configured attached account DID (setup attach-auth flow). // Otherwise, only allow users already in the users table. - let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM happyview_users") .fetch_one(&state.db) .await .map_err(|e| AppError::Internal(format!("user count query failed: {e}")))?; if user_count.0 > 0 { let user_exists: Option<(i32,)> = sqlx::query_as(&adapt_sql( - "SELECT 1 FROM users WHERE did = ?", + "SELECT 1 FROM happyview_users WHERE did = ?", state.db_backend, )) .bind(did.as_ref()) @@ -236,7 +236,7 @@ if user_exists.is_none() { // Allow login if this DID is the configured attached account (setup flow) let is_attached_account: Option<(i32,)> = sqlx::query_as(&adapt_sql( - "SELECT 1 FROM service_identity WHERE attached_account_did = ?", + "SELECT 1 FROM happyview_service_identity WHERE attached_account_did = ?", state.db_backend, )) .bind(did.as_ref()) @@ -260,7 +260,7 @@ // Look up the client_key for the API client so we can store it in the session cookie // for per-client rate limiting. let client_key = if let Some(ref cid) = client_id { let sql = adapt_sql( - "SELECT client_key FROM api_clients WHERE client_id_url = ? AND is_active = 1", + "SELECT client_key FROM happyview_api_clients WHERE client_id_url = ? AND is_active = 1", state.db_backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -360,12 +360,14 @@ let raw = cookie.value().to_string(); let did = raw.split('\n').next().unwrap_or(&raw).to_string(); let backend = state.db_backend; - let user: Option<(i32,)> = - sqlx::query_as(&adapt_sql("SELECT 1 FROM users WHERE did = ?", backend)) - .bind(&did) - .fetch_optional(&state.db) - .await - .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; + let user: Option<(i32,)> = sqlx::query_as(&adapt_sql( + "SELECT 1 FROM happyview_users WHERE did = ?", + backend, + )) + .bind(&did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; Ok(Json(MeResponse { did, diff --git a/src/delegation/db.rs b/src/delegation/db.rs --- a/src/delegation/db.rs +++ b/src/delegation/db.rs @@ -13,7 +13,7 @@ api_client_id: &str, ) -> Result<(), AppError> { let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO delegated_accounts (account_did, linked_by, api_client_id, created_at) VALUES (?, ?, ?, ?)", + "INSERT INTO happyview_delegated_accounts (account_did, linked_by, api_client_id, created_at) VALUES (?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -33,7 +33,7 @@ backend: crate::db::DatabaseBackend, account_did: &str, ) -> Result<(), AppError> { let sql = adapt_sql( - "DELETE FROM delegated_accounts WHERE account_did = ?", + "DELETE FROM happyview_delegated_accounts WHERE account_did = ?", backend, ); sqlx::query(&sql) @@ -50,7 +50,7 @@ backend: crate::db::DatabaseBackend, account_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT linked_by FROM delegated_accounts WHERE account_did = ?", + "SELECT linked_by FROM happyview_delegated_accounts WHERE account_did = ?", backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -76,7 +76,7 @@ backend: crate::db::DatabaseBackend, account_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT api_client_id FROM delegated_accounts WHERE account_did = ?", + "SELECT api_client_id FROM happyview_delegated_accounts WHERE account_did = ?", backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -97,7 +97,7 @@ granted_by: &str, ) -> Result<(), AppError> { let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO account_delegates (account_did, user_did, role, granted_by, created_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO happyview_account_delegates (account_did, user_did, role, granted_by, created_at) VALUES (?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -119,7 +119,7 @@ account_did: &str, user_did: &str, ) -> Result<(), AppError> { let sql = adapt_sql( - "DELETE FROM account_delegates WHERE account_did = ? AND user_did = ?", + "DELETE FROM happyview_account_delegates WHERE account_did = ? AND user_did = ?", backend, ); sqlx::query(&sql) @@ -138,7 +138,7 @@ account_did: &str, user_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT role FROM account_delegates WHERE account_did = ? AND user_did = ?", + "SELECT role FROM happyview_account_delegates WHERE account_did = ? AND user_did = ?", backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) @@ -157,7 +157,7 @@ user_did: &str, api_client_id: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT ad.account_did, ad.role, ad.created_at FROM account_delegates ad JOIN delegated_accounts da ON da.account_did = ad.account_did WHERE ad.user_did = ? AND da.api_client_id = ? ORDER BY ad.created_at DESC", + "SELECT ad.account_did, ad.role, ad.created_at FROM happyview_account_delegates ad JOIN happyview_delegated_accounts da ON da.account_did = ad.account_did WHERE ad.user_did = ? AND da.api_client_id = ? ORDER BY ad.created_at DESC", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -184,7 +184,7 @@ account_did: &str, user_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT da.linked_by, ad.role, ad.created_at FROM delegated_accounts da JOIN account_delegates ad ON da.account_did = ad.account_did WHERE da.account_did = ? AND ad.user_did = ?", + "SELECT da.linked_by, ad.role, ad.created_at FROM happyview_delegated_accounts da JOIN happyview_account_delegates ad ON da.account_did = ad.account_did WHERE da.account_did = ? AND ad.user_did = ?", backend, ); let row: Option<(String, String, String)> = sqlx::query_as(&sql) @@ -202,7 +202,7 @@ backend: crate::db::DatabaseBackend, account_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT user_did, role, granted_by, created_at FROM account_delegates WHERE account_did = ? ORDER BY created_at ASC", + "SELECT user_did, role, granted_by, created_at FROM happyview_account_delegates WHERE account_did = ? ORDER BY created_at ASC", backend, ); let rows: Vec<(String, String, String, String)> = sqlx::query_as(&sql) diff --git a/src/delegation/link_account.rs b/src/delegation/link_account.rs --- a/src/delegation/link_account.rs +++ b/src/delegation/link_account.rs @@ -47,7 +47,7 @@ .ok_or_else(|| AppError::Auth("linkAccount requires DPoP authentication".into()))?; let api_client_id = crate::repo::get_dpop_client_id(&state, client_key).await?; let session_check_sql = crate::db::adapt_sql( - "SELECT id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + "SELECT id FROM happyview_dpop_sessions WHERE api_client_id = ? AND user_did = ?", state.db_backend, ); let session_exists: Option<(String,)> = sqlx::query_as(&session_check_sql) diff --git a/src/dev_happyview/create_client.rs b/src/dev_happyview/create_client.rs --- a/src/dev_happyview/create_client.rs +++ b/src/dev_happyview/create_client.rs @@ -61,7 +61,7 @@ .await .map_err(|_| AppError::Auth("Invalid client".into()))?; let parent_check_sql = adapt_sql( - "SELECT parent_client_id, created_by FROM api_clients WHERE id = ?", + "SELECT parent_client_id, created_by FROM happyview_api_clients WHERE id = ?", state.db_backend, ); let parent_row: Option<(Option, String)> = sqlx::query_as(&parent_check_sql) @@ -91,7 +91,7 @@ } // 5. Check for duplicate client_id_url let dup_check_sql = adapt_sql( - "SELECT id FROM api_clients WHERE client_id_url = ?", + "SELECT id FROM happyview_api_clients WHERE client_id_url = ?", state.db_backend, ); let dup: Option<(String,)> = sqlx::query_as(&dup_check_sql) @@ -132,7 +132,7 @@ .as_ref() .map(|origins| serde_json::to_string(origins).unwrap_or_else(|_| "[]".to_string())); let insert_sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 1, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, rate_limit_capacity, rate_limit_refill_rate, client_type, allowed_origins, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 1, ?, ?, ?, ?, ?)", state.db_backend, ); sqlx::query(&insert_sql) diff --git a/src/dev_happyview/delete_client.rs b/src/dev_happyview/delete_client.rs --- a/src/dev_happyview/delete_client.rs +++ b/src/dev_happyview/delete_client.rs @@ -48,7 +48,7 @@ let id = input.id; // 3. Look up client_id_url and client_key before deleting (scoped to owner) let lookup_sql = adapt_sql( - "SELECT client_id_url, client_key FROM api_clients WHERE id = ? AND owner_did = ?", + "SELECT client_id_url, client_key FROM happyview_api_clients WHERE id = ? AND owner_did = ?", state.db_backend, ); let client_info: Option<(String, String)> = sqlx::query_as(&lookup_sql) @@ -60,7 +60,7 @@ .map_err(|e| AppError::Internal(format!("failed to look up api client: {e}")))?; // 4. Look up child clients before deleting (ON DELETE CASCADE will remove DB rows) let children_sql = adapt_sql( - "SELECT client_id_url, client_key FROM api_clients WHERE parent_client_id = ?", + "SELECT client_id_url, client_key FROM happyview_api_clients WHERE parent_client_id = ?", state.db_backend, ); let children: Vec<(String, String)> = sqlx::query_as(&children_sql) @@ -71,7 +71,7 @@ .unwrap_or_default(); // 5. Delete from DB — scoped to owner_did so users cannot delete others' clients let delete_sql = adapt_sql( - "DELETE FROM api_clients WHERE id = ? AND owner_did = ?", + "DELETE FROM happyview_api_clients WHERE id = ? AND owner_did = ?", state.db_backend, ); let result = sqlx::query(&delete_sql) diff --git a/src/dev_happyview/get_client.rs b/src/dev_happyview/get_client.rs --- a/src/dev_happyview/get_client.rs +++ b/src/dev_happyview/get_client.rs @@ -50,7 +50,7 @@ let did = claims.did(); let sql = adapt_sql( "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_at \ - FROM api_clients \ + FROM happyview_api_clients \ WHERE id = $1 AND owner_did = $2", state.db_backend, ); diff --git a/src/dev_happyview/list_clients.rs b/src/dev_happyview/list_clients.rs --- a/src/dev_happyview/list_clients.rs +++ b/src/dev_happyview/list_clients.rs @@ -44,7 +44,7 @@ // Reject requests from child clients if let Some(client_key) = claims.client_key() { let sql = adapt_sql( - "SELECT parent_client_id FROM api_clients WHERE client_key = $1", + "SELECT parent_client_id FROM happyview_api_clients WHERE client_key = $1", state.db_backend, ); let parent_check: Option> = sqlx::query_scalar(&sql) @@ -63,7 +63,7 @@ // Fetch all clients owned by the authenticated user let sql = adapt_sql( "SELECT id, client_key, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_at \ - FROM api_clients \ + FROM happyview_api_clients \ WHERE owner_did = $1 \ ORDER BY created_at DESC", state.db_backend, diff --git a/src/event_log.rs b/src/event_log.rs --- a/src/event_log.rs +++ b/src/event_log.rs @@ -44,11 +44,11 @@ // Build database-specific cleanup query // Cannot use adapt_sql: Postgres uses make_interval(days => $1) which has no SQLite equivalent pattern. let cleanup_sql = match backend { DatabaseBackend::Sqlite => { - "DELETE FROM event_logs WHERE created_at < datetime('now', '-' || ? || ' days')" + "DELETE FROM happyview_event_logs WHERE created_at < datetime('now', '-' || ? || ' days')" .to_string() } DatabaseBackend::Postgres => { - "DELETE FROM event_logs WHERE created_at < NOW() - make_interval(days => $1)" + "DELETE FROM happyview_event_logs WHERE created_at < NOW() - make_interval(days => $1)" .to_string() } }; @@ -82,7 +82,7 @@ let id = Uuid::new_v4().to_string(); let created_at = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO event_logs (id, event_type, severity, actor_did, subject, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_event_logs (id, event_type, severity, actor_did, subject, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); diff --git a/src/external_auth/routes.rs b/src/external_auth/routes.rs --- a/src/external_auth/routes.rs +++ b/src/external_auth/routes.rs @@ -461,7 +461,7 @@ // Try to load from database first (if encryption key is available) if let Some(key) = encryption_key { let sql = crate::db::adapt_sql( - "SELECT config FROM plugin_configs WHERE plugin_id = ?", + "SELECT config FROM happyview_plugin_configs WHERE plugin_id = ?", db_backend, ); diff --git a/src/external_auth/state.rs b/src/external_auth/state.rs --- a/src/external_auth/state.rs +++ b/src/external_auth/state.rs @@ -39,7 +39,7 @@ let expires_at = chrono::Utc::now() + chrono::Duration::minutes(10); let expires_str = expires_at.to_rfc3339(); let sql = adapt_sql( - "INSERT INTO external_auth_state (state, did, plugin_id, redirect_uri, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_external_auth_state (state, did, plugin_id, redirect_uri, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", backend, ); @@ -68,7 +68,7 @@ let now = now_rfc3339(); // Get state if not expired let sql = adapt_sql( - "SELECT did, plugin_id, redirect_uri FROM external_auth_state WHERE state = ? AND expires_at > ?", + "SELECT did, plugin_id, redirect_uri FROM happyview_external_auth_state WHERE state = ? AND expires_at > ?", backend, ); @@ -81,7 +81,10 @@ let (did, plugin_id, redirect_uri) = row.ok_or(StateError::NotFound)?; // Delete the state (one-time use) - let delete_sql = adapt_sql("DELETE FROM external_auth_state WHERE state = ?", backend); + let delete_sql = adapt_sql( + "DELETE FROM happyview_external_auth_state WHERE state = ?", + backend, + ); sqlx::query(&delete_sql).bind(state).execute(db).await?; Ok(StoredState { @@ -99,7 +102,7 @@ ) -> Result { let now = now_rfc3339(); let sql = adapt_sql( - "DELETE FROM external_auth_state WHERE expires_at <= ?", + "DELETE FROM happyview_external_auth_state WHERE expires_at <= ?", backend, ); let result = sqlx::query(&sql).bind(&now).execute(db).await?; diff --git a/src/external_auth/sync.rs b/src/external_auth/sync.rs --- a/src/external_auth/sync.rs +++ b/src/external_auth/sync.rs @@ -32,7 +32,7 @@ // For now, just track dedup key if let Some(dedup_key) = &record.dedup_key { let sql = adapt_sql( - "INSERT INTO plugin_dedup_keys (plugin_id, did, dedup_key, record_uri, updated_at) + "INSERT INTO happyview_plugin_dedup_keys (plugin_id, did, dedup_key, record_uri, updated_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT (plugin_id, did, dedup_key) DO UPDATE SET record_uri = excluded.record_uri, updated_at = excluded.updated_at", diff --git a/src/external_auth/tokens.rs b/src/external_auth/tokens.rs --- a/src/external_auth/tokens.rs +++ b/src/external_auth/tokens.rs @@ -62,7 +62,7 @@ let id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO external_account_tokens (id, did, plugin_id, account_id, access_token, refresh_token, token_type, scope, expires_at, created_at, updated_at) + "INSERT INTO happyview_external_account_tokens (id, did, plugin_id, account_id, access_token, refresh_token, token_type, scope, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (did, plugin_id) DO UPDATE SET account_id = excluded.account_id, access_token = excluded.access_token, refresh_token = excluded.refresh_token, token_type = excluded.token_type, scope = excluded.scope, expires_at = excluded.expires_at, updated_at = excluded.updated_at", @@ -98,7 +98,7 @@ ) -> Result { let key = encryption_key.ok_or(TokenError::KeyNotConfigured)?; let sql = adapt_sql( - "SELECT account_id, access_token, refresh_token, token_type, scope, expires_at FROM external_account_tokens WHERE did = ? AND plugin_id = ?", + "SELECT account_id, access_token, refresh_token, token_type, scope, expires_at FROM happyview_external_account_tokens WHERE did = ? AND plugin_id = ?", backend, ); @@ -140,7 +140,7 @@ did: &str, plugin_id: &str, ) -> Result { let sql = adapt_sql( - "DELETE FROM external_account_tokens WHERE did = ? AND plugin_id = ?", + "DELETE FROM happyview_external_account_tokens WHERE did = ? AND plugin_id = ?", backend, ); @@ -161,7 +161,7 @@ did: &str, plugin_id: &str, ) -> Result { let sql = adapt_sql( - "SELECT 1 FROM external_account_tokens WHERE did = ? AND plugin_id = ?", + "SELECT 1 FROM happyview_external_account_tokens WHERE did = ? AND plugin_id = ?", backend, ); @@ -182,7 +182,7 @@ did: &str, plugin_id: &str, ) -> Result, TokenError> { let sql = adapt_sql( - "SELECT account_id FROM external_account_tokens WHERE did = ? AND plugin_id = ?", + "SELECT account_id FROM happyview_external_account_tokens WHERE did = ? AND plugin_id = ?", backend, ); @@ -211,7 +211,7 @@ backend: DatabaseBackend, did: &str, ) -> Result, TokenError> { let sql = adapt_sql( - "SELECT plugin_id, account_id, created_at, updated_at FROM external_account_tokens WHERE did = ? ORDER BY created_at DESC", + "SELECT plugin_id, account_id, created_at, updated_at FROM happyview_external_account_tokens WHERE did = ? ORDER BY created_at DESC", backend, ); diff --git a/src/jetstream.rs b/src/jetstream.rs --- a/src/jetstream.rs +++ b/src/jetstream.rs @@ -47,7 +47,10 @@ // Cursor persistence // --------------------------------------------------------------------------- async fn load_cursor(db: &AnyPool, backend: DatabaseBackend) -> Option { - let sql = adapt_sql("SELECT value FROM instance_settings WHERE key = ?", backend); + let sql = adapt_sql( + "SELECT value FROM happyview_instance_settings WHERE key = ?", + backend, + ); let row: Option<(String,)> = sqlx::query_as(&sql) .bind("jetstream_cursor") .fetch_optional(db) @@ -60,7 +63,7 @@ async fn save_cursor(db: &AnyPool, backend: DatabaseBackend, cursor: i64) { let now = now_rfc3339(); let sql = adapt_sql( r#" - INSERT INTO instance_settings (key, value, updated_at) + INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? "#, diff --git a/src/labeler.rs b/src/labeler.rs --- a/src/labeler.rs +++ b/src/labeler.rs @@ -59,11 +59,12 @@ let mut tasks: HashMap> = HashMap::new(); loop { // Read all active subscriptions from the database. - let active: Vec<(String,)> = - sqlx::query_as("SELECT did FROM labeler_subscriptions WHERE status = 'active'") - .fetch_all(&state.db) - .await - .unwrap_or_default(); + let active: Vec<(String,)> = sqlx::query_as( + "SELECT did FROM happyview_labeler_subscriptions WHERE status = 'active'", + ) + .fetch_all(&state.db) + .await + .unwrap_or_default(); let active_dids: Vec = active.into_iter().map(|(did,)| did).collect(); @@ -149,7 +150,7 @@ let ws_url = http_to_ws(&pds_endpoint); // Read cursor from database. let cursor_sql = adapt_sql( - "SELECT cursor FROM labeler_subscriptions WHERE did = ?", + "SELECT cursor FROM happyview_labeler_subscriptions WHERE did = ?", state.db_backend, ); let cursor: Option<(Option,)> = sqlx::query_as(&cursor_sql) @@ -358,7 +359,7 @@ if final_label.neg { // Negation label — remove it. let delete_sql = adapt_sql( - "DELETE FROM labels WHERE src = ? AND uri = ? AND val = ?", + "DELETE FROM happyview_labels WHERE src = ? AND uri = ? AND val = ?", backend, ); if let Err(e) = sqlx::query(&delete_sql) @@ -377,7 +378,7 @@ } else { // Normal label — upsert. Store timestamps as RFC3339 strings for portability. let insert_sql = adapt_sql( r#" - INSERT INTO labels (src, uri, val, cts, exp) + INSERT INTO happyview_labels (src, uri, val, cts, exp) VALUES (?, ?, ?, ?, ?) ON CONFLICT (src, uri, val) DO UPDATE SET cts = EXCLUDED.cts, @@ -406,7 +407,7 @@ async fn persist_cursor(db: &sqlx::AnyPool, did: &str, seq: i64, backend: DatabaseBackend) { let now = now_rfc3339(); let update_sql = adapt_sql( - "UPDATE labeler_subscriptions SET cursor = ?, updated_at = ? WHERE did = ?", + "UPDATE happyview_labeler_subscriptions SET cursor = ?, updated_at = ? WHERE did = ?", backend, ); if let Err(e) = sqlx::query(&update_sql) @@ -439,7 +440,7 @@ state: &AppState, uri: &str, ) -> Result<(), Box> { let subscriptions: Vec<(String,)> = - sqlx::query_as("SELECT did FROM labeler_subscriptions WHERE status = 'active'") + sqlx::query_as("SELECT did FROM happyview_labeler_subscriptions WHERE status = 'active'") .fetch_all(&state.db) .await?; @@ -502,10 +503,11 @@ // Build database-specific cleanup query for expired labels let expired_sql = match backend { DatabaseBackend::Sqlite => { - "DELETE FROM labels WHERE exp IS NOT NULL AND exp < datetime('now')".to_string() + "DELETE FROM happyview_labels WHERE exp IS NOT NULL AND exp < datetime('now')" + .to_string() } DatabaseBackend::Postgres => { - "DELETE FROM labels WHERE exp IS NOT NULL AND exp < NOW()".to_string() + "DELETE FROM happyview_labels WHERE exp IS NOT NULL AND exp < NOW()".to_string() } }; @@ -525,7 +527,7 @@ }; // Delete orphaned labels (no matching record). let orphaned = sqlx::query( - "DELETE FROM labels WHERE NOT EXISTS (SELECT 1 FROM records WHERE records.uri = labels.uri)", + "DELETE FROM happyview_labels WHERE NOT EXISTS (SELECT 1 FROM happyview_records WHERE happyview_records.uri = happyview_labels.uri)", ) .execute(&db) .await; diff --git a/src/lexicon.rs b/src/lexicon.rs --- a/src/lexicon.rs +++ b/src/lexicon.rs @@ -12,6 +12,8 @@ pub enum LexiconType { Record, Query, Procedure, + /// A space type declaration: defines the shape and allowed collections for a space type. + Space, /// Lexicons with no `main` def or a non-endpoint main type (token, object, string, etc.). Definitions, } @@ -83,6 +85,10 @@ /// Optional per-NSID token cost for rate limiting. pub token_cost: Option, /// Optional space type NSID indicating this lexicon is designed for use within spaces of that type. pub space_type: Option, + /// For space declarations: the human-readable name (1-64 chars). + pub space_name: Option, + /// For space declarations: the allowed collection NSIDs. + pub space_collections: Option>, } impl ParsedLexicon { @@ -110,6 +116,7 @@ let lexicon_type = match main_type_str { Some("record") => LexiconType::Record, Some("query") => LexiconType::Query, Some("procedure") => LexiconType::Procedure, + Some("space") => LexiconType::Space, _ => LexiconType::Definitions, }; @@ -128,6 +135,41 @@ .get("spaceType") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let (space_name, space_collections) = if lexicon_type == LexiconType::Space { + let main = main_def.ok_or("space lexicon missing 'defs.main'")?; + + main.get("key") + .and_then(|v| v.as_str()) + .ok_or("space lexicon 'defs.main.key' must be a string")?; + + let name = main + .get("name") + .and_then(|v| v.as_str()) + .ok_or("space lexicon 'defs.main.name' must be a string")?; + let name_len = name.chars().count(); + if name_len == 0 || name_len > 64 { + return Err("space lexicon 'defs.main.name' must be 1-64 characters".into()); + } + + let collections_val = main + .get("collections") + .and_then(|v| v.as_array()) + .ok_or("space lexicon 'defs.main.collections' must be an array")?; + let collections: Vec = collections_val + .iter() + .enumerate() + .map(|(i, v)| { + v.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| format!("space lexicon 'collections[{i}]' must be a string")) + }) + .collect::>()?; + + (Some(name.to_string()), Some(collections)) + } else { + (None, None) + }; + Ok(Self { id, lexicon_type, @@ -142,6 +184,8 @@ target_collection, action, token_cost, space_type, + space_name, + space_collections, }) } } @@ -176,7 +220,7 @@ Option, Option, Option, )> = sqlx::query_as( - "SELECT id, lexicon_json, revision, target_collection, action, token_cost FROM lexicons", + "SELECT id, lexicon_json, revision, target_collection, action, token_cost FROM happyview_lexicons", ) .fetch_all(db) .await @@ -274,6 +318,15 @@ /// Return the total count of registered lexicons. pub async fn count(&self) -> usize { let inner = self.inner.read().await; inner.len() + } + + /// Look up a space-type declaration by NSID. Returns `None` if not found or not a space type. + pub async fn get_space_declaration(&self, id: &str) -> Option { + let inner = self.inner.read().await; + inner + .get(id) + .filter(|lex| lex.lexicon_type == LexiconType::Space) + .cloned() } } @@ -652,6 +705,164 @@ assert_eq!(ProcedureAction::Create.to_optional_str(), Some("create")); assert_eq!(ProcedureAction::Update.to_optional_str(), Some("update")); assert_eq!(ProcedureAction::Delete.to_optional_str(), Some("delete")); assert_eq!(ProcedureAction::Upsert.to_optional_str(), None); + } + + fn space_declaration_lexicon_json() -> Value { + json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "name": "Forum", + "collections": [ + "com.example.forum.post", + "com.example.forum.comment" + ] + } + } + }) + } + + #[test] + fn parse_space_declaration_lexicon() { + let parsed = ParsedLexicon::parse( + space_declaration_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); + assert_eq!(parsed.lexicon_type, LexiconType::Space); + assert_eq!(parsed.space_name.as_deref(), Some("Forum")); + assert_eq!( + parsed.space_collections, + Some(vec![ + "com.example.forum.post".to_string(), + "com.example.forum.comment".to_string() + ]) + ); + } + + #[test] + fn parse_space_declaration_missing_key_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "name": "Forum", + "collections": [] + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("key")); + } + + #[test] + fn parse_space_declaration_missing_name_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "collections": [] + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn parse_space_declaration_name_too_long_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "name": "a".repeat(65), + "collections": [] + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("1-64")); + } + + #[test] + fn parse_space_declaration_missing_collections_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "name": "Forum" + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("collections")); + } + + #[tokio::test] + async fn registry_get_space_declaration() { + let reg = LexiconRegistry::new(); + let parsed = ParsedLexicon::parse( + space_declaration_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); + reg.upsert(parsed).await; + + let decl = reg.get_space_declaration("com.example.forum").await; + assert!(decl.is_some()); + assert_eq!( + decl.unwrap().space_collections, + Some(vec![ + "com.example.forum.post".to_string(), + "com.example.forum.comment".to_string() + ]) + ); + + let not_space = reg.get_space_declaration("nonexistent").await; + assert!(not_space.is_none()); + } + + #[tokio::test] + async fn registry_get_space_declaration_excludes_non_space_types() { + let reg = LexiconRegistry::new(); + let parsed = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); + reg.upsert(parsed).await; + + let result = reg + .get_space_declaration("games.gamesgamesgamesgames.game") + .await; + assert!(result.is_none()); } #[test] diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,7 @@ pub mod service_entries; pub mod service_identity; pub mod setup; pub mod spaces; +pub mod verification_methods; pub mod xrpc; use auth::oauth_store::{DbSessionStore, DbStateStore}; diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -1,3 +1,4 @@ +use axum::body::Bytes; use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; use std::sync::Arc; @@ -5,6 +6,22 @@ use crate::AppState; use crate::db::{adapt_sql, now_rfc3339}; use crate::profile; +/// Opaque handle to blob bytes stored on the Rust side. +/// Lua scripts receive this from `atproto.blob_download()` and pass it +/// to `atproto.blob_upload()` — the binary data never enters the Lua VM. +#[derive(Clone)] +pub(crate) struct BlobHandle { + pub data: Bytes, + pub mime_type: String, +} + +impl mlua::UserData for BlobHandle { + fn add_methods>(methods: &mut M) { + methods.add_method("size", |_, this, ()| Ok(this.data.len())); + methods.add_method("mime_type", |_, this, ()| Ok(this.mime_type.clone())); + } +} + /// Register the `atproto` table with AT Protocol utility functions. /// /// When `caller_did` is provided, the `atproto.sign(record)` function is @@ -32,6 +49,68 @@ })?; atproto_table.set("resolve_service_endpoint", resolve_fn)?; + // atproto.blob_download(did, cid) -> { handle = BlobHandle, mimeType = string, size = number } + { + let state_clone = state.clone(); + let blob_download_fn = + lua.create_async_function(move |lua, (did, cid): (String, String)| { + let state = state_clone.clone(); + async move { + let pds_endpoint = + profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, &did) + .await + .map_err(|e| { + mlua::Error::runtime(format!( + "blob_download: failed to resolve PDS for {did}: {e}" + )) + })?; + + let url = format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + pds_endpoint, + urlencoding::encode(&did), + urlencoding::encode(&cid), + ); + + let response = state.http.get(&url).send().await.map_err(|e| { + mlua::Error::runtime(format!("blob_download: request failed: {e}")) + })?; + + let status = response.status(); + if !status.is_success() { + return Err(mlua::Error::runtime(format!( + "blob_download: PDS returned {status} for did={did} cid={cid}" + ))); + } + + let mime_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let bytes = response.bytes().await.map_err(|e| { + mlua::Error::runtime(format!("blob_download: failed to read body: {e}")) + })?; + + let size = bytes.len(); + let handle = BlobHandle { + data: bytes, + mime_type: mime_type.clone(), + }; + + let result = lua.create_table()?; + result.set("handle", lua.create_userdata(handle)?)?; + result.set("mimeType", mime_type)?; + result.set("size", size)?; + + Ok(mlua::Value::Table(result)) + } + })?; + atproto_table.set("blob_download", blob_download_fn)?; + } + // get_labels(uri) -> array of { src, uri, val, cts } let state_clone = state.clone(); let get_labels_fn = lua.create_async_function(move |lua, uri: String| { @@ -40,7 +119,7 @@ async move { let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val, cts FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", + "SELECT src, uri, val, cts FROM happyview_labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String, String)> = sqlx::query_as(&sql) @@ -64,7 +143,7 @@ idx += 1; } // Check for self-labels in the record itself. - let record_sql = adapt_sql("SELECT did, record FROM records WHERE uri = ?", backend); + let record_sql = adapt_sql("SELECT did, record FROM happyview_records WHERE uri = ?", backend); let record: Option<(String, String)> = sqlx::query_as(&record_sql) .bind(&uri) .fetch_optional(&state.db) @@ -112,7 +191,7 @@ let now = now_rfc3339(); // Query labels for all URIs (one query per URI since AnyPool doesn't support array binding). let label_sql = adapt_sql( - "SELECT src, uri, val, cts FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", + "SELECT src, uri, val, cts FROM happyview_labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let mut rows: Vec<(String, String, String, String)> = Vec::new(); @@ -131,7 +210,7 @@ } // Query records for self-labels. let record_sql = adapt_sql( - "SELECT uri, did, record FROM records WHERE uri = ?", + "SELECT uri, did, record FROM happyview_records WHERE uri = ?", backend, ); let mut records: Vec<(String, String, String)> = Vec::new(); @@ -472,6 +551,123 @@ lua.globals().set("atproto", atproto_table)?; Ok(()) } +/// Register blob upload capability on the existing `atproto` table. +/// +/// Called only in procedure execution contexts where PDS auth is +/// available. `blob_download` is registered in `register_atproto_api` +/// (available everywhere); `blob_upload` needs caller credentials to +/// write to the caller's PDS. +pub(crate) fn register_atproto_blob_api( + lua: &Lua, + state: Arc, + claims: Arc, + pds_auth: Arc, +) -> LuaResult<()> { + let atproto_table: mlua::Table = lua.globals().get("atproto")?; + + let upload_fn = lua.create_async_function( + move |lua, (handle, content_type): (mlua::AnyUserData, String)| { + let state = state.clone(); + let claims = claims.clone(); + let pds_auth = pds_auth.clone(); + async move { + let blob_handle = handle.borrow::().map_err(|_| { + mlua::Error::runtime( + "blob_upload: first argument must be a BlobHandle from blob_download()", + ) + })?; + let blob_bytes = blob_handle.data.clone(); + drop(blob_handle); + + let result = + upload_blob_to_pds(&state, claims.did(), &pds_auth, &content_type, blob_bytes) + .await + .map_err(|e| mlua::Error::runtime(format!("blob_upload: {e}")))?; + + lua.to_value(&result) + } + }, + )?; + atproto_table.set("blob_upload", upload_fn)?; + + Ok(()) +} + +async fn upload_blob_to_pds( + state: &AppState, + caller_did: &str, + pds_auth: &crate::repo::PdsAuth, + content_type: &str, + blob_bytes: Bytes, +) -> Result { + use crate::error::AppError; + use crate::repo::PdsAuth; + + match pds_auth { + PdsAuth::OAuth(session) => { + use atrium_xrpc::{ + InputDataOrBytes, OutputDataOrBytes, XrpcClient, XrpcRequest, http::Method, + }; + + let request = XrpcRequest { + method: Method::POST, + nsid: "com.atproto.repo.uploadBlob".to_string(), + parameters: None::<()>, + input: Some(InputDataOrBytes::<()>::Bytes(blob_bytes.to_vec())), + encoding: Some(content_type.to_string()), + }; + + let result: Result< + OutputDataOrBytes, + atrium_xrpc::Error, + > = session.send_xrpc(&request).await; + + match result { + Ok(OutputDataOrBytes::Data(data)) => Ok(data), + Ok(OutputDataOrBytes::Bytes(bytes)) => serde_json::from_slice(&bytes) + .map_err(|e| AppError::Internal(format!("invalid uploadBlob response: {e}"))), + Err(e) => Err(AppError::Internal(format!("PDS uploadBlob failed: {e}"))), + } + } + PdsAuth::Dpop { + api_client_id, + dpop_key_id, + encryption_key, + } => { + let resp = crate::oauth::pds_write::dpop_pds_post_blob( + &state.http, + &state.db, + state.db_backend, + encryption_key, + &state.oauth, + &state.config.plc_url, + api_client_id, + caller_did, + dpop_key_id, + content_type, + blob_bytes, + ) + .await?; + + let status = resp.status(); + let body = resp + .bytes() + .await + .map_err(|e| AppError::Internal(format!("failed to read upload response: {e}")))?; + + if !status.is_success() { + let body_str = String::from_utf8_lossy(&body); + return Err(AppError::Internal(format!( + "PDS uploadBlob returned {status}: {body_str}" + ))); + } + + serde_json::from_slice(&body) + .map_err(|e| AppError::Internal(format!("invalid uploadBlob response: {e}"))) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -748,5 +944,231 @@ and type(atproto.spaces.query) == "function" "#; let result: bool = lua.load(chunk).eval_async().await.unwrap(); assert!(result); + } + + #[tokio::test] + async fn blob_handle_exposes_size_and_mime() { + let state = test_state_with_plc(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let handle = BlobHandle { + data: axum::body::Bytes::from_static(b"hello world"), + mime_type: "text/plain".to_string(), + }; + lua.globals() + .set("test_handle", lua.create_userdata(handle).unwrap()) + .unwrap(); + + let size: usize = lua + .load("return test_handle:size()") + .eval_async() + .await + .unwrap(); + assert_eq!(size, 11); + + let mime: String = lua + .load("return test_handle:mime_type()") + .eval_async() + .await + .unwrap(); + assert_eq!(mime, "text/plain"); + } + + #[tokio::test] + async fn blob_download_returns_handle_and_metadata() { + let mock = wiremock::MockServer::start().await; + + let did_doc = serde_json::json!({ + "id": "did:plc:blobsource", + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": mock.uri() + }] + }); + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/did:plc:blobsource")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&did_doc)) + .mount(&mock) + .await; + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/xrpc/com.atproto.sync.getBlob")) + .and(wiremock::matchers::query_param("did", "did:plc:blobsource")) + .and(wiremock::matchers::query_param("cid", "bafytest123")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "image/png") + .set_body_bytes(vec![0x89, 0x50, 0x4E, 0x47]), + ) + .mount(&mock) + .await; + + let state = test_state_with_plc(&mock.uri()); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let chunk = r#" + local result = atproto.blob_download("did:plc:blobsource", "bafytest123") + return { + size = result.handle:size(), + mimeType = result.mimeType, + } + "#; + let result: mlua::Table = lua.load(chunk).eval_async().await.unwrap(); + assert_eq!(result.get::("size").unwrap(), 4); + assert_eq!(result.get::("mimeType").unwrap(), "image/png"); + } + + #[tokio::test] + async fn blob_download_throws_on_404() { + let mock = wiremock::MockServer::start().await; + + let did_doc = serde_json::json!({ + "id": "did:plc:blobsource", + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": mock.uri() + }] + }); + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/did:plc:blobsource")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&did_doc)) + .mount(&mock) + .await; + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/xrpc/com.atproto.sync.getBlob")) + .respond_with(wiremock::ResponseTemplate::new(404)) + .mount(&mock) + .await; + + let state = test_state_with_plc(&mock.uri()); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let result: Result = lua + .load(r#"return atproto.blob_download("did:plc:blobsource", "bafymissing")"#) + .eval_async() + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn blob_download_defaults_mime_to_octet_stream() { + let mock = wiremock::MockServer::start().await; + + let did_doc = serde_json::json!({ + "id": "did:plc:blobsource", + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": mock.uri() + }] + }); + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/did:plc:blobsource")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&did_doc)) + .mount(&mock) + .await; + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/xrpc/com.atproto.sync.getBlob")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(vec![0xFF, 0xD8])) + .mount(&mock) + .await; + + let state = test_state_with_plc(&mock.uri()); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let chunk = r#" + local result = atproto.blob_download("did:plc:blobsource", "bafynoheader") + return result.mimeType + "#; + let result: String = lua.load(chunk).eval_async().await.unwrap(); + assert_eq!(result, "application/octet-stream"); + } + + #[tokio::test] + async fn blob_download_throws_on_429() { + let mock = wiremock::MockServer::start().await; + + let did_doc = serde_json::json!({ + "id": "did:plc:blobsource", + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": mock.uri() + }] + }); + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/did:plc:blobsource")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&did_doc)) + .mount(&mock) + .await; + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/xrpc/com.atproto.sync.getBlob")) + .respond_with(wiremock::ResponseTemplate::new(429)) + .mount(&mock) + .await; + + let state = test_state_with_plc(&mock.uri()); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let result: Result = lua + .load(r#"return atproto.blob_download("did:plc:blobsource", "bafyratelimit")"#) + .eval_async() + .await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("429"), + "error should mention 429 status: {err_msg}" + ); + } + + #[tokio::test] + async fn blob_download_throws_on_did_resolution_failure() { + let mock = wiremock::MockServer::start().await; + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/did:plc:nonexistent")) + .respond_with(wiremock::ResponseTemplate::new(404)) + .mount(&mock) + .await; + + let state = test_state_with_plc(&mock.uri()); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let result: Result = lua + .load(r#"return atproto.blob_download("did:plc:nonexistent", "bafytest")"#) + .eval_async() + .await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("resolve PDS"), + "error should mention PDS resolution failure: {err_msg}" + ); + } + + #[tokio::test] + async fn blob_upload_throws_without_auth() { + let state = test_state_with_plc(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let has_upload: bool = lua + .load("return atproto.blob_upload ~= nil") + .eval_async() + .await + .unwrap(); + assert!(!has_upload); } } diff --git a/src/lua/context.rs b/src/lua/context.rs --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -8,7 +8,7 @@ pub struct SpaceContext { pub space: String, pub space_id: String, pub did: String, - pub owner_did: String, + pub authority_did: String, pub type_nsid: String, pub skey: String, } @@ -21,7 +21,7 @@ let table = lua.create_table()?; table.set("space", ctx.space.as_str())?; table.set("space_id", ctx.space_id.as_str())?; table.set("did", ctx.did.as_str())?; - table.set("owner_did", ctx.owner_did.as_str())?; + table.set("authority_did", ctx.authority_did.as_str())?; table.set("type_nsid", ctx.type_nsid.as_str())?; table.set("skey", ctx.skey.as_str())?; globals.set("space", table)?; @@ -274,7 +274,7 @@ let space = SpaceContext { space: "ats://did:plc:owner/com.example.forum/main".into(), space_id: "space-123".into(), did: "did:plc:owner".into(), - owner_did: "did:plc:owner".into(), + authority_did: "did:plc:owner".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), }; diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -210,7 +210,7 @@ }; let did_clause = if did.is_some() { " AND did = ?" } else { "" }; let sql = adapt_sql( - &format!("SELECT uri, did, record FROM records WHERE collection = ?{did_clause}{filter_clause} ORDER BY {order_expr} LIMIT ? OFFSET ?"), + &format!("SELECT uri, did, record FROM happyview_records WHERE collection = ?{did_clause}{filter_clause} ORDER BY {order_expr} LIMIT ? OFFSET ?"), backend, ); let mut q = sqlx::query_as(&sql).bind(&collection); @@ -262,7 +262,7 @@ "" }; let sql = adapt_sql( &format!( - "SELECT uri, did, record, created_at FROM records \ + "SELECT uri, did, record, created_at FROM happyview_records \ WHERE collection = ?{did_clause}{cursor_clause}{filter_clause} \ ORDER BY created_at DESC, uri DESC \ LIMIT ?" @@ -321,7 +321,10 @@ let get_fn = lua.create_async_function(move |lua, uri: String| { let state = state_get.clone(); async move { let backend = state.db_backend; - let sql = adapt_sql("SELECT record FROM records WHERE uri = ?", backend); + let sql = adapt_sql( + "SELECT record FROM happyview_records WHERE uri = ?", + backend, + ); let row: Option<(String,)> = sqlx::query_as(&sql) .bind(&uri) .fetch_optional(&state.db) @@ -366,7 +369,7 @@ // while SQLite needs separate ? for each. Different bind counts. let rows: Vec<(String, String, String)> = match backend { DatabaseBackend::Sqlite => { let sql = format!( - "SELECT uri, did, record FROM records \ + "SELECT uri, did, record FROM happyview_records \ WHERE collection = ? \ AND json_extract(record, '$.{field}') LIKE ? COLLATE NOCASE \ ORDER BY \ @@ -390,7 +393,7 @@ .map_err(|e| mlua::Error::runtime(format!("DB search failed: {e}")))? } DatabaseBackend::Postgres => { let sql = format!( - "SELECT uri, did, record FROM records \ + "SELECT uri, did, record FROM happyview_records \ WHERE collection = $1 \ AND record::jsonb->>'{field}' ILIKE $2 \ ORDER BY \ @@ -448,7 +451,7 @@ async move { let backend = state.db_backend; let count: (i64,) = if let Some(ref did) = did { let sql = adapt_sql( - "SELECT COUNT(*) FROM records WHERE collection = ? AND did = ?", + "SELECT COUNT(*) FROM happyview_records WHERE collection = ? AND did = ?", backend, ); sqlx::query_as(&sql) @@ -458,8 +461,10 @@ .fetch_one(&state.db) .await .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))? } else { - let sql = - adapt_sql("SELECT COUNT(*) FROM records WHERE collection = ?", backend); + let sql = adapt_sql( + "SELECT COUNT(*) FROM happyview_records WHERE collection = ?", + backend, + ); sqlx::query_as(&sql) .bind(&collection) .fetch_one(&state.db) @@ -491,8 +496,8 @@ let rows_raw: Vec = match (&did, &cursor_parts) { (Some(did), Some((cursor_ts, cursor_uri))) => { let sql = adapt_sql( - "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ - INNER JOIN record_refs ref ON ref.source_uri = r.uri \ + "SELECT r.uri, r.did, r.record, r.created_at FROM happyview_records r \ + INNER JOIN happyview_record_refs ref ON ref.source_uri = r.uri \ WHERE ref.target_uri = ? AND ref.collection = ? AND r.did = ? \ AND (r.created_at < ? OR (r.created_at = ? AND r.uri < ?)) \ ORDER BY r.created_at DESC, r.uri DESC \ @@ -513,8 +518,8 @@ .map_err(|e| mlua::Error::runtime(format!("DB backlinks failed: {e}")))? } (Some(did), None) => { let sql = adapt_sql( - "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ - INNER JOIN record_refs ref ON ref.source_uri = r.uri \ + "SELECT r.uri, r.did, r.record, r.created_at FROM happyview_records r \ + INNER JOIN happyview_record_refs ref ON ref.source_uri = r.uri \ WHERE ref.target_uri = ? AND ref.collection = ? AND r.did = ? \ ORDER BY r.created_at DESC, r.uri DESC \ LIMIT ?", @@ -531,8 +536,8 @@ .map_err(|e| mlua::Error::runtime(format!("DB backlinks failed: {e}")))? } (None, Some((cursor_ts, cursor_uri))) => { let sql = adapt_sql( - "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ - INNER JOIN record_refs ref ON ref.source_uri = r.uri \ + "SELECT r.uri, r.did, r.record, r.created_at FROM happyview_records r \ + INNER JOIN happyview_record_refs ref ON ref.source_uri = r.uri \ WHERE ref.target_uri = ? AND ref.collection = ? \ AND (r.created_at < ? OR (r.created_at = ? AND r.uri < ?)) \ ORDER BY r.created_at DESC, r.uri DESC \ @@ -552,8 +557,8 @@ .map_err(|e| mlua::Error::runtime(format!("DB backlinks failed: {e}")))? } (None, None) => { let sql = adapt_sql( - "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ - INNER JOIN record_refs ref ON ref.source_uri = r.uri \ + "SELECT r.uri, r.did, r.record, r.created_at FROM happyview_records r \ + INNER JOIN happyview_record_refs ref ON ref.source_uri = r.uri \ WHERE ref.target_uri = ? AND ref.collection = ? \ ORDER BY r.created_at DESC, r.uri DESC \ LIMIT ?", @@ -808,7 +813,7 @@ async fn raw_allows_non_select() { let state = test_state(); let lua = setup(&state); let result: Result = lua - .load(r#"return db.raw("DELETE FROM records")"#) + .load(r#"return db.raw("DELETE FROM happyview_records")"#) .eval_async() .await; // Should fail with a DB connection error, NOT a validation error diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -23,7 +23,7 @@ use super::sandbox; /// Load all script variables from the database as a key-value map. async fn load_env_vars(db: &sqlx::AnyPool, backend: DatabaseBackend) -> HashMap { - let sql = adapt_sql("SELECT key, value FROM script_variables", backend); + let sql = adapt_sql("SELECT key, value FROM happyview_script_variables", backend); sqlx::query_as::<_, (String, String)>(&sql) .fetch_all(db) .await @@ -215,6 +215,37 @@ } if let Err(e) = atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(claims.did())) { let error_message = format!("failed to register atproto API: {e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: Some(claims.did().to_string()), + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "input": input_json, + "caller_did": claims.did(), + "method": method, + "duration_ms": start.elapsed().as_millis() as u64, + }), + }, + backend, + ) + .await; + return Err(AppError::Internal(error_message)); + } + + if let Some(ref pds_auth) = pds_auth_arc + && let Err(e) = atproto_api::register_atproto_blob_api( + &lua, + state_arc.clone(), + claims_arc.clone(), + pds_auth.clone(), + ) + { + let error_message = format!("failed to register atproto blob API: {e}"); log_event( &state.db, EventLog { diff --git a/src/lua/record.rs b/src/lua/record.rs --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -148,7 +148,7 @@ .unwrap_or_default(); let now = now_rfc3339(); let data_str = serde_json::to_string(&data).unwrap_or_default(); let upsert_sql = adapt_sql( - r#"INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -212,7 +212,7 @@ let rkey = uri.split('/').next_back().unwrap_or_default(); let data_str = serde_json::to_string(&data).unwrap_or_default(); let now = now_rfc3339(); let upsert_sql = adapt_sql( - r#"INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -317,7 +317,7 @@ } // Always delete locally — operator's logical action is // "remove this record from view" regardless of PDS outcome. - let delete_sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); + let delete_sql = adapt_sql("DELETE FROM happyview_records WHERE uri = ?", backend); let _ = sqlx::query(&delete_sql).bind(&uri).execute(&state.db).await; this.raw_set("_uri", mlua::Value::Nil)?; @@ -417,7 +417,7 @@ // NULL CID — no PDS round-trip means we have no real CID // to record. let upsert_sql = adapt_sql( - r#"INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -462,7 +462,7 @@ let uri: String = this.raw_get::>("_uri")?.ok_or_else(|| { mlua::Error::runtime("cannot delete_local a Record that has no _uri") })?; - let delete_sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); + let delete_sql = adapt_sql("DELETE FROM happyview_records WHERE uri = ?", backend); sqlx::query(&delete_sql) .bind(&uri) .execute(&state.db) @@ -749,7 +749,7 @@ .unwrap_or_default(); let now = now_rfc3339(); let data_str = serde_json::to_string(&data).unwrap_or_default(); let upsert_sql = adapt_sql( - r#"INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -818,7 +818,7 @@ uri.split('/').next_back().unwrap_or_default(); let data_str = serde_json::to_string(&data).unwrap_or_default(); let now = now_rfc3339(); let upsert_sql = adapt_sql( - r#"INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -874,7 +874,7 @@ let metatable = metatable_c.clone(); async move { let backend = state.db_backend; let sql = adapt_sql( - "SELECT collection, record, cid FROM records WHERE uri = ?", + "SELECT collection, record, cid FROM happyview_records WHERE uri = ?", backend, ); let row: Option<(String, String, String)> = sqlx::query_as(&sql) @@ -941,7 +941,7 @@ let state = state.clone(); let uri = uri.clone(); async move { let sql = adapt_sql( - "SELECT collection, record, cid FROM records WHERE uri = ?", + "SELECT collection, record, cid FROM happyview_records WHERE uri = ?", backend, ); let row: Option<(String, String, String)> = sqlx::query_as(&sql) @@ -1020,7 +1020,7 @@ let delete_local_static_fn = lua.create_async_function(move |_lua, uri: String| { let state = state.clone(); async move { let backend = state.db_backend; - let delete_sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); + let delete_sql = adapt_sql("DELETE FROM happyview_records WHERE uri = ?", backend); let res = sqlx::query(&delete_sql) .bind(&uri) .execute(&state.db) diff --git a/src/lua/scripts.rs b/src/lua/scripts.rs --- a/src/lua/scripts.rs +++ b/src/lua/scripts.rs @@ -223,7 +223,7 @@ /// Look up a single trigger id. Returns `None` when no row matches OR when /// the row's `script_type` is unknown to this binary (logged at warn). pub async fn resolve(state: &AppState, trigger_id: &str) -> Option { let sql = adapt_sql( - "SELECT id, body, script_type FROM scripts WHERE id = ?", + "SELECT id, body, script_type FROM happyview_scripts WHERE id = ?", state.db_backend, ); let row: Option<(String, String, String)> = match sqlx::query_as(&sql) @@ -791,7 +791,7 @@ async fn load_env_vars( db: &sqlx::AnyPool, backend: DatabaseBackend, ) -> std::collections::HashMap { - let sql = adapt_sql("SELECT key, value FROM script_variables", backend); + let sql = adapt_sql("SELECT key, value FROM happyview_script_variables", backend); sqlx::query_as::<_, (String, String)>(&sql) .fetch_all(db) .await @@ -813,7 +813,7 @@ async fn write_dead_letter(state: &AppState, entry: &DeadLetterEntry<'_>) { let payload_str = serde_json::to_string(entry.payload).unwrap_or_else(|_| "{}".to_string()); let sql = adapt_sql( - "INSERT INTO dead_letter_scripts + "INSERT INTO happyview_dead_letter_scripts (script_ref, host_kind, host_id, payload, error, attempts, created_at, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", state.db_backend, diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -313,7 +313,7 @@ /// not consulted by dispatch). async fn seed_script(state: &AppState, trigger: &str, body: &str) { sqlx::query( r#" - CREATE TABLE IF NOT EXISTS scripts ( + CREATE TABLE IF NOT EXISTS happyview_scripts ( id TEXT PRIMARY KEY, body TEXT NOT NULL, description TEXT, @@ -327,7 +327,7 @@ .execute(&state.db) .await .unwrap(); sqlx::query( - "INSERT OR REPLACE INTO scripts (id, body, script_type, created_at, updated_at) + "INSERT OR REPLACE INTO happyview_scripts (id, body, script_type, created_at, updated_at) VALUES (?, ?, 'lua', datetime('now'), datetime('now'))", ) .bind(trigger) @@ -352,6 +352,8 @@ target_collection: None, action: ProcedureAction::Create, token_cost: None, space_type: None, + space_name: None, + space_collections: None, } } @@ -370,6 +372,8 @@ target_collection: None, action: ProcedureAction::Create, token_cost: None, space_type: None, + space_name: None, + space_collections: None, } } diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -53,14 +53,14 @@ { let db_bg = db_pool.clone(); let backend = db_backend; tokio::spawn(async move { - let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM record_refs") + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM happyview_record_refs") .fetch_one(&db_bg) .await .expect("failed to count record_refs"); if count.0 == 0 { info!("backfilling record_refs table in background..."); - let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM records") + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM happyview_records") .fetch_one(&db_bg) .await .expect("failed to count records"); @@ -71,7 +71,7 @@ let mut offset = 0i64; let mut processed = 0usize; let query = db::adapt_sql( - "SELECT uri, collection, record FROM records ORDER BY uri LIMIT ? OFFSET ?", + "SELECT uri, collection, record FROM happyview_records ORDER BY uri LIMIT ? OFFSET ?", backend, ); @@ -121,7 +121,7 @@ // Re-fetch all network lexicons from their respective PDSes. let http = reqwest::Client::new(); let network_rows: Vec<(String, Option, Option)> = sqlx::query_as( - "SELECT id, authority_did, target_collection FROM lexicons WHERE source = 'network'", + "SELECT id, authority_did, target_collection FROM happyview_lexicons WHERE source = 'network'", ) .fetch_all(&db_pool) .await @@ -142,7 +142,7 @@ ) { Ok(parsed) => { let now = db::now_rfc3339(); let update_sql = db::adapt_sql( - "UPDATE lexicons SET lexicon_json = ?, last_fetched_at = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND source = 'network'", + "UPDATE happyview_lexicons SET lexicon_json = ?, last_fetched_at = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND source = 'network'", db_backend, ); let lexicon_json_str = @@ -283,7 +283,7 @@ Option, Option, ); let client_rows: Vec = sqlx::query_as( - "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate, parent_client_id FROM api_clients WHERE is_active = 1", + "SELECT client_key, client_secret_hash, client_uri, rate_limit_capacity, rate_limit_refill_rate, parent_client_id FROM happyview_api_clients WHERE is_active = 1", ) .fetch_all(&db_pool) .await @@ -326,7 +326,8 @@ // Seed and load domain cache let domain_cache = happyview::domain::DomainCache::new(); { - let count_sql = happyview::db::adapt_sql("SELECT COUNT(*) FROM domains", db_backend); + let count_sql = + happyview::db::adapt_sql("SELECT COUNT(*) FROM happyview_domains", db_backend); let row = sqlx::query(&count_sql) .fetch_one(&db_pool) .await @@ -337,7 +338,7 @@ if count == 0 { let id = uuid::Uuid::new_v4().to_string(); let now = happyview::db::now_rfc3339(); let insert_sql = happyview::db::adapt_sql( - "INSERT INTO domains (id, url, is_primary, created_at, updated_at) VALUES (?, ?, 1, ?, ?)", + "INSERT INTO happyview_domains (id, url, is_primary, created_at, updated_at) VALUES (?, ?, 1, ?, ?)", db_backend, ); sqlx::query(&insert_sql) @@ -352,7 +353,7 @@ info!("Seeded primary domain: {}", config.public_url); } else { // Sync the primary domain URL with PUBLIC_URL if it changed let primary_sql = happyview::db::adapt_sql( - "SELECT id, url FROM domains WHERE is_primary = 1", + "SELECT id, url FROM happyview_domains WHERE is_primary = 1", db_backend, ); if let Some(row) = sqlx::query(&primary_sql) @@ -365,7 +366,7 @@ if primary_url != config.public_url { let primary_id: String = row.try_get("id").unwrap_or_default(); let now = happyview::db::now_rfc3339(); let update_sql = happyview::db::adapt_sql( - "UPDATE domains SET url = ?, updated_at = ? WHERE id = ?", + "UPDATE happyview_domains SET url = ?, updated_at = ? WHERE id = ?", db_backend, ); sqlx::query(&update_sql) @@ -384,7 +385,7 @@ } } let select_sql = happyview::db::adapt_sql( - "SELECT id, url, is_primary, created_at, updated_at FROM domains", + "SELECT id, url, is_primary, created_at, updated_at FROM happyview_domains", db_backend, ); let rows = sqlx::query(&select_sql) @@ -720,7 +721,7 @@ .is_none() { let now = happyview::db::now_rfc3339(); let sql = happyview::db::adapt_sql( - "INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING", + "INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING", backend, ); if let Err(e) = sqlx::query(&sql) diff --git a/src/oauth/client_auth.rs b/src/oauth/client_auth.rs --- a/src/oauth/client_auth.rs +++ b/src/oauth/client_auth.rs @@ -22,7 +22,7 @@ ) -> Result { let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); let sql = adapt_sql( - "SELECT id, client_key, client_type, scopes, allowed_origins, client_secret_hash FROM api_clients WHERE client_key = ? AND is_active = 1", + "SELECT id, client_key, client_type, scopes, allowed_origins, client_secret_hash FROM happyview_api_clients WHERE client_key = ? AND is_active = 1", backend, ); @@ -67,7 +67,7 @@ client_key: &str, origin: Option<&str>, ) -> Result { let sql = adapt_sql( - "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1", + "SELECT id, client_key, client_type, scopes, allowed_origins FROM happyview_api_clients WHERE client_key = ? AND is_active = 1", backend, ); @@ -126,7 +126,7 @@ backend: DatabaseBackend, client_key: &str, ) -> Result { let sql = adapt_sql( - "SELECT id, client_key, client_type, scopes, allowed_origins FROM api_clients WHERE client_key = ? AND is_active = 1", + "SELECT id, client_key, client_type, scopes, allowed_origins FROM happyview_api_clients WHERE client_key = ? AND is_active = 1", backend, ); @@ -208,6 +208,16 @@ let prefix = format!("repo:{}?", col); client_set.iter().any(|cs| cs.starts_with(&prefix)) }); if all_allowed { + continue; + } + } + + // The PDS also grants bare `repo:COLLECTION` scopes (without + // `?action=`). Match if the expanded client set has any + // `repo:COLLECTION?action=...` entry for that collection. + if let Some(collection) = scope.strip_prefix("repo:") { + let prefix = format!("repo:{}?", collection); + if client_set.iter().any(|cs| cs.starts_with(&prefix)) { continue; } } @@ -493,6 +503,51 @@ "atproto include:com.example.authBasic", ®, ) .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn validate_scopes_bare_repo_collection_allowed_with_expanded_permissions() { + let reg = empty_registry(); + let raw = serde_json::json!({ + "lexicon": 1, + "id": "com.example.authBasic", + "defs": { + "main": { + "type": "permission-set", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": ["com.example.profile", "com.example.post"] + } + ] + } + } + }); + let parsed = crate::lexicon::ParsedLexicon::parse( + raw, + 1, + None, + crate::lexicon::ProcedureAction::Upsert, + None, + ) + .unwrap(); + reg.upsert(parsed).await; + + let result = validate_scopes( + "atproto repo:com.example.profile", + "atproto include:com.example.authBasic", + ®, + ) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn validate_scopes_bare_repo_collection_rejected_without_permission() { + let reg = empty_registry(); + let result = validate_scopes("atproto repo:com.example.secret", "atproto", ®).await; assert!(result.is_err()); } diff --git a/src/oauth/keys.rs b/src/oauth/keys.rs --- a/src/oauth/keys.rs +++ b/src/oauth/keys.rs @@ -117,7 +117,7 @@ .map_err(|e| AppError::Internal(format!("failed to encrypt DPoP key: {e}")))?; let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO dpop_keys (id, provision_id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_dpop_keys (id, provision_id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); @@ -145,7 +145,7 @@ provision_id: &str, ) -> Result<(String, String, serde_json::Value, String, Option), AppError> { // Returns: (id, api_client_id, private_jwk, thumbprint, pkce_challenge) let sql = adapt_sql( - "SELECT id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge FROM dpop_keys WHERE provision_id = ?", + "SELECT id, api_client_id, private_key_enc, jwk_thumbprint, pkce_challenge FROM happyview_dpop_keys WHERE provision_id = ?", backend, ); @@ -174,7 +174,10 @@ pool: &sqlx::AnyPool, backend: DatabaseBackend, key_id: &str, ) -> Result { - let sql = adapt_sql("SELECT jwk_thumbprint FROM dpop_keys WHERE id = ?", backend); + let sql = adapt_sql( + "SELECT jwk_thumbprint FROM happyview_dpop_keys WHERE id = ?", + backend, + ); let row: Option<(String,)> = sqlx::query_as(&sql) .bind(key_id) @@ -194,7 +197,7 @@ api_client_id: &str, thumbprint: &str, ) -> Result { let sql = adapt_sql( - "SELECT id FROM dpop_keys WHERE api_client_id = ? AND jwk_thumbprint = ?", + "SELECT id FROM happyview_dpop_keys WHERE api_client_id = ? AND jwk_thumbprint = ?", backend, ); @@ -216,13 +219,16 @@ backend: DatabaseBackend, dpop_key_id: &str, ) -> Result<(), AppError> { // Session is deleted by CASCADE, but be explicit for clarity - let session_sql = adapt_sql("DELETE FROM dpop_sessions WHERE dpop_key_id = ?", backend); + let session_sql = adapt_sql( + "DELETE FROM happyview_dpop_sessions WHERE dpop_key_id = ?", + backend, + ); let _ = sqlx::query(&session_sql) .bind(dpop_key_id) .execute(pool) .await; - let key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + let key_sql = adapt_sql("DELETE FROM happyview_dpop_keys WHERE id = ?", backend); sqlx::query(&key_sql) .bind(dpop_key_id) .execute(pool) diff --git a/src/oauth/pds_write.rs b/src/oauth/pds_write.rs --- a/src/oauth/pds_write.rs +++ b/src/oauth/pds_write.rs @@ -47,7 +47,7 @@ None => resolve_pds_from_did(http, plc_url, user_did).await?, }; let key_sql = crate::db::adapt_sql( - "SELECT private_key_enc FROM dpop_keys WHERE id = ?", + "SELECT private_key_enc FROM happyview_dpop_keys WHERE id = ?", backend, ); let row: Option<(Vec,)> = sqlx::query_as(&key_sql) @@ -593,7 +593,7 @@ backend: DatabaseBackend, api_client_id: &str, ) -> Result { let sql = crate::db::adapt_sql( - "SELECT client_id_url FROM api_clients WHERE id = ?", + "SELECT client_id_url FROM happyview_api_clients WHERE id = ?", backend, ); let row: Option<(String,)> = sqlx::query_as(&sql) diff --git a/src/oauth/sessions.rs b/src/oauth/sessions.rs --- a/src/oauth/sessions.rs +++ b/src/oauth/sessions.rs @@ -57,7 +57,7 @@ .transpose()?; let now = now_rfc3339(); let sql = adapt_sql( - r#"INSERT INTO dpop_sessions (id, api_client_id, dpop_key_id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer, created_at, updated_at) + r#"INSERT INTO happyview_dpop_sessions (id, api_client_id, dpop_key_id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (api_client_id, user_did, dpop_key_id) DO UPDATE SET access_token_enc = EXCLUDED.access_token_enc, @@ -100,7 +100,7 @@ user_did: &str, dpop_key_id: &str, ) -> Result { let sql = adapt_sql( - "SELECT id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND user_did = ? AND dpop_key_id = ?", + "SELECT id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM happyview_dpop_sessions WHERE api_client_id = ? AND user_did = ? AND dpop_key_id = ?", backend, ); @@ -163,7 +163,7 @@ api_client_id: &str, dpop_key_id: &str, ) -> Result { let sql = adapt_sql( - "SELECT id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND dpop_key_id = ?", + "SELECT id, user_did, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM happyview_dpop_sessions WHERE api_client_id = ? AND dpop_key_id = ?", backend, ); @@ -225,7 +225,7 @@ user_did: &str, dpop_key_id: &str, ) -> Result { let del_session_sql = adapt_sql( - "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ? AND dpop_key_id = ?", + "DELETE FROM happyview_dpop_sessions WHERE api_client_id = ? AND user_did = ? AND dpop_key_id = ?", backend, ); sqlx::query(&del_session_sql) @@ -236,7 +236,7 @@ .execute(pool) .await .map_err(|e| AppError::Internal(format!("failed to delete DPoP session: {e}")))?; - let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + let del_key_sql = adapt_sql("DELETE FROM happyview_dpop_keys WHERE id = ?", backend); sqlx::query(&del_key_sql) .bind(dpop_key_id) .execute(pool) @@ -254,7 +254,7 @@ api_client_id: &str, user_did: &str, ) -> Result<(), AppError> { let key_ids_sql = adapt_sql( - "SELECT dpop_key_id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + "SELECT dpop_key_id FROM happyview_dpop_sessions WHERE api_client_id = ? AND user_did = ?", backend, ); let key_ids: Vec<(String,)> = sqlx::query_as(&key_ids_sql) @@ -265,7 +265,7 @@ .await .map_err(|e| AppError::Internal(format!("failed to list DPoP sessions: {e}")))?; let del_sessions_sql = adapt_sql( - "DELETE FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + "DELETE FROM happyview_dpop_sessions WHERE api_client_id = ? AND user_did = ?", backend, ); sqlx::query(&del_sessions_sql) @@ -275,7 +275,7 @@ .execute(pool) .await .map_err(|e| AppError::Internal(format!("failed to delete DPoP sessions: {e}")))?; - let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + let del_key_sql = adapt_sql("DELETE FROM happyview_dpop_keys WHERE id = ?", backend); for (key_id,) in key_ids { let _ = sqlx::query(&del_key_sql).bind(&key_id).execute(pool).await; } @@ -291,7 +291,7 @@ api_client_id: &str, user_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, dpop_key_id, scopes, created_at, updated_at FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + "SELECT id, dpop_key_id, scopes, created_at, updated_at FROM happyview_dpop_sessions WHERE api_client_id = ? AND user_did = ?", backend, ); @@ -327,7 +327,7 @@ api_client_id: &str, user_did: &str, ) -> Result { let sql = adapt_sql( - "SELECT id, dpop_key_id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM dpop_sessions WHERE api_client_id = ? AND user_did = ? LIMIT 1", + "SELECT id, dpop_key_id, access_token_enc, refresh_token_enc, token_expires_at, scopes, pds_url, issuer FROM happyview_dpop_sessions WHERE api_client_id = ? AND user_did = ? LIMIT 1", backend, ); @@ -389,7 +389,7 @@ api_client_id: &str, user_did: &str, ) -> Result { let lookup_sql = adapt_sql( - "SELECT dpop_key_id FROM dpop_sessions WHERE id = ? AND api_client_id = ? AND user_did = ?", + "SELECT dpop_key_id FROM happyview_dpop_sessions WHERE id = ? AND api_client_id = ? AND user_did = ?", backend, ); let row: Option<(String,)> = sqlx::query_as(&lookup_sql) @@ -402,14 +402,14 @@ .map_err(|e| AppError::Internal(format!("failed to look up DPoP session: {e}")))?; let (dpop_key_id,) = row.ok_or_else(|| AppError::NotFound("DPoP session not found".into()))?; - let del_session_sql = adapt_sql("DELETE FROM dpop_sessions WHERE id = ?", backend); + let del_session_sql = adapt_sql("DELETE FROM happyview_dpop_sessions WHERE id = ?", backend); sqlx::query(&del_session_sql) .bind(session_id) .execute(pool) .await .map_err(|e| AppError::Internal(format!("failed to delete DPoP session: {e}")))?; - let del_key_sql = adapt_sql("DELETE FROM dpop_keys WHERE id = ?", backend); + let del_key_sql = adapt_sql("DELETE FROM happyview_dpop_keys WHERE id = ?", backend); sqlx::query(&del_key_sql) .bind(&dpop_key_id) .execute(pool) diff --git a/src/plugin/attestation.rs b/src/plugin/attestation.rs --- a/src/plugin/attestation.rs +++ b/src/plugin/attestation.rs @@ -362,7 +362,10 @@ let default_key_id = format!("did:web:{host}#attestation"); let default_sig_type = "games.gamesgamesgamesgames.attestation".to_string(); // 2. Try loading from instance_settings - let sql = adapt_sql("SELECT value FROM instance_settings WHERE key = ?", backend); + let sql = adapt_sql( + "SELECT value FROM happyview_instance_settings WHERE key = ?", + backend, + ); let existing: Option<(String,)> = sqlx::query_as(&sql) .bind("attestation_private_key") .fetch_optional(db) @@ -404,7 +407,7 @@ hex::encode(key_bytes) }; let upsert_sql = adapt_sql( - "INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?) \ + "INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) \ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", backend, ); @@ -596,7 +599,7 @@ .connect("sqlite::memory:") .await .unwrap(); sqlx::query( - "CREATE TABLE instance_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT '')", + "CREATE TABLE happyview_instance_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT '')", ) .execute(&pool) .await @@ -635,7 +638,7 @@ .connect("sqlite::memory:") .await .unwrap(); sqlx::query( - "CREATE TABLE instance_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT '')", + "CREATE TABLE happyview_instance_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT '')", ) .execute(&pool) .await diff --git a/src/plugin/host/kv.rs b/src/plugin/host/kv.rs --- a/src/plugin/host/kv.rs +++ b/src/plugin/host/kv.rs @@ -11,7 +11,7 @@ } pub async fn kv_get(ctx: &HostContext, key: &str) -> Result>, KvError> { let sql = adapt_sql( - "SELECT value FROM plugin_kv + "SELECT value FROM happyview_plugin_kv WHERE plugin_id = ? AND scope = ? AND key = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", ctx.db_backend, @@ -45,7 +45,7 @@ .map(|secs| (chrono::Utc::now() + chrono::Duration::seconds(secs as i64)).to_rfc3339()); // Upsert let sql = adapt_sql( - "INSERT INTO plugin_kv (plugin_id, scope, key, value, expires_at, created_at) + "INSERT INTO happyview_plugin_kv (plugin_id, scope, key, value, expires_at, created_at) VALUES (?, ?, ?, ?, ?, datetime('now')) ON CONFLICT (plugin_id, scope, key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", @@ -66,7 +66,7 @@ } pub async fn kv_delete(ctx: &HostContext, key: &str) -> Result<(), KvError> { let sql = adapt_sql( - "DELETE FROM plugin_kv WHERE plugin_id = ? AND scope = ? AND key = ?", + "DELETE FROM happyview_plugin_kv WHERE plugin_id = ? AND scope = ? AND key = ?", ctx.db_backend, ); diff --git a/src/plugin/host/lookup.rs b/src/plugin/host/lookup.rs --- a/src/plugin/host/lookup.rs +++ b/src/plugin/host/lookup.rs @@ -40,7 +40,7 @@ // Build JSON path for query let json_path = format!("$.{}", external_id_field); let sql = adapt_sql( - "SELECT uri, cid FROM records + "SELECT uri, cid FROM happyview_records WHERE collection = ? AND json_extract(record, ?) = ? LIMIT 1", diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -81,7 +81,7 @@ .and_then(|m| serde_json::to_string(m).ok()); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO plugins (id, source, url, sha256, enabled, loaded_at, api_version, manifest) + "INSERT INTO happyview_plugins (id, source, url, sha256, enabled, loaded_at, api_version, manifest) VALUES (?, ?, ?, ?, 1, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET source = excluded.source, @@ -126,7 +126,7 @@ return Err("No database configured".into()); }; let sql = adapt_sql( - "SELECT id, source, url, sha256 FROM plugins WHERE enabled = true", + "SELECT id, source, url, sha256 FROM happyview_plugins WHERE enabled = true", self.db_backend, ); diff --git a/src/plugin/sync.rs b/src/plugin/sync.rs --- a/src/plugin/sync.rs +++ b/src/plugin/sync.rs @@ -225,7 +225,7 @@ }; let sql = adapt_sql( &format!( - "SELECT uri, cid FROM records WHERE collection = 'games.gamesgamesgamesgames.game' AND {} = ? LIMIT 1", + "SELECT uri, cid FROM happyview_records WHERE collection = 'games.gamesgamesgamesgames.game' AND {} = ? LIMIT 1", json_path ), backend, diff --git a/src/record_handler.rs b/src/record_handler.rs --- a/src/record_handler.rs +++ b/src/record_handler.rs @@ -103,7 +103,7 @@ let now = now_rfc3339(); let backend = state.db_backend; let insert_sql = adapt_sql( r#" - INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -216,7 +216,7 @@ .await; return; } - let delete_sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); + let delete_sql = adapt_sql("DELETE FROM happyview_records WHERE uri = ?", backend); match sqlx::query(&delete_sql).bind(&uri).execute(db).await { Ok(_) => { if state.verbose_event_logging.load(Ordering::Relaxed) { @@ -275,7 +275,7 @@ let backend = state.db_backend; // Check if this NSID is one we're tracking and the DID matches the authority. let select_sql = adapt_sql( - "SELECT target_collection FROM lexicons WHERE id = ? AND source = 'network' AND authority_did = ?", + "SELECT target_collection FROM happyview_lexicons WHERE id = ? AND source = 'network' AND authority_did = ?", backend, ); let tracked: Option<(Option,)> = sqlx::query_as(&select_sql) @@ -317,13 +317,13 @@ // Upsert into lexicons table with last_fetched_at. let now = now_rfc3339(); let upsert_sql = adapt_sql( r#" - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at, created_at) + INSERT INTO happyview_lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at, created_at) VALUES (?, ?, 0, ?, 'network', ?, ?, ?) ON CONFLICT (id) DO UPDATE SET lexicon_json = EXCLUDED.lexicon_json, target_collection = EXCLUDED.target_collection, last_fetched_at = ?, - revision = lexicons.revision + 1, + revision = happyview_lexicons.revision + 1, updated_at = ? "#, backend, @@ -354,7 +354,7 @@ } } "delete" => { // Remove from lexicons table and registry. - let delete_sql = adapt_sql("DELETE FROM lexicons WHERE id = ?", backend); + let delete_sql = adapt_sql("DELETE FROM happyview_lexicons WHERE id = ?", backend); let _ = sqlx::query(&delete_sql).bind(nsid).execute(db).await; let was_present = lexicons.remove(nsid).await; diff --git a/src/record_refs.rs b/src/record_refs.rs --- a/src/record_refs.rs +++ b/src/record_refs.rs @@ -40,7 +40,10 @@ ) -> Result<(), sqlx::Error> { let uris = extract_at_uris(record); // Delete existing refs for this source - let delete_sql = adapt_sql("DELETE FROM record_refs WHERE source_uri = ?", backend); + let delete_sql = adapt_sql( + "DELETE FROM happyview_record_refs WHERE source_uri = ?", + backend, + ); sqlx::query(&delete_sql) .bind(source_uri) .execute(db) @@ -48,7 +51,7 @@ .await?; // Insert new refs let insert_sql = adapt_sql( - "INSERT INTO record_refs (source_uri, target_uri, collection) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO happyview_record_refs (source_uri, target_uri, collection) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", backend, ); for target_uri in &uris { diff --git a/src/repo/session.rs b/src/repo/session.rs --- a/src/repo/session.rs +++ b/src/repo/session.rs @@ -12,7 +12,7 @@ state: &AppState, client_key: &str, ) -> Result { let sql = adapt_sql( - "SELECT id FROM api_clients WHERE client_key = ? AND is_active = 1", + "SELECT id FROM happyview_api_clients WHERE client_key = ? AND is_active = 1", state.db_backend, ); diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -69,6 +69,12 @@ state.clone(), crate::feature_middleware::require_spaces, )), ) + .merge(crate::spaces::simplespace::simplespace_routes().layer( + axum::middleware::from_fn_with_state( + state.clone(), + crate::feature_middleware::require_spaces, + ), + )) .nest("/auth", crate::auth::routes::routes()) .nest("/external-auth", crate::external_auth::routes()) .nest("/oauth", crate::oauth::routes::routes()) @@ -368,6 +374,12 @@ .iter() .map(|e| (e.fragment_id.clone(), e.service_type.clone())) .collect(); + let extra_vms = crate::verification_methods::list_methods(&state.db, state.db_backend) + .await? + .into_iter() + .map(|m| (m.fragment_id, m.key_type, m.public_key_multibase)) + .collect::>(); + let service_endpoint = format!("https://{host}"); let signing_key_multibase = extract_public_key_multibase(&identity, &state)?; @@ -378,6 +390,7 @@ host, &signing_key_multibase, &entry_pairs, &service_endpoint, + &extra_vms, ) .ok_or_else(|| AppError::NotFound("DID document not available".into()))?; diff --git a/src/service_entries.rs b/src/service_entries.rs --- a/src/service_entries.rs +++ b/src/service_entries.rs @@ -59,7 +59,7 @@ db: &AnyPool, backend: DatabaseBackend, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries ORDER BY id", + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM happyview_service_entries ORDER BY id", backend, ); @@ -80,7 +80,7 @@ ) -> Result { let now = Utc::now().to_rfc3339(); let insert_sql = adapt_sql( - "INSERT INTO service_entries (fragment_id, service_type, access_mode, created_at, updated_at) VALUES (?, ?, 'all', ?, ?) RETURNING id", + "INSERT INTO happyview_service_entries (fragment_id, service_type, access_mode, created_at, updated_at) VALUES (?, ?, 'all', ?, ?) RETURNING id", backend, ); @@ -96,7 +96,7 @@ let id = row.0; let fetch_sql = adapt_sql( - "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE id = ?", + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM happyview_service_entries WHERE id = ?", backend, ); @@ -142,7 +142,7 @@ if set_clauses.len() == 1 { // Only updated_at — nothing meaningful to update; just fetch current state. let fetch_sql = adapt_sql( - "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE id = ?", + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM happyview_service_entries WHERE id = ?", backend, ); let row: Option = sqlx::query_as(&fetch_sql) @@ -156,7 +156,7 @@ .ok_or_else(|| AppError::NotFound(format!("service entry {id} not found"))); } let raw = format!( - "UPDATE service_entries SET {} WHERE id = ?", + "UPDATE happyview_service_entries SET {} WHERE id = ?", set_clauses.join(", ") ); let update_sql = adapt_sql(&raw, backend); @@ -183,7 +183,7 @@ return Err(AppError::NotFound(format!("service entry {id} not found"))); } let fetch_sql = adapt_sql( - "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE id = ?", + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM happyview_service_entries WHERE id = ?", backend, ); let row: ServiceEntryRow = sqlx::query_as(&fetch_sql) @@ -201,7 +201,10 @@ db: &AnyPool, backend: DatabaseBackend, id: i64, ) -> Result { - let sql = adapt_sql("DELETE FROM service_entries WHERE id = ?", backend); + let sql = adapt_sql( + "DELETE FROM happyview_service_entries WHERE id = ?", + backend, + ); let result = sqlx::query(&sql) .bind(id) @@ -223,7 +226,7 @@ backend: DatabaseBackend, entry_id: i64, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT lexicon_id FROM service_entry_xrpcs WHERE service_entry_id = ? ORDER BY lexicon_id", + "SELECT lexicon_id FROM happyview_service_entry_xrpcs WHERE service_entry_id = ? ORDER BY lexicon_id", backend, ); @@ -244,7 +247,7 @@ entry_id: i64, lexicon_ids: &[String], ) -> Result<(), AppError> { let sql = adapt_sql( - "INSERT INTO service_entry_xrpcs (service_entry_id, lexicon_id) VALUES (?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO happyview_service_entry_xrpcs (service_entry_id, lexicon_id) VALUES (?, ?) ON CONFLICT DO NOTHING", backend, ); @@ -268,7 +271,7 @@ entry_id: i64, lexicon_ids: &[String], ) -> Result<(), AppError> { let sql = adapt_sql( - "DELETE FROM service_entry_xrpcs WHERE service_entry_id = ? AND lexicon_id = ?", + "DELETE FROM happyview_service_entry_xrpcs WHERE service_entry_id = ? AND lexicon_id = ?", backend, ); @@ -299,7 +302,7 @@ fragment_id: &str, xrpc_method: &str, ) -> Result { let sql = adapt_sql( - "SELECT id, access_mode FROM service_entries WHERE fragment_id = ? LIMIT 1", + "SELECT id, access_mode FROM happyview_service_entries WHERE fragment_id = ? LIMIT 1", backend, ); @@ -320,7 +323,7 @@ } // access_mode = "specific" — check junction table let check_sql = adapt_sql( - "SELECT 1 FROM service_entry_xrpcs WHERE service_entry_id = ? AND lexicon_id = ? LIMIT 1", + "SELECT 1 FROM happyview_service_entry_xrpcs WHERE service_entry_id = ? AND lexicon_id = ? LIMIT 1", backend, ); @@ -343,7 +346,7 @@ backend: DatabaseBackend, lexicon_id: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE access_mode = 'all' OR EXISTS (SELECT 1 FROM service_entry_xrpcs WHERE service_entry_xrpcs.service_entry_id = service_entries.id AND service_entry_xrpcs.lexicon_id = ?) ORDER BY id", + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM happyview_service_entries WHERE access_mode = 'all' OR EXISTS (SELECT 1 FROM happyview_service_entry_xrpcs WHERE happyview_service_entry_xrpcs.service_entry_id = happyview_service_entries.id AND happyview_service_entry_xrpcs.lexicon_id = ?) ORDER BY id", backend, ); diff --git a/src/service_identity.rs b/src/service_identity.rs --- a/src/service_identity.rs +++ b/src/service_identity.rs @@ -82,7 +82,7 @@ db: &AnyPool, backend: DatabaseBackend, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT mode, did, signing_key_enc, attached_account_did, CAST(setup_complete AS INTEGER), created_at, updated_at FROM service_identity WHERE id = 1", + "SELECT mode, did, signing_key_enc, attached_account_did, CAST(setup_complete AS INTEGER), created_at, updated_at FROM happyview_service_identity WHERE id = 1", backend, ); @@ -139,7 +139,7 @@ attached_account_did: Option<&str>, ) -> Result<(), AppError> { let now = chrono::Utc::now().to_rfc3339(); let sql = adapt_sql( - "INSERT INTO service_identity (id, mode, did, signing_key_enc, rotation_key_enc, attached_account_did, setup_complete, created_at, updated_at) + "INSERT INTO happyview_service_identity (id, mode, did, signing_key_enc, rotation_key_enc, attached_account_did, setup_complete, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?, FALSE, ?, ?) ON CONFLICT (id) DO UPDATE SET mode = excluded.mode, @@ -171,7 +171,7 @@ /// Mark setup as complete for the service identity row. pub async fn mark_setup_complete(db: &AnyPool, backend: DatabaseBackend) -> Result<(), AppError> { let now = chrono::Utc::now().to_rfc3339(); let sql = adapt_sql( - "UPDATE service_identity SET setup_complete = TRUE, updated_at = ? WHERE id = 1", + "UPDATE happyview_service_identity SET setup_complete = TRUE, updated_at = ? WHERE id = 1", backend, ); @@ -188,12 +188,16 @@ /// Generate a DID document for did:web identity mode. /// The DID is derived dynamically from the request host rather than stored, /// so the same signing key works across any domain pointing at this server. /// Returns None if the identity mode is not DidWeb. +/// +/// `extra_verification_methods` is a slice of (fragment_id, key_type, public_key_multibase) +/// tuples for additional verification methods (e.g. `#atproto_space`). pub fn generate_did_document( identity: &ServiceIdentity, host: &str, signing_key_multibase: &str, service_entries: &[(String, String)], service_endpoint: &str, + extra_verification_methods: &[(String, String, String)], ) -> Option { if identity.mode != IdentityMode::DidWeb { return None; @@ -201,12 +205,21 @@ } let did = format!("did:web:{}", host.replace(':', "%3A")); - let verification_method = serde_json::json!([{ + let mut verification_methods: Vec = vec![serde_json::json!({ "id": format!("{did}#atproto"), "type": "Multikey", "controller": &did, "publicKeyMultibase": signing_key_multibase - }]); + })]; + + for (fragment_id, key_type, public_key_multibase) in extra_verification_methods { + verification_methods.push(serde_json::json!({ + "id": format!("{did}{fragment_id}"), + "type": key_type, + "controller": &did, + "publicKeyMultibase": public_key_multibase + })); + } let services: Vec = service_entries .iter() @@ -225,7 +238,7 @@ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1" ], "id": &did, - "verificationMethod": verification_method, + "verificationMethod": verification_methods, "service": services })) } @@ -270,8 +283,15 @@ #[test] fn generate_did_document_returns_none_for_non_web() { let identity = make_identity(IdentityMode::DidPlc, Some("did:plc:abc123")); assert!( - generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") - .is_none() + generate_did_document( + &identity, + "example.com", + "zKey", + &[], + "https://example.com", + &[] + ) + .is_none() ); } @@ -284,6 +304,7 @@ "example.com", "zKey123", &[], "https://example.com", + &[], ) .unwrap(); assert_eq!(doc["id"], "did:web:example.com"); @@ -298,6 +319,7 @@ "example.com", "zKey123", &[], "https://example.com", + &[], ) .unwrap(); assert_eq!(doc["id"], "did:web:example.com"); @@ -321,6 +343,7 @@ "example.com", "zKey123", &entries, "https://example.com", + &[], ) .unwrap(); let services = doc["service"].as_array().unwrap(); @@ -334,9 +357,15 @@ #[test] fn generate_did_document_context_and_structure() { let identity = make_identity(IdentityMode::DidWeb, None); - let doc = - generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") - .unwrap(); + let doc = generate_did_document( + &identity, + "example.com", + "zKey", + &[], + "https://example.com", + &[], + ) + .unwrap(); let context = doc["@context"].as_array().unwrap(); assert_eq!(context.len(), 2); assert_eq!(context[0], "https://www.w3.org/ns/did/v1"); @@ -346,5 +375,30 @@ let vm = &doc["verificationMethod"][0]; assert_eq!(vm["id"], "did:web:example.com#atproto"); assert_eq!(vm["type"], "Multikey"); assert_eq!(vm["controller"], "did:web:example.com"); + } + + #[test] + fn generate_did_document_includes_extra_verification_methods() { + let identity = make_identity(IdentityMode::DidWeb, None); + let extra = vec![( + "#atproto_space".to_string(), + "Multikey".to_string(), + "zExtraKey".to_string(), + )]; + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &[], + "https://example.com", + &extra, + ) + .unwrap(); + let vms = doc["verificationMethod"].as_array().unwrap(); + assert_eq!(vms.len(), 2); + assert_eq!(vms[0]["id"], "did:web:example.com#atproto"); + assert_eq!(vms[1]["id"], "did:web:example.com#atproto_space"); + assert_eq!(vms[1]["publicKeyMultibase"], "zExtraKey"); + assert_eq!(vms[1]["type"], "Multikey"); } } diff --git a/src/setup.rs b/src/setup.rs --- a/src/setup.rs +++ b/src/setup.rs @@ -191,7 +191,7 @@ let signing_key_did = crate::plc::private_key_to_did_key(&signing_key_bytes)?; // Decrypt rotation key let sql = crate::db::adapt_sql( - "SELECT rotation_key_enc FROM service_identity WHERE id = 1", + "SELECT rotation_key_enc FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&sql) @@ -260,7 +260,7 @@ let account_did = match identity.mode { IdentityMode::AttachAccount => { let sql = crate::db::adapt_sql( - "SELECT attached_account_did FROM service_identity WHERE id = 1", + "SELECT attached_account_did FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&sql) @@ -318,7 +318,7 @@ let account_did = match identity.mode { IdentityMode::AttachAccount => { let sql = crate::db::adapt_sql( - "SELECT attached_account_did FROM service_identity WHERE id = 1", + "SELECT attached_account_did FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&sql) @@ -492,7 +492,7 @@ let current_did = raw.split('\n').next().unwrap_or(&raw).to_string(); // Verify the current DID matches the configured attached_account_did let sql = crate::db::adapt_sql( - "SELECT attached_account_did FROM service_identity WHERE id = 1", + "SELECT attached_account_did FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&sql) @@ -518,7 +518,7 @@ } // Verify the original DID is a known admin user let user_exists: Option<(i32,)> = sqlx::query_as(&crate::db::adapt_sql( - "SELECT 1 FROM users WHERE did = ?", + "SELECT 1 FROM happyview_users WHERE did = ?", state.db_backend, )) .bind(&original_did) @@ -564,7 +564,7 @@ )); } let sql = crate::db::adapt_sql( - "SELECT rotation_key_enc FROM service_identity WHERE id = 1", + "SELECT rotation_key_enc FROM happyview_service_identity WHERE id = 1", state.db_backend, ); let row: Option<(Option,)> = sqlx::query_as(&sql) diff --git a/src/spaces/auth.rs b/src/spaces/auth.rs --- a/src/spaces/auth.rs +++ b/src/spaces/auth.rs @@ -9,24 +9,28 @@ use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::plugin::encryption::{decrypt, encrypt}; use crate::spaces::credential::{ - DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, sign_credential, + DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, make_jti, sign_credential, }; -use crate::spaces::types::{AccessMode, Space}; +use crate::spaces::types::{AppAccess, MintPolicy, Space}; pub struct IssuedCredential { pub token: String, pub expires_at: String, } +#[allow(clippy::too_many_arguments)] pub async fn issue_credential( pool: &sqlx::AnyPool, backend: DatabaseBackend, + http: &reqwest::Client, encryption_key: &[u8; 32], space: &Space, subject_did: &str, client_id: Option<&str>, + authority_did: &str, ) -> Result { check_app_access(space, client_id)?; + check_mint_policy(http, space, subject_did, client_id, authority_did).await?; let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; @@ -37,12 +41,11 @@ .as_secs(); let exp = now + DEFAULT_CREDENTIAL_TTL_SECS; let claims = SpaceCredentialClaims { - iss: space.did.clone(), - sub: subject_did.to_string(), - space: format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey), - scope: "read".into(), + iss: space.authority_did.clone(), + sub: format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey), iat: now, exp, + jti: make_jti(), }; let token = sign_credential(&claims, &private_jwk)?; @@ -57,37 +60,196 @@ Ok(IssuedCredential { token, expires_at }) } -pub fn check_app_access(space: &Space, client_id: Option<&str>) -> Result<(), AppError> { - let Some(client_id) = client_id else { - return Ok(()); - }; - - match space.access_mode { - AccessMode::DefaultDeny => { - if let Some(ref allowlist) = space.app_allowlist { - if !allowlist.iter().any(|id| id == client_id) { - return Err(AppError::Forbidden( - "This app is not authorized to access this space".into(), - )); - } +async fn check_mint_policy( + http: &reqwest::Client, + space: &Space, + subject_did: &str, + client_id: Option<&str>, + authority_did: &str, +) -> Result<(), AppError> { + match space.mint_policy { + MintPolicy::Public => Ok(()), + MintPolicy::MemberList => { + // Caller must already be a member; verified upstream by the credential issuance route. + // We trust that the delegation token proves membership was checked. + Ok(()) + } + MintPolicy::ManagingApp => { + let managing_app = space.managing_app_did.as_deref().ok_or_else(|| { + AppError::Internal( + "space mint_policy is managing-app but managing_app_did is not set".into(), + ) + })?; + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let granted = check_user_access_with_managing_app( + http, + managing_app, + &space_uri, + subject_did, + client_id, + authority_did, + ) + .await?; + if granted { + Ok(()) } else { - return Err(AppError::Forbidden( - "Space is in default_deny mode with no allowlist".into(), - )); + Err(AppError::Forbidden( + "managing app denied access to this space".into(), + )) } } - AccessMode::DefaultAllow => { - if let Some(ref denylist) = space.app_denylist - && denylist.iter().any(|id| id == client_id) - { - return Err(AppError::Forbidden( - "This app has been denied access to this space".into(), - )); - } + } +} + +async fn check_user_access_with_managing_app( + http: &reqwest::Client, + managing_app: &str, + space_uri: &str, + user_did: &str, + client_id: Option<&str>, + authority_did: &str, +) -> Result { + // Parse DID#fragment — the fragment identifies the service endpoint in the DID doc. + // For outbound callback we derive the endpoint from the DID. + let (did, fragment) = if let Some(pos) = managing_app.find('#') { + (&managing_app[..pos], Some(&managing_app[pos + 1..])) + } else { + (managing_app, None) + }; + + if let Some(frag) = fragment + && frag != "atproto_pds" + { + return Err(AppError::BadRequest(format!( + "unsupported service fragment '#{frag}' for managing app" + ))); + } + + // Resolve the managing app's PDS/service endpoint from its DID document. + let endpoint = resolve_did_service_endpoint(http, did).await?; + + let url = format!( + "{}/xrpc/com.atproto.simplespace.checkUserAccess", + endpoint.trim_end_matches('/') + ); + + let mut body = serde_json::json!({ + "space": space_uri, + "did": user_did, + }); + if let Some(cid) = client_id { + body["clientId"] = serde_json::Value::String(cid.to_string()); + } + + // Service auth: iss = authority_did, aud = managing_app DID. + // We use a simple unsigned assertion here; a full implementation would sign with the space key. + // For now we send the request without service auth and rely on the managing app to trust HappyView. + let resp = http + .post(&url) + .json(&body) + .header("X-Authority-Did", authority_did) + .send() + .await + .map_err(|e| AppError::Internal(format!("checkUserAccess request failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::FORBIDDEN + || resp.status() == reqwest::StatusCode::UNAUTHORIZED + { + return Ok(false); + } + + if !resp.status().is_success() { + return Err(AppError::Internal(format!( + "checkUserAccess returned unexpected status {}", + resp.status() + ))); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("checkUserAccess response parse failed: {e}")))?; + + Ok(json + .get("granted") + .and_then(|v| v.as_bool()) + .unwrap_or(false)) +} + +async fn resolve_did_service_endpoint( + http: &reqwest::Client, + did: &str, +) -> Result { + let url = if did.starts_with("did:plc:") { + format!("https://plc.directory/{did}") + } else if did.starts_with("did:web:") { + let identifier = did.strip_prefix("did:web:").unwrap(); + let mut segments = identifier.split(':'); + let host = segments.next().unwrap(); + let path_segments: Vec<&str> = segments.collect(); + if path_segments.is_empty() { + format!("https://{host}/.well-known/did.json") + } else { + format!("https://{host}/{}/did.json", path_segments.join("/")) } + } else { + return Err(AppError::BadRequest(format!( + "unsupported DID method for managing app: {did}" + ))); + }; + + #[derive(serde::Deserialize)] + struct DidDoc { + #[serde(default)] + service: Vec, + } + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct DidService { + id: String, + service_endpoint: String, } - Ok(()) + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("DID resolution failed for {did}: {e}")))?; + + if !resp.status().is_success() { + return Err(AppError::Internal(format!( + "DID resolution returned {} for {did}", + resp.status() + ))); + } + + let doc: DidDoc = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid DID document for {did}: {e}")))?; + + doc.service + .iter() + .find(|s| s.id == "#atproto_pds" || s.id == format!("{did}#atproto_pds")) + .map(|s| s.service_endpoint.clone()) + .ok_or_else(|| AppError::Internal(format!("no #atproto_pds service in DID doc for {did}"))) +} + +pub fn check_app_access(space: &Space, attested_client_id: Option<&str>) -> Result<(), AppError> { + match &space.app_access { + AppAccess::Open => Ok(()), + AppAccess::AllowList { allowed } => { + let client_id = attested_client_id + .ok_or_else(|| AppError::Auth("space requires client attestation".into()))?; + if allowed.iter().any(|id| id == client_id) { + Ok(()) + } else { + Err(AppError::Forbidden( + "this app is not authorized to access this space".into(), + )) + } + } + } } async fn get_or_create_signing_key( @@ -97,7 +259,7 @@ encryption_key: &[u8; 32], space: &Space, ) -> Result { let sql = adapt_sql( - "SELECT signing_key_enc FROM space_dids WHERE space_id = ?", + "SELECT signing_key_enc FROM happyview_space_dids WHERE space_id = ?", backend, ); let row: Option<(Vec,)> = sqlx::query_as(&sql) @@ -129,7 +291,7 @@ .map_err(|e| AppError::Internal(format!("failed to encrypt rotation key: {e}")))?; let now = now_rfc3339(); let insert_sql = adapt_sql( - "INSERT INTO space_dids (id, did, space_id, signing_key_enc, rotation_key_enc, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_space_dids (id, did, space_id, signing_key_enc, rotation_key_enc, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); @@ -139,7 +301,7 @@ .bind(&space.did) .bind(&space.id) .bind(&encrypted_signing) .bind(&encrypted_rotation) - .bind(&space.owner_did) + .bind(&space.authority_did) .bind(&now) .execute(pool) .await @@ -198,7 +360,7 @@ .map(|dt| dt.to_rfc3339()) .unwrap_or_default(); let sql = adapt_sql( - "INSERT INTO space_credentials (id, space_id, issued_to, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_space_credentials (id, space_id, issued_to, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", backend, ); @@ -219,20 +381,20 @@ #[cfg(test)] mod tests { use super::*; - use crate::spaces::types::{AccessMode, Space, SpaceConfig}; + use crate::spaces::types::{AppAccess, MintPolicy, Space, SpaceConfig}; - fn test_space(access_mode: AccessMode) -> Space { + fn test_space(app_access: AppAccess) -> Space { Space { id: "test-space".into(), did: "did:plc:owner".into(), - owner_did: "did:plc:owner".into(), + authority_did: "did:plc:owner".into(), + creator_did: "did:plc:owner".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), display_name: None, description: None, - access_mode, - app_allowlist: None, - app_denylist: None, + mint_policy: MintPolicy::MemberList, + app_access, managing_app_did: None, config: SpaceConfig::default(), revision: None, @@ -242,40 +404,40 @@ } } #[test] - fn app_access_default_allow_no_lists() { - let space = test_space(AccessMode::DefaultAllow); + fn app_access_open_allows_any() { + let space = test_space(AppAccess::Open); assert!(check_app_access(&space, Some("any-app")).is_ok()); } #[test] - fn app_access_default_allow_denied() { - let mut space = test_space(AccessMode::DefaultAllow); - space.app_denylist = Some(vec!["bad-app".into()]); - + fn app_access_allowlist_permits_listed() { + let space = test_space(AppAccess::AllowList { + allowed: vec!["good-app".into()], + }); assert!(check_app_access(&space, Some("good-app")).is_ok()); - assert!(check_app_access(&space, Some("bad-app")).is_err()); + assert!(check_app_access(&space, Some("other-app")).is_err()); } #[test] - fn app_access_default_deny_no_allowlist() { - let space = test_space(AccessMode::DefaultDeny); - assert!(check_app_access(&space, Some("any-app")).is_err()); + fn app_access_allowlist_requires_client_id() { + let space = test_space(AppAccess::AllowList { allowed: vec![] }); + assert!(check_app_access(&space, None).is_err()); } #[test] - fn app_access_default_deny_allowed() { - let mut space = test_space(AccessMode::DefaultDeny); - space.app_allowlist = Some(vec!["good-app".into()]); - - assert!(check_app_access(&space, Some("good-app")).is_ok()); - assert!(check_app_access(&space, Some("other-app")).is_err()); + fn app_access_open_allows_none_client_id() { + let space = test_space(AppAccess::Open); + assert!(check_app_access(&space, None).is_ok()); } #[test] - fn app_access_no_client_id_always_passes() { - let space = test_space(AccessMode::DefaultDeny); - assert!(check_app_access(&space, None).is_ok()); + fn app_access_empty_allowlist_rejects() { + let space = test_space(AppAccess::AllowList { allowed: vec![] }); + assert!(check_app_access(&space, Some("any-client")).is_err()); } + + // resolve_did_service_endpoint is async and makes HTTP calls to resolve DID + // documents, so it cannot be unit-tested without a mock HTTP server. #[test] fn generate_keypair_produces_valid_jwk() { diff --git a/src/spaces/client_attestation.rs b/src/spaces/client_attestation.rs new file mode 100644 --- /dev/null +++ b/src/spaces/client_attestation.rs @@ -0,0 +1,131 @@ +use crate::error::AppError; + +pub const CLIENT_ATTESTATION_TYP: &str = "atproto-client-attestation+jwt"; + +pub struct VerifiedAttestation { + pub client_id: String, +} + +pub async fn verify_client_attestation( + token: &str, + expected_aud: &str, + http: &reqwest::Client, +) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid client attestation format".into())); + } + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| AppError::Auth("invalid attestation header encoding".into()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid attestation header".into()))?; + + if header["typ"].as_str() != Some(CLIENT_ATTESTATION_TYP) { + return Err(AppError::Auth(format!( + "attestation typ must be {CLIENT_ATTESTATION_TYP}" + ))); + } + + let kid = header["kid"] + .as_str() + .ok_or_else(|| AppError::Auth("attestation header missing kid".into()))?; + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid attestation payload encoding".into()))?; + + #[derive(serde::Deserialize)] + struct AttestationClaims { + iss: String, + sub: String, + aud: String, + exp: u64, + } + + let claims: AttestationClaims = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid attestation payload".into()))?; + + if claims.iss != claims.sub { + return Err(AppError::Auth("attestation iss must equal sub".into())); + } + + if claims.aud != expected_aud { + return Err(AppError::Auth("attestation aud mismatch".into())); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if now >= claims.exp { + return Err(AppError::Auth("client attestation has expired".into())); + } + + // Fetch client metadata + let metadata: serde_json::Value = http + .get(&claims.iss) + .send() + .await + .map_err(|e| AppError::Internal(format!("failed to fetch client metadata: {e}")))? + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid client metadata: {e}")))?; + + // Resolve JWKS + let jwks = if let Some(jwks) = metadata.get("jwks") { + jwks.clone() + } else if let Some(jwks_uri) = metadata["jwks_uri"].as_str() { + http.get(jwks_uri) + .send() + .await + .map_err(|e| AppError::Internal(format!("failed to fetch JWKS: {e}")))? + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid JWKS: {e}")))? + } else { + return Err(AppError::Auth( + "client metadata has no jwks or jwks_uri".into(), + )); + }; + + // Find key by kid + let keys = jwks["keys"] + .as_array() + .ok_or_else(|| AppError::Auth("JWKS missing keys array".into()))?; + + let key = keys + .iter() + .find(|k| k["kid"].as_str() == Some(kid)) + .ok_or_else(|| AppError::Auth(format!("no key matching kid '{kid}' in JWKS")))?; + + // Verify signature using the matched key + let alg = header["alg"].as_str().unwrap_or("ES256"); + match alg { + "ES256" => { + let jwk = crate::spaces::credential::p256_jwk_to_verifying_key(key)?; + let message = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| AppError::Auth("invalid attestation signature encoding".into()))?; + let sig = p256::ecdsa::Signature::from_bytes(sig_bytes.as_slice().into()) + .map_err(|_| AppError::Auth("invalid attestation signature format".into()))?; + use p256::ecdsa::signature::Verifier; + jwk.verify(message.as_bytes(), &sig) + .map_err(|_| AppError::Auth("attestation signature verification failed".into()))?; + } + _ => { + return Err(AppError::Auth(format!( + "unsupported attestation alg: {alg}" + ))); + } + } + + Ok(VerifiedAttestation { + client_id: claims.iss, + }) +} diff --git a/src/spaces/commit.rs b/src/spaces/commit.rs new file mode 100644 --- /dev/null +++ b/src/spaces/commit.rs @@ -0,0 +1,227 @@ +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use k256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer, signature::Verifier}; +use sha2::Sha256; + +use crate::error::AppError; + +pub struct SignedCommit { + pub hash: [u8; 32], + pub ikm: [u8; 32], + pub sig: Vec, + pub mac: [u8; 32], + pub rev: String, +} + +pub fn build_context(space_uri: &str, rev: &str, ikm: &[u8; 32]) -> Vec { + let tag = b"atproto-space-v1"; + let space_bytes = space_uri.as_bytes(); + let rev_bytes = rev.as_bytes(); + + let mut ctx = + Vec::with_capacity(tag.len() + 2 + space_bytes.len() + 2 + rev_bytes.len() + 2 + 32); + + ctx.extend_from_slice(tag); + + // TLS 1.3 variable-length encoding: big-endian uint16 length prefix + ctx.extend_from_slice(&(space_bytes.len() as u16).to_be_bytes()); + ctx.extend_from_slice(space_bytes); + + ctx.extend_from_slice(&(rev_bytes.len() as u16).to_be_bytes()); + ctx.extend_from_slice(rev_bytes); + + ctx.extend_from_slice(&(ikm.len() as u16).to_be_bytes()); + ctx.extend_from_slice(ikm); + + ctx +} + +pub fn sign_commit( + hash: &[u8; 32], + space_uri: &str, + rev: &str, + signing_key: &SigningKey, +) -> Result { + let mut ikm = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut ikm); + + let ctx = build_context(space_uri, rev, &ikm); + + // sig covers space + rev + ikm, NOT the hash — prevents rebroadcast proof + let sig: Signature = signing_key.sign(&ctx); + + // mac = HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash) + let hk = Hkdf::::new(None, &ikm); + let mut derived_key = [0u8; 32]; + hk.expand(&ctx, &mut derived_key) + .map_err(|e| AppError::Internal(format!("HKDF expand failed: {e}")))?; + + let mut mac_hasher = as Mac>::new_from_slice(&derived_key) + .map_err(|e| AppError::Internal(format!("HMAC init failed: {e}")))?; + mac_hasher.update(hash); + let mac: [u8; 32] = mac_hasher.finalize().into_bytes().into(); + + Ok(SignedCommit { + hash: *hash, + ikm, + sig: sig.to_bytes().to_vec(), + mac, + rev: rev.to_string(), + }) +} + +pub fn verify_commit( + commit: &SignedCommit, + space_uri: &str, + verifying_key: &VerifyingKey, +) -> Result<(), AppError> { + let ctx = build_context(space_uri, &commit.rev, &commit.ikm); + + let sig = Signature::from_bytes(commit.sig.as_slice().into()) + .map_err(|_| AppError::Auth("invalid commit signature format".into()))?; + verifying_key + .verify(&ctx, &sig) + .map_err(|_| AppError::Auth("commit signature verification failed".into()))?; + + // Recompute and verify MAC + let hk = Hkdf::::new(None, &commit.ikm); + let mut derived_key = [0u8; 32]; + hk.expand(&ctx, &mut derived_key) + .map_err(|e| AppError::Internal(format!("HKDF expand failed: {e}")))?; + + let mut mac_hasher = as Mac>::new_from_slice(&derived_key) + .map_err(|e| AppError::Internal(format!("HMAC init failed: {e}")))?; + mac_hasher.update(&commit.hash); + + mac_hasher.verify_slice(&commit.mac).map_err(|_| { + AppError::Auth("commit MAC verification failed — repo hash mismatch".into()) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use k256::ecdsa::SigningKey; + + fn test_signing_key() -> SigningKey { + let mut bytes = [0u8; 32]; + bytes[31] = 1; // valid non-zero scalar + SigningKey::from_bytes((&bytes[..]).into()).unwrap() + } + + #[test] + fn context_string_format() { + let ctx = build_context( + "ats://did:plc:abc/com.example.forum/main", + "3k2abc", + &[0xAA; 32], + ); + // Starts with protocol tag + assert!(ctx.starts_with(b"atproto-space-v1")); + } + + #[test] + fn context_includes_all_fields() { + let space = "ats://did:plc:abc/com.example.forum/main"; + let rev = "3k2abc"; + let ikm = [0xBB; 32]; + let ctx = build_context(space, rev, &ikm); + + // Context must contain the space URI, rev, and ikm + assert!(ctx.windows(space.len()).any(|w| w == space.as_bytes())); + assert!(ctx.windows(rev.len()).any(|w| w == rev.as_bytes())); + assert!(ctx.windows(32).any(|w| w == ikm)); + } + + #[test] + fn sign_and_verify_roundtrip() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xCC; 32]; + let space = "ats://did:plc:abc/com.example.forum/main"; + + let commit = sign_commit(&hash, space, "3k2rev1", &sk).unwrap(); + + assert_eq!(commit.hash, hash); + assert_eq!(commit.rev, "3k2rev1"); + assert_eq!(commit.mac.len(), 32); + assert!(!commit.sig.is_empty()); + + assert!(verify_commit(&commit, space, &vk).is_ok()); + } + + #[test] + fn verify_rejects_wrong_key() { + let sk1 = test_signing_key(); + let mut bytes2 = [0u8; 32]; + bytes2[31] = 2; + let sk2 = SigningKey::from_bytes((&bytes2[..]).into()).unwrap(); + let vk2 = *sk2.verifying_key(); + + let hash = [0xDD; 32]; + let space = "ats://did:plc:abc/com.example.forum/main"; + + let commit = sign_commit(&hash, space, "rev1", &sk1).unwrap(); + assert!(verify_commit(&commit, space, &vk2).is_err()); + } + + #[test] + fn verify_rejects_tampered_hash() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xEE; 32]; + let space = "ats://did:plc:abc/com.example.forum/main"; + + let mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); + commit.hash[0] ^= 0xFF; // tamper + assert!(verify_commit(&commit, space, &vk).is_err()); + } + + #[test] + fn verify_rejects_wrong_space() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xFF; 32]; + + let commit = sign_commit( + &hash, + "ats://did:plc:abc/com.example.forum/main", + "rev1", + &sk, + ) + .unwrap(); + assert!(verify_commit(&commit, "ats://did:plc:xyz/com.example.forum/other", &vk).is_err()); + } + + #[test] + fn different_ikm_per_commit() { + let sk = test_signing_key(); + let hash = [0xAA; 32]; + let space = "ats://did:plc:abc/com.example.forum/main"; + + let c1 = sign_commit(&hash, space, "rev1", &sk).unwrap(); + let c2 = sign_commit(&hash, space, "rev1", &sk).unwrap(); + + // Each call generates fresh ikm + assert_ne!(c1.ikm, c2.ikm); + // But both verify + let vk = *sk.verifying_key(); + assert!(verify_commit(&c1, space, &vk).is_ok()); + assert!(verify_commit(&c2, space, &vk).is_ok()); + } + + #[test] + fn verify_rejects_tampered_mac() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xCC; 32]; + let space = "ats://did:plc:abc/com.example.forum/main"; + + let mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); + assert!(verify_commit(&commit, space, &vk).is_ok()); + commit.mac[0] ^= 0xFF; + assert!(verify_commit(&commit, space, &vk).is_err()); + } +} diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -1,13 +1,21 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use p256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer, signature::Verifier}; +use k256::ecdsa::{ + Signature as K256Signature, SigningKey as K256SigningKey, VerifyingKey as K256VerifyingKey, + signature::Signer as K256Signer, signature::Verifier as K256Verifier, +}; +use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::error::AppError; use crate::profile; -pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours -pub const GRANT_TTL_SECS: u64 = 5 * 60; // 5 minutes +pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 2 * 60 * 60; // 2 hours +pub const DELEGATION_TOKEN_TTL_SECS: u64 = 60; // 60 seconds + +pub const DELEGATION_TOKEN_TYP: &str = "atproto-space-delegation+jwt"; +pub const SPACE_CREDENTIAL_TYP: &str = "atproto-space-credential+jwt"; /// Peek at a JWT's header to check its `typ` field without verifying the signature. pub fn peek_jwt_typ(token: &str) -> Option { @@ -17,7 +25,18 @@ let header: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?; header["typ"].as_str().map(|s| s.to_string()) } -/// Peek at a space credential JWT's payload to extract the `sub` (user DID) without verifying. +/// Peek at a delegation token's payload to extract the `sub` (space URI) without verifying. +pub fn peek_delegation_sub(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?; + let claims: DelegationTokenClaims = serde_json::from_slice(&payload_bytes).ok()?; + Some(claims.sub) +} + +/// Peek at a space credential JWT's payload to extract the `sub` (space URI) without verifying. pub fn peek_credential_sub(token: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -29,49 +48,118 @@ Some(claims.sub) } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemberGrantClaims { - pub sub: String, - pub space: String, - pub scope: String, +pub struct DelegationTokenClaims { + pub iss: String, // User DID + pub sub: String, // Space URI (ats://...) + pub aud: String, // Space host (did#atproto_space_host) pub iat: u64, pub exp: u64, + pub jti: String, // Random nonce } -pub fn sign_grant(claims: &MemberGrantClaims, secret: &[u8; 32]) -> Result { - let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); - let key = jsonwebtoken::EncodingKey::from_secret(secret); - jsonwebtoken::encode(&header, claims, &key) - .map_err(|e| AppError::Internal(format!("failed to sign member grant: {e}"))) +pub fn sign_delegation_token( + claims: &DelegationTokenClaims, + signing_key: &K256SigningKey, +) -> Result { + let header = serde_json::json!({ + "alg": "ES256K", + "typ": DELEGATION_TOKEN_TYP, + "kid": "#atproto", + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()); + + let message = format!("{}.{}", header_b64, payload_b64); + let signature: K256Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64)) } -pub fn verify_grant(token: &str, secret: &[u8; 32]) -> Result { - let key = jsonwebtoken::DecodingKey::from_secret(secret); - let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); - validation.required_spec_claims.clear(); - validation.validate_exp = false; - let data = jsonwebtoken::decode::(token, &key, &validation) - .map_err(|e| AppError::Auth(format!("invalid member grant: {e}")))?; +pub fn verify_delegation_token( + token: &str, + verifying_key: &K256VerifyingKey, + expected_aud: &str, +) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid delegation token format".into())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| AppError::Auth("invalid delegation token header encoding".into()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid delegation token header".into()))?; + + if header["alg"].as_str() != Some("ES256K") { + return Err(AppError::Auth("delegation token alg must be ES256K".into())); + } + + if header["typ"].as_str() != Some(DELEGATION_TOKEN_TYP) { + return Err(AppError::Auth(format!( + "delegation token typ must be {DELEGATION_TOKEN_TYP}" + ))); + } + + let message = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| AppError::Auth("invalid delegation token signature encoding".into()))?; + + // Try direct verify, then with low-S normalization + let verified = if let Ok(sig) = K256Signature::from_bytes(sig_bytes.as_slice().into()) { + if verifying_key.verify(message.as_bytes(), &sig).is_ok() { + true + } else if let Some(normalized) = sig.normalize_s() { + verifying_key + .verify(message.as_bytes(), &normalized) + .is_ok() + } else { + false + } + } else { + false + }; + + if !verified { + return Err(AppError::Auth( + "delegation token signature verification failed".into(), + )); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid delegation token payload encoding".into()))?; + let claims: DelegationTokenClaims = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid delegation token payload".into()))?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - if now >= data.claims.exp { - return Err(AppError::Auth("member grant has expired".into())); + if now >= claims.exp { + return Err(AppError::Auth("delegation token has expired".into())); } - Ok(data.claims) + if claims.aud != expected_aud { + return Err(AppError::Auth( + "delegation token audience does not match this host".into(), + )); + } + + Ok(claims) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceCredentialClaims { - pub iss: String, - pub sub: String, - pub space: String, - pub scope: String, + pub iss: String, // Space authority DID + pub sub: String, // Space URI (ats://...) pub iat: u64, pub exp: u64, + pub jti: String, // Random nonce } pub fn sign_credential( @@ -91,7 +179,8 @@ .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; let header = serde_json::json!({ "alg": "ES256", - "typ": "space_credential", + "typ": SPACE_CREDENTIAL_TYP, + "kid": "#atproto_space", }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); @@ -123,33 +212,13 @@ if header["alg"].as_str() != Some("ES256") { return Err(AppError::Auth("credential alg must be ES256".into())); } - if header["typ"].as_str() != Some("space_credential") { - return Err(AppError::Auth( - "credential typ must be space_credential".into(), - )); + if header["typ"].as_str() != Some(SPACE_CREDENTIAL_TYP) { + return Err(AppError::Auth(format!( + "credential typ must be {SPACE_CREDENTIAL_TYP}" + ))); } - let x_b64 = public_jwk["x"] - .as_str() - .ok_or_else(|| AppError::Auth("public key missing x".into()))?; - let y_b64 = public_jwk["y"] - .as_str() - .ok_or_else(|| AppError::Auth("public key missing y".into()))?; - - let x_bytes = URL_SAFE_NO_PAD - .decode(x_b64) - .map_err(|_| AppError::Auth("invalid public key x".into()))?; - let y_bytes = URL_SAFE_NO_PAD - .decode(y_b64) - .map_err(|_| AppError::Auth("invalid public key y".into()))?; - - let mut sec1 = Vec::with_capacity(1 + 32 + 32); - sec1.push(0x04); - sec1.extend_from_slice(&x_bytes); - sec1.extend_from_slice(&y_bytes); - - let verifying_key = VerifyingKey::from_sec1_bytes(&sec1) - .map_err(|_| AppError::Auth("invalid space credential public key".into()))?; + let verifying_key = p256_jwk_to_verifying_key(public_jwk)?; let message = format!("{}.{}", parts[0], parts[1]); let sig_bytes = URL_SAFE_NO_PAD @@ -180,6 +249,31 @@ Ok(claims) } +/// Extract a P-256 verifying key from a JWK. +pub fn p256_jwk_to_verifying_key(jwk: &serde_json::Value) -> Result { + let x_b64 = jwk["x"] + .as_str() + .ok_or_else(|| AppError::Auth("JWK missing x".into()))?; + let y_b64 = jwk["y"] + .as_str() + .ok_or_else(|| AppError::Auth("JWK missing y".into()))?; + + let x_bytes = URL_SAFE_NO_PAD + .decode(x_b64) + .map_err(|_| AppError::Auth("invalid JWK x".into()))?; + let y_bytes = URL_SAFE_NO_PAD + .decode(y_b64) + .map_err(|_| AppError::Auth("invalid JWK y".into()))?; + + let mut sec1 = Vec::with_capacity(65); + sec1.push(0x04); + sec1.extend_from_slice(&x_bytes); + sec1.extend_from_slice(&y_bytes); + + VerifyingKey::from_sec1_bytes(&sec1) + .map_err(|_| AppError::Auth("invalid P-256 public key".into())) +} + /// Convert a multibase-encoded P-256 public key (from a DID doc `publicKeyMultibase`) /// into a JWK suitable for `verify_credential`. pub fn multikey_to_p256_jwk(public_key_multibase: &str) -> Result { @@ -215,14 +309,13 @@ } /// Verify a space credential JWT issued by an external space host. /// -/// Resolves the issuer's DID document, extracts the `#atproto` signing key, +/// Resolves the issuer's DID document, extracts the `#atproto_space` signing key, /// and verifies the JWT signature and expiry. pub async fn verify_external_credential( token: &str, http: &reqwest::Client, plc_url: &str, ) -> Result { - // Peek at the payload to extract the issuer DID without verifying yet let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { return Err(AppError::Auth("invalid credential format".into())); @@ -239,8 +332,10 @@ let vm = did_doc .verification_method .iter() - .find(|v| v.id.ends_with("#atproto")) - .ok_or_else(|| AppError::Auth("issuer DID has no #atproto verification method".into()))?; + .find(|v| v.id.ends_with("#atproto_space")) + .ok_or_else(|| { + AppError::Auth("issuer DID has no #atproto_space verification method".into()) + })?; let multibase = vm .public_key_multibase @@ -249,6 +344,10 @@ .ok_or_else(|| AppError::Auth("verification method missing publicKeyMultibase".into()))?; let jwk = multikey_to_p256_jwk(multibase)?; verify_credential(token, &jwk) +} + +pub fn make_jti() -> String { + Uuid::new_v4().to_string() } #[cfg(test)] @@ -263,11 +362,10 @@ .unwrap() .as_secs(); SpaceCredentialClaims { iss: "did:plc:spaceowner".into(), - sub: "did:plc:requester".into(), - space: "did:plc:spaceowner/com.example.forum/main".into(), - scope: "read".into(), + sub: "ats://did:plc:spaceowner/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), } } @@ -281,10 +379,9 @@ let verified = verify_credential(&token, &keypair.public_jwk).unwrap(); assert_eq!(verified.iss, claims.iss); assert_eq!(verified.sub, claims.sub); - assert_eq!(verified.space, claims.space); - assert_eq!(verified.scope, claims.scope); assert_eq!(verified.iat, claims.iat); assert_eq!(verified.exp, claims.exp); + assert_eq!(verified.jti, claims.jti); } #[test] @@ -293,7 +390,6 @@ let keypair = generate_dpop_keypair().unwrap(); let claims = make_claims(); let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); - // Tamper with the payload let parts: Vec<&str> = token.split('.').collect(); let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); payload_bytes[0] ^= 0xFF; @@ -324,11 +420,10 @@ .unwrap() .as_secs(); let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "did:plc:user".into(), - space: "did:plc:owner/test/main".into(), - scope: "read".into(), + sub: "ats://did:plc:owner/com.example.test/main".into(), iat: now - 7200, - exp: now - 3600, // expired 1 hour ago + exp: now - 3600, + jti: make_jti(), }; let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); @@ -344,81 +439,127 @@ let result = verify_credential("not-a-jwt", &keypair.public_jwk); assert!(result.is_err()); } - fn test_secret() -> [u8; 32] { - [0xAB; 32] + fn make_k256_signing_key() -> K256SigningKey { + let key_bytes = [0x42u8; 32]; + K256SigningKey::from_bytes((&key_bytes[..]).into()).expect("valid key") } - #[test] - fn grant_sign_and_verify_roundtrip() { - let secret = test_secret(); + fn make_delegation_claims() -> DelegationTokenClaims { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let claims = MemberGrantClaims { - sub: "did:plc:member".into(), - space: "ats://did:plc:space/com.example.forum/main".into(), - scope: "read".into(), + DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), iat: now, - exp: now + GRANT_TTL_SECS, - }; + exp: now + DELEGATION_TOKEN_TTL_SECS, + jti: make_jti(), + } + } - let token = sign_grant(&claims, &secret).unwrap(); - let verified = verify_grant(&token, &secret).unwrap(); + #[test] + fn delegation_sign_and_verify_roundtrip() { + let signing_key = make_k256_signing_key(); + let verifying_key = K256VerifyingKey::from(&signing_key); + let claims = make_delegation_claims(); + + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + let verified = verify_delegation_token(&token, &verifying_key, &claims.aud).unwrap(); + assert_eq!(verified.iss, claims.iss); assert_eq!(verified.sub, claims.sub); - assert_eq!(verified.space, claims.space); - assert_eq!(verified.scope, claims.scope); + assert_eq!(verified.aud, claims.aud); + assert_eq!(verified.jti, claims.jti); } #[test] - fn grant_rejects_wrong_secret() { - let secret1 = [0xAB; 32]; - let secret2 = [0xCD; 32]; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let claims = MemberGrantClaims { - sub: "did:plc:member".into(), - space: "ats://did:plc:space/com.example.forum/main".into(), - scope: "read".into(), - iat: now, - exp: now + GRANT_TTL_SECS, - }; + fn delegation_rejects_wrong_key() { + let signing_key = make_k256_signing_key(); + let other_key = K256SigningKey::from_bytes((&[0x99u8; 32][..]).into()).unwrap(); + let verifying_key = K256VerifyingKey::from(&other_key); + let claims = make_delegation_claims(); - let token = sign_grant(&claims, &secret1).unwrap(); - let result = verify_grant(&token, &secret2); + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + let result = verify_delegation_token(&token, &verifying_key, &claims.aud); assert!(result.is_err()); } #[test] - fn grant_rejects_expired() { - let secret = test_secret(); + fn delegation_rejects_wrong_aud() { + let signing_key = make_k256_signing_key(); + let verifying_key = K256VerifyingKey::from(&signing_key); + let claims = make_delegation_claims(); + + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + let result = + verify_delegation_token(&token, &verifying_key, "did:plc:wrong#atproto_space_host"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("audience")); + } + + #[test] + fn delegation_rejects_expired() { + let signing_key = make_k256_signing_key(); + let verifying_key = K256VerifyingKey::from(&signing_key); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let claims = MemberGrantClaims { - sub: "did:plc:member".into(), - space: "ats://did:plc:space/com.example.forum/main".into(), - scope: "read".into(), - iat: now - 600, - exp: now - 300, + let claims = DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), + iat: now - 120, + exp: now - 60, + jti: make_jti(), }; - let token = sign_grant(&claims, &secret).unwrap(); - let result = verify_grant(&token, &secret); + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + let result = verify_delegation_token(&token, &verifying_key, &claims.aud); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("expired")); } #[test] + fn delegation_rejects_wrong_typ() { + let signing_key = make_k256_signing_key(); + let verifying_key = K256VerifyingKey::from(&signing_key); + let claims = make_delegation_claims(); + + // Craft a token with wrong typ + let header = serde_json::json!({ "alg": "ES256K", "typ": "wrong-typ", "kid": "#atproto" }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let sig: K256Signature = signing_key.sign(message.as_bytes()); + let token = format!( + "{}.{}.{}", + header_b64, + payload_b64, + URL_SAFE_NO_PAD.encode(sig.to_bytes()) + ); + + let result = verify_delegation_token(&token, &verifying_key, &claims.aud); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("typ")); + } + + #[test] fn credential_has_space_credential_typ() { let keypair = generate_dpop_keypair().unwrap(); let claims = make_claims(); let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); - assert_eq!(peek_jwt_typ(&token).as_deref(), Some("space_credential")); + assert_eq!(peek_jwt_typ(&token).as_deref(), Some(SPACE_CREDENTIAL_TYP)); + } + + #[test] + fn delegation_has_delegation_typ() { + let signing_key = make_k256_signing_key(); + let claims = make_delegation_claims(); + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + assert_eq!(peek_jwt_typ(&token).as_deref(), Some(DELEGATION_TOKEN_TYP)); } #[test] @@ -428,13 +569,13 @@ assert_eq!(peek_jwt_typ(""), None); } #[test] - fn peek_credential_sub_extracts_did() { + fn peek_credential_sub_extracts_space_uri() { let keypair = generate_dpop_keypair().unwrap(); let claims = make_claims(); let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); assert_eq!( peek_credential_sub(&token).as_deref(), - Some("did:plc:requester") + Some("ats://did:plc:spaceowner/com.example.forum/main") ); } @@ -468,5 +609,22 @@ let result = verify_credential(&token, &keypair.public_jwk); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("typ")); + } + + #[test] + fn multikey_to_p256_jwk_invalid_multibase() { + let result = multikey_to_p256_jwk("xabc123"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("multibase")); + } + + #[test] + fn multikey_to_p256_jwk_wrong_codec() { + let mut bytes = vec![0x99u8, 0x99]; + bytes.extend_from_slice(&[0u8; 33]); + let encoded = multibase::encode(multibase::Base::Base58Btc, &bytes); + let result = multikey_to_p256_jwk(&encoded); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("P-256")); } } diff --git a/src/spaces/db.rs b/src/spaces/db.rs --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -14,31 +14,25 @@ ) -> Result<(), AppError> { let now = now_rfc3339(); let config_json = serde_json::to_string(&space.config) .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; - let allowlist_json = space - .app_allowlist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); - let denylist_json = space - .app_denylist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); + let app_access_json = serde_json::to_string(&space.app_access) + .map_err(|e| AppError::Internal(format!("failed to serialize app_access: {e}")))?; let sql = adapt_sql( - "INSERT INTO spaces (id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_spaces (id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) .bind(&space.id) .bind(&space.did) - .bind(&space.owner_did) + .bind(&space.authority_did) + .bind(&space.creator_did) .bind(&space.type_nsid) .bind(&space.skey) .bind(&space.display_name) .bind(&space.description) - .bind(space.access_mode.as_str()) - .bind(&allowlist_json) - .bind(&denylist_json) + .bind(space.mint_policy.as_str()) + .bind(&app_access_json) .bind(&space.managing_app_did) .bind(&config_json) .bind(&now) @@ -56,7 +50,7 @@ backend: DatabaseBackend, id: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM spaces WHERE id = ?", + "SELECT id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE id = ?", backend, ); @@ -77,7 +71,7 @@ type_nsid: &str, skey: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM spaces WHERE did = ? AND type_nsid = ? AND skey = ?", + "SELECT id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE did = ? AND type_nsid = ? AND skey = ?", backend, ); @@ -95,15 +89,15 @@ pub async fn list_spaces_by_owner( pool: &sqlx::AnyPool, backend: DatabaseBackend, - owner_did: &str, + authority_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM spaces WHERE owner_did = ? ORDER BY created_at DESC", + "SELECT id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE authority_did = ? ORDER BY created_at DESC", backend, ); let rows: Vec = sqlx::query_as(&sql) - .bind(owner_did) + .bind(authority_did) .fetch_all(pool) .await .map_err(|e| AppError::Internal(format!("failed to list spaces: {e}")))?; @@ -128,12 +122,12 @@ let decoded_cursor = cursor.and_then(decode_cursor); let sql = if decoded_cursor.is_some() { adapt_sql( - "SELECT s.did, s.owner_did, s.type_nsid, s.skey, sm.created_at FROM space_members sm JOIN spaces s ON s.id = sm.space_id WHERE sm.member_did = ? AND (sm.created_at > ? OR (sm.created_at = ? AND ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) > ?)) ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", + "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? AND (sm.created_at > ? OR (sm.created_at = ? AND ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) > ?)) ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) } else { adapt_sql( - "SELECT s.did, s.owner_did, s.type_nsid, s.skey, sm.created_at FROM space_members sm JOIN spaces s ON s.id = sm.space_id WHERE sm.member_did = ? ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", + "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) }; @@ -152,9 +146,9 @@ let views: Vec = rows .into_iter() .map( - |(space_did, owner_did, type_nsid, skey, created_at)| SpaceView { + |(space_did, authority_did, type_nsid, skey, created_at)| SpaceView { uri: format!("ats://{}/{}/{}", space_did, type_nsid, skey), - is_owner: owner_did == did, + is_owner: authority_did == did, created_at, }, ) @@ -177,26 +171,19 @@ ) -> Result { let now = now_rfc3339(); let config_json = serde_json::to_string(&space.config) .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; - let allowlist_json = space - .app_allowlist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); - let denylist_json = space - .app_denylist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); + let app_access_json = serde_json::to_string(&space.app_access) + .map_err(|e| AppError::Internal(format!("failed to serialize app_access: {e}")))?; let sql = adapt_sql( - "UPDATE spaces SET display_name = ?, description = ?, access_mode = ?, app_allowlist = ?, app_denylist = ?, managing_app_did = ?, config = ?, updated_at = ? WHERE id = ?", + "UPDATE happyview_spaces SET display_name = ?, description = ?, mint_policy = ?, app_access = ?, managing_app_did = ?, config = ?, updated_at = ? WHERE id = ?", backend, ); let result = sqlx::query(&sql) .bind(&space.display_name) .bind(&space.description) - .bind(space.access_mode.as_str()) - .bind(&allowlist_json) - .bind(&denylist_json) + .bind(space.mint_policy.as_str()) + .bind(&app_access_json) .bind(&space.managing_app_did) .bind(&config_json) .bind(&now) @@ -213,7 +200,7 @@ pool: &sqlx::AnyPool, backend: DatabaseBackend, id: &str, ) -> Result { - let sql = adapt_sql("DELETE FROM spaces WHERE id = ?", backend); + let sql = adapt_sql("DELETE FROM happyview_spaces WHERE id = ?", backend); let result = sqlx::query(&sql) .bind(id) @@ -230,11 +217,11 @@ String, String, String, String, + String, Option, Option, String, - Option, - Option, + String, Option, String, Option, @@ -243,32 +230,24 @@ String, ); fn parse_space_row(r: SpaceRow) -> Result { - let access_mode = AccessMode::parse(&r.7) - .ok_or_else(|| AppError::Internal(format!("invalid access_mode: {}", r.7)))?; - let app_allowlist: Option> = - r.8.as_deref() - .map(serde_json::from_str) - .transpose() - .map_err(|e| AppError::Internal(format!("invalid app_allowlist: {e}")))?; - let app_denylist: Option> = - r.9.as_deref() - .map(serde_json::from_str) - .transpose() - .map_err(|e| AppError::Internal(format!("invalid app_denylist: {e}")))?; + let mint_policy = MintPolicy::parse(&r.8) + .ok_or_else(|| AppError::Internal(format!("invalid mint_policy: {}", r.8)))?; + let app_access: AppAccess = serde_json::from_str(&r.9) + .map_err(|e| AppError::Internal(format!("invalid app_access: {e}")))?; let config: SpaceConfig = serde_json::from_str(&r.11) .map_err(|e| AppError::Internal(format!("invalid space config: {e}")))?; Ok(Space { id: r.0, did: r.1, - owner_did: r.2, - type_nsid: r.3, - skey: r.4, - display_name: r.5, - description: r.6, - access_mode, - app_allowlist, - app_denylist, + authority_did: r.2, + creator_did: r.3, + type_nsid: r.4, + skey: r.5, + display_name: r.6, + description: r.7, + mint_policy, + app_access, managing_app_did: r.10, config, revision: r.12, @@ -288,7 +267,7 @@ member: &SpaceMember, ) -> Result<(), AppError> { let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO space_members (id, space_id, member_did, access, is_delegation, granted_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_space_members (id, space_id, member_did, access, is_delegation, granted_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); @@ -314,7 +293,7 @@ space_id: &str, did: &str, ) -> Result { let sql = adapt_sql( - "DELETE FROM space_members WHERE space_id = ? AND member_did = ?", + "DELETE FROM happyview_space_members WHERE space_id = ? AND member_did = ?", backend, ); @@ -335,7 +314,7 @@ space_id: &str, did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? AND member_did = ?", + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM happyview_space_members WHERE space_id = ? AND member_did = ?", backend, ); @@ -355,7 +334,7 @@ backend: DatabaseBackend, space_id: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? ORDER BY created_at ASC", + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM happyview_space_members WHERE space_id = ? ORDER BY created_at ASC", backend, ); @@ -374,7 +353,7 @@ backend: DatabaseBackend, did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE member_did = ? ORDER BY created_at ASC", + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM happyview_space_members WHERE member_did = ? ORDER BY created_at ASC", backend, ); @@ -419,10 +398,10 @@ .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; let sql = match backend { DatabaseBackend::Sqlite => { - "INSERT OR REPLACE INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)".to_string() + "INSERT OR REPLACE INTO happyview_space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)".to_string() } DatabaseBackend::Postgres => adapt_sql( - "INSERT INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at", + "INSERT INTO happyview_space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at", backend, ), }; @@ -449,7 +428,7 @@ backend: DatabaseBackend, uri: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE uri = ?", + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM happyview_space_records WHERE uri = ?", backend, ); @@ -470,7 +449,7 @@ collection: &str, rkey: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND rkey = ? LIMIT 1", + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM happyview_space_records WHERE space_id = ? AND collection = ? AND rkey = ? LIMIT 1", backend, ); @@ -514,7 +493,7 @@ } let where_clause = conditions.join(" AND "); let raw = format!( - "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE {} ORDER BY indexed_at {}, uri {} LIMIT ?", + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM happyview_space_records WHERE {} ORDER BY indexed_at {}, uri {} LIMIT ?", where_clause, order, order ); let sql = adapt_sql(&raw, backend); @@ -560,7 +539,7 @@ let record_json = serde_json::to_string(&record.record) .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; let sql = adapt_sql( - "INSERT INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", backend, ); @@ -598,7 +577,7 @@ let record_json = serde_json::to_string(&record.record) .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; let sql = adapt_sql( - "UPDATE space_records SET record = ?, cid = ?, indexed_at = ? WHERE uri = ? AND cid = ?", + "UPDATE happyview_space_records SET record = ?, cid = ?, indexed_at = ? WHERE uri = ? AND cid = ?", backend, ); @@ -628,7 +607,7 @@ pool: &sqlx::AnyPool, backend: DatabaseBackend, uri: &str, ) -> Result { - let sql = adapt_sql("DELETE FROM space_records WHERE uri = ?", backend); + let sql = adapt_sql("DELETE FROM happyview_space_records WHERE uri = ?", backend); let result = sqlx::query(&sql) .bind(uri) @@ -646,7 +625,7 @@ uri: &str, swap_cid: &str, ) -> Result { let sql = adapt_sql( - "DELETE FROM space_records WHERE uri = ? AND cid = ?", + "DELETE FROM happyview_space_records WHERE uri = ? AND cid = ?", backend, ); @@ -676,7 +655,7 @@ revision: &str, ) -> Result<(), AppError> { let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE spaces SET revision = ?, updated_at = ? WHERE id = ?", + "UPDATE happyview_spaces SET revision = ?, updated_at = ? WHERE id = ?", backend, ); @@ -691,6 +670,220 @@ Ok(()) } +// --------------------------------------------------------------------------- +// Repo State +// --------------------------------------------------------------------------- + +pub async fn get_or_create_repo_state( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id, space_id, author_did, lthash_state, rev, hash, ikm, sig, mac, updated_at FROM happyview_space_repo_state WHERE space_id = ? AND author_did = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(space_id) + .bind(author_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get repo state: {e}")))?; + + if let Some(r) = row { + return parse_repo_state_row(r); + } + + let id = uuid::Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let default_lthash = vec![0u8; 2048]; + let insert_sql = adapt_sql( + "INSERT INTO happyview_space_repo_state (id, space_id, author_did, lthash_state, rev, hash, ikm, sig, mac, updated_at) VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?)", + backend, + ); + sqlx::query(&insert_sql) + .bind(&id) + .bind(space_id) + .bind(author_did) + .bind(&default_lthash) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to create repo state: {e}")))?; + + Ok(RepoState { + id, + space_id: space_id.to_string(), + author_did: author_did.to_string(), + lthash_state: default_lthash, + rev: None, + hash: None, + ikm: None, + sig: None, + mac: None, + updated_at: now, + }) +} + +pub async fn update_repo_state( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + state: &RepoState, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "UPDATE happyview_space_repo_state SET lthash_state = ?, rev = ?, hash = ?, ikm = ?, sig = ?, mac = ?, updated_at = ? WHERE id = ?", + backend, + ); + + sqlx::query(&sql) + .bind(&state.lthash_state) + .bind(&state.rev) + .bind(&state.hash) + .bind(&state.ikm) + .bind(&state.sig) + .bind(&state.mac) + .bind(&now) + .bind(&state.id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to update repo state: {e}")))?; + + Ok(()) +} + +type RepoStateRow = ( + String, + String, + String, + Vec, + Option, + Option>, + Option>, + Option>, + Option>, + String, +); + +fn parse_repo_state_row(r: RepoStateRow) -> Result { + Ok(RepoState { + id: r.0, + space_id: r.1, + author_did: r.2, + lthash_state: r.3, + rev: r.4, + hash: r.5, + ikm: r.6, + sig: r.7, + mac: r.8, + updated_at: r.9, + }) +} + +// --------------------------------------------------------------------------- +// Notification Registrations +// --------------------------------------------------------------------------- + +pub async fn register_notify( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + reg: &NotifyRegistration, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO happyview_space_notify_registrations (id, space_id, author_did, endpoint, registered_by, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(®.id) + .bind(®.space_id) + .bind(®.author_did) + .bind(®.endpoint) + .bind(®.registered_by) + .bind(®.expires_at) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to register notify: {e}")))?; + + Ok(()) +} + +pub async fn list_notify_registrations( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: Option<&str>, +) -> Result, AppError> { + let sql = if author_did.is_some() { + adapt_sql( + "SELECT id, space_id, author_did, endpoint, registered_by, expires_at, created_at FROM happyview_space_notify_registrations WHERE space_id = ? AND author_did = ? ORDER BY created_at ASC", + backend, + ) + } else { + adapt_sql( + "SELECT id, space_id, author_did, endpoint, registered_by, expires_at, created_at FROM happyview_space_notify_registrations WHERE space_id = ? ORDER BY created_at ASC", + backend, + ) + }; + + let mut query = sqlx::query_as::<_, NotifyRow>(&sql).bind(space_id); + if let Some(did) = author_did { + query = query.bind(did); + } + + let rows = query + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list notify registrations: {e}")))?; + + Ok(rows.into_iter().map(parse_notify_row).collect()) +} + +pub async fn delete_notify_registration( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + id: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM happyview_space_notify_registrations WHERE id = ?", + backend, + ); + + let result = sqlx::query(&sql) + .bind(id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete notify registration: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +type NotifyRow = ( + String, + String, + Option, + String, + String, + String, + String, +); + +fn parse_notify_row(r: NotifyRow) -> NotifyRegistration { + NotifyRegistration { + id: r.0, + space_id: r.1, + author_did: r.2, + endpoint: r.3, + registered_by: r.4, + expires_at: r.5, + created_at: r.6, + } +} + type RecordRow = ( String, String, @@ -718,6 +911,55 @@ indexed_at: r.7, }) } +/// Find the author DID of any record in the space that contains a blob ref +/// with the given CID. The CID appears in serialised record JSON as the +/// `$link` value inside an ATProto blob ref object. +pub async fn find_blob_author_did( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + blob_cid: &str, +) -> Result, AppError> { + let pattern = format!("%\"$link\":\"{blob_cid}\"%"); + let sql = adapt_sql( + "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? LIMIT 1", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(space_id) + .bind(&pattern) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to find blob author: {e}")))?; + Ok(row.map(|(did,)| did)) +} + +// --------------------------------------------------------------------------- +// Space Repos +// --------------------------------------------------------------------------- + +pub async fn list_space_repos( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT DISTINCT r.author_did, s.rev FROM happyview_space_records r LEFT JOIN happyview_space_repo_state s ON s.space_id = r.space_id AND s.author_did = r.author_did WHERE r.space_id = ? ORDER BY r.author_did ASC", + backend, + ); + + let rows: Vec<(String, Option)> = sqlx::query_as(&sql) + .bind(space_id) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list space repos: {e}")))?; + + Ok(rows + .into_iter() + .map(|(did, rev)| serde_json::json!({ "did": did, "rev": rev })) + .collect()) +} + // --------------------------------------------------------------------------- // Space Invites // --------------------------------------------------------------------------- @@ -729,7 +971,7 @@ invite: &SpaceInvite, ) -> Result<(), AppError> { let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO space_invites (id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_space_invites (id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", backend, ); @@ -757,7 +999,7 @@ backend: DatabaseBackend, token_hash: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE token_hash = ?", + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM happyview_space_invites WHERE token_hash = ?", backend, ); @@ -776,7 +1018,7 @@ backend: DatabaseBackend, invite_id: &str, ) -> Result<(), AppError> { let sql = adapt_sql( - "UPDATE space_invites SET uses = uses + 1 WHERE id = ?", + "UPDATE happyview_space_invites SET uses = uses + 1 WHERE id = ?", backend, ); @@ -794,7 +1036,10 @@ pool: &sqlx::AnyPool, backend: DatabaseBackend, invite_id: &str, ) -> Result { - let sql = adapt_sql("UPDATE space_invites SET revoked = 1 WHERE id = ?", backend); + let sql = adapt_sql( + "UPDATE happyview_space_invites SET revoked = 1 WHERE id = ?", + backend, + ); let result = sqlx::query(&sql) .bind(invite_id) @@ -811,7 +1056,7 @@ backend: DatabaseBackend, space_id: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE space_id = ? ORDER BY created_at DESC", + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM happyview_space_invites WHERE space_id = ? ORDER BY created_at DESC", backend, ); diff --git a/src/spaces/integration_tests.rs b/src/spaces/integration_tests.rs new file mode 100644 --- /dev/null +++ b/src/spaces/integration_tests.rs @@ -0,0 +1,575 @@ +/// Cross-module integration tests for the spaces subsystem. +/// +/// These run with `cargo test --lib` — no database required. +#[cfg(test)] +mod tests { + // ----------------------------------------------------------------------- + // 1. LtHash + commit integration + // ----------------------------------------------------------------------- + + use crate::spaces::commit::{sign_commit, verify_commit}; + use crate::spaces::lthash::{LtHashState, record_element}; + use k256::ecdsa::SigningKey; + + fn test_signing_key() -> SigningKey { + let mut bytes = [0u8; 32]; + bytes[31] = 1; + SigningKey::from_bytes((&bytes[..]).into()).unwrap() + } + + /// Add two records, generate a commit over the hash, verify it. + #[test] + fn lthash_commit_roundtrip() { + let mut state = LtHashState::new(); + let elem_a = record_element("com.example.forum.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.forum.post", "bbb", "bafyreibbb"); + + state.add(&elem_a); + let hash_after_a = state.hash(); + assert_ne!( + hash_after_a, + LtHashState::new().hash(), + "hash must change after first add" + ); + + state.add(&elem_b); + let hash_after_ab = state.hash(); + assert_ne!( + hash_after_ab, hash_after_a, + "hash must change after second add" + ); + + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let space_uri = "ats://did:plc:abc/com.example.forum/main"; + let rev = "3k2rev1"; + + let commit = sign_commit(&hash_after_ab, space_uri, rev, &sk).unwrap(); + assert_eq!(commit.hash, hash_after_ab); + assert_eq!(commit.rev, rev); + assert!(verify_commit(&commit, space_uri, &vk).is_ok()); + } + + /// Remove a record — hash must change back toward the previous state. + #[test] + fn lthash_commit_after_delete() { + let mut state = LtHashState::new(); + let elem_a = record_element("com.example.forum.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.forum.post", "bbb", "bafyreibbb"); + + state.add(&elem_a); + state.add(&elem_b); + let hash_two = state.hash(); + + state.remove(&elem_b); + let hash_one = state.hash(); + assert_ne!(hash_one, hash_two, "hash must change after delete"); + + // The remaining state should equal a state built with only elem_a + let mut expected = LtHashState::new(); + expected.add(&elem_a); + assert_eq!( + hash_one, + expected.hash(), + "hash after delete must match single-record state" + ); + + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let space_uri = "ats://did:plc:abc/com.example.forum/main"; + let commit = sign_commit(&hash_one, space_uri, "3k2rev2", &sk).unwrap(); + assert!(verify_commit(&commit, space_uri, &vk).is_ok()); + } + + /// Commit signed for one hash must not verify against a different hash. + #[test] + fn commit_does_not_verify_for_different_hash() { + let mut state_a = LtHashState::new(); + state_a.add(&record_element("col", "key1", "cid1")); + let hash_a = state_a.hash(); + + let mut state_b = LtHashState::new(); + state_b.add(&record_element("col", "key2", "cid2")); + let hash_b = state_b.hash(); + + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let space_uri = "ats://did:plc:abc/com.example.forum/main"; + + let commit_a = sign_commit(&hash_a, space_uri, "rev1", &sk).unwrap(); + // Tamper: swap in hash_b + let mut tampered = commit_a; + tampered.hash = hash_b; + assert!(verify_commit(&tampered, space_uri, &vk).is_err()); + } + + // ----------------------------------------------------------------------- + // 2. Credential flow cross-module + // ----------------------------------------------------------------------- + + use crate::oauth::keys::generate_dpop_keypair; + use crate::spaces::credential::{ + DEFAULT_CREDENTIAL_TTL_SECS, DELEGATION_TOKEN_TTL_SECS, DELEGATION_TOKEN_TYP, + DelegationTokenClaims, SPACE_CREDENTIAL_TYP, SpaceCredentialClaims, make_jti, + peek_credential_sub, peek_jwt_typ, sign_credential, sign_delegation_token, + verify_credential, verify_delegation_token, + }; + use k256::ecdsa::{SigningKey as K256SigningKey, VerifyingKey as K256VerifyingKey}; + + fn k256_key() -> K256SigningKey { + K256SigningKey::from_bytes((&[0x42u8; 32][..]).into()).unwrap() + } + + fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + } + + /// Full delegation → space credential flow: sign delegation token, then sign space credential + /// and verify both in sequence. + #[test] + fn delegation_then_credential_flow() { + let now = now_secs(); + let sk = k256_key(); + let vk = K256VerifyingKey::from(&sk); + + // Step 1: member signs delegation token + let delegation = DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), + iat: now, + exp: now + DELEGATION_TOKEN_TTL_SECS, + jti: make_jti(), + }; + let token = sign_delegation_token(&delegation, &sk).unwrap(); + + // Peek must return the correct typ before verification + assert_eq!(peek_jwt_typ(&token).as_deref(), Some(DELEGATION_TOKEN_TYP)); + + // Step 2: space host verifies delegation token + let verified_delegation = verify_delegation_token(&token, &vk, &delegation.aud).unwrap(); + assert_eq!(verified_delegation.iss, "did:plc:member"); + assert_eq!( + verified_delegation.sub, + "ats://did:plc:space/com.example.forum/main" + ); + + // Step 3: space host issues a space credential (using P-256 key) + let keypair = generate_dpop_keypair().unwrap(); + let cred_claims = SpaceCredentialClaims { + iss: "did:plc:space".into(), + sub: verified_delegation.sub.clone(), + iat: now, + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), + }; + let credential = sign_credential(&cred_claims, &keypair.private_jwk).unwrap(); + + // Peek on credential + assert_eq!( + peek_jwt_typ(&credential).as_deref(), + Some(SPACE_CREDENTIAL_TYP) + ); + assert_eq!( + peek_credential_sub(&credential).as_deref(), + Some("ats://did:plc:space/com.example.forum/main") + ); + + // Step 4: verify credential + let verified_cred = verify_credential(&credential, &keypair.public_jwk).unwrap(); + assert_eq!(verified_cred.iss, "did:plc:space"); + assert_eq!( + verified_cred.sub, + "ats://did:plc:space/com.example.forum/main" + ); + } + + /// An expired delegation token must be rejected before we even try to issue a credential. + #[test] + fn expired_delegation_blocks_credential_flow() { + let now = now_secs(); + let sk = k256_key(); + let vk = K256VerifyingKey::from(&sk); + + let delegation = DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), + iat: now - 120, + exp: now - 60, // already expired + jti: make_jti(), + }; + let token = sign_delegation_token(&delegation, &sk).unwrap(); + let result = verify_delegation_token(&token, &vk, &delegation.aud); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("expired")); + } + + // ----------------------------------------------------------------------- + // 3. Oplog types: serialization roundtrips + // ----------------------------------------------------------------------- + + use crate::spaces::types::{OplogAction, OplogEntry}; + + #[test] + fn oplog_action_serde_roundtrip() { + for action in [ + OplogAction::Create, + OplogAction::Update, + OplogAction::Delete, + ] { + let json = serde_json::to_string(&action).unwrap(); + let parsed: OplogAction = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, action); + } + } + + #[test] + fn oplog_entry_serialization() { + let entry = OplogEntry { + id: "entry-1".into(), + space_id: "space-abc".into(), + author_did: "did:plc:author".into(), + rev: "3k2rev1".into(), + idx: 0, + action: OplogAction::Create, + collection: "com.example.forum.post".into(), + rkey: "3k2abc".into(), + cid: Some("bafyreiabc".into()), + prev: None, + created_at: "2026-01-01T00:00:00Z".into(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let parsed: OplogEntry = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.id, entry.id); + assert_eq!(parsed.space_id, entry.space_id); + assert_eq!(parsed.action.as_str(), "create"); + assert_eq!(parsed.cid.as_deref(), Some("bafyreiabc")); + assert!(parsed.prev.is_none()); + } + + #[test] + fn oplog_entry_delete_has_no_cid() { + let entry = OplogEntry { + id: "entry-2".into(), + space_id: "space-abc".into(), + author_did: "did:plc:author".into(), + rev: "3k2rev2".into(), + idx: 0, + action: OplogAction::Delete, + collection: "com.example.forum.post".into(), + rkey: "3k2abc".into(), + cid: None, + prev: Some("bafyreiabc".into()), + created_at: "2026-01-01T00:00:01Z".into(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let parsed: OplogEntry = serde_json::from_str(&json).unwrap(); + assert!(parsed.cid.is_none()); + assert_eq!(parsed.prev.as_deref(), Some("bafyreiabc")); + } + + // ----------------------------------------------------------------------- + // 4. Simplespace config types + // ----------------------------------------------------------------------- + + use crate::spaces::types::{AppAccess, MintPolicy, SpaceConfig}; + + #[test] + fn mint_policy_serde_roundtrip() { + let cases = [ + (MintPolicy::MemberList, "\"member-list\""), + (MintPolicy::Public, "\"public\""), + (MintPolicy::ManagingApp, "\"managing-app\""), + ]; + for (policy, expected_json) in cases { + let json = serde_json::to_string(&policy).unwrap(); + assert_eq!(json, expected_json); + let parsed: MintPolicy = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, policy); + } + } + + #[test] + fn app_access_open_default() { + let access = AppAccess::default(); + assert!(matches!(access, AppAccess::Open)); + let json = serde_json::to_string(&access).unwrap(); + assert_eq!(json, r#"{"type":"open"}"#); + } + + #[test] + fn app_access_allowlist_roundtrip() { + let access = AppAccess::AllowList { + allowed: vec!["https://app.example.com/client-metadata.json".into()], + }; + let json = serde_json::to_string(&access).unwrap(); + let parsed: AppAccess = serde_json::from_str(&json).unwrap(); + match parsed { + AppAccess::AllowList { allowed } => { + assert_eq!(allowed.len(), 1); + assert_eq!(allowed[0], "https://app.example.com/client-metadata.json"); + } + _ => panic!("expected AllowList"), + } + } + + #[test] + fn space_config_defaults_false() { + let config: SpaceConfig = serde_json::from_str("{}").unwrap(); + assert!(!config.membership_public); + assert!(!config.records_public); + assert!(config.extra.is_empty()); + } + + #[test] + fn space_config_preserves_extra_fields() { + let json = r#"{"membership_public":true,"records_public":false,"allowedCollections":["col.a","col.b"]}"#; + let config: SpaceConfig = serde_json::from_str(json).unwrap(); + assert!(config.membership_public); + assert!(!config.records_public); + let collections = config.extra.get("allowedCollections").unwrap(); + assert_eq!(collections.as_array().unwrap().len(), 2); + } + + // ----------------------------------------------------------------------- + // 5. Backward-compatible route namespace constants + // + // The PROTO_NS and LEGACY_NS constants are private to routes.rs. + // We verify the expected values here as a named constant in test scope + // so the intent is documented and any refactor that changes the strings + // will need to update these tests. + // ----------------------------------------------------------------------- + + /// The AT Protocol namespace used for canonical space routes. + const EXPECTED_PROTO_NS: &str = "com.atproto"; + + /// The HappyView legacy namespace kept for backward compatibility. + const EXPECTED_LEGACY_NS: &str = "dev.happyview"; + + #[test] + fn proto_ns_value_is_com_atproto() { + // getDelegationToken is on the proto namespace; getMemberGrant is the legacy alias + let proto_route = format!("/xrpc/{}.space.getDelegationToken", EXPECTED_PROTO_NS); + assert_eq!(proto_route, "/xrpc/com.atproto.space.getDelegationToken"); + } + + #[test] + fn legacy_ns_value_is_dev_happyview() { + // getMemberGrant is the legacy alias for getDelegationToken + let legacy_route = format!("/xrpc/{}.space.getMemberGrant", EXPECTED_LEGACY_NS); + assert_eq!(legacy_route, "/xrpc/dev.happyview.space.getMemberGrant"); + } + + #[test] + fn create_space_legacy_maps_to_simplespace() { + // dev.happyview.space.createSpace is the legacy alias for com.atproto.simplespace.createSpace + let legacy = format!("/xrpc/{}.space.createSpace", EXPECTED_LEGACY_NS); + let canonical = format!("/xrpc/{}.simplespace.createSpace", EXPECTED_PROTO_NS); + // Both paths must be distinct strings that map to the same handler + assert_ne!(legacy, canonical); + assert_eq!(legacy, "/xrpc/dev.happyview.space.createSpace"); + assert_eq!(canonical, "/xrpc/com.atproto.simplespace.createSpace"); + } + + // ----------------------------------------------------------------------- + // 6. Read scope validation — cross-module + // ----------------------------------------------------------------------- + + use crate::spaces::scope::{SpaceReadAccess, check_delegation_token_access, check_read_access}; + use crate::spaces::types::SpaceAccess; + + /// read_self member reads own record → ok + #[test] + fn read_self_member_reads_own_record_ok() { + let result = check_read_access( + "did:plc:alice", + "did:plc:alice", + SpaceReadAccess::ReadSelf, + false, + ); + assert!(result.is_ok()); + } + + /// read_self member reads other's record → error + #[test] + fn read_self_member_reads_others_record_err() { + let result = check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + false, + ); + assert!(result.is_err()); + } + + /// read_self member tries getDelegationToken → error + #[test] + fn read_self_member_cannot_get_delegation_token() { + let result = check_delegation_token_access(SpaceReadAccess::ReadSelf, false); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("delegation")); + } + + /// Full read member: can read own record + #[test] + fn full_read_member_reads_own_record_ok() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:alice", + SpaceReadAccess::Read, + false + ) + .is_ok() + ); + } + + /// Full read member: can read other's record + #[test] + fn full_read_member_reads_others_record_ok() { + assert!( + check_read_access("did:plc:alice", "did:plc:bob", SpaceReadAccess::Read, false).is_ok() + ); + } + + /// Full read member: can get delegation token + #[test] + fn full_read_member_can_get_delegation_token() { + assert!(check_delegation_token_access(SpaceReadAccess::Read, false).is_ok()); + } + + /// space_credential bypasses read_self restriction on reads + #[test] + fn space_credential_bypasses_read_self_on_read() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + true + ) + .is_ok() + ); + } + + /// space_credential bypasses read_self restriction on delegation token + #[test] + fn space_credential_bypasses_read_self_on_delegation() { + assert!(check_delegation_token_access(SpaceReadAccess::ReadSelf, true).is_ok()); + } + + /// SpaceReadAccess::from_space_access maps access levels correctly + #[test] + fn space_access_to_read_access_mapping() { + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::ReadSelf), + SpaceReadAccess::ReadSelf + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Read), + SpaceReadAccess::Read + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Write), + SpaceReadAccess::Read + ); + } + + // ----------------------------------------------------------------------- + // 7. Blob sync query params + // ----------------------------------------------------------------------- + + /// GetSpaceBlobQuery is private to routes.rs; test the equivalent deserialization shape. + #[test] + fn blob_query_params_camel_case() { + // The route accepts ?space=...&cid=... in camelCase — verify serde_json can + // round-trip the equivalent shape used in routes.rs. + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct BlobQuery { + space: String, + cid: String, + } + + let qs = serde_json::json!({ + "space": "ats://did:plc:abc/com.example.forum/main", + "cid": "bafyreiabc123" + }); + let q: BlobQuery = serde_json::from_value(qs).unwrap(); + assert_eq!(q.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(q.cid, "bafyreiabc123"); + } + + // ----------------------------------------------------------------------- + // 8. Verification methods — key generation and roundtrip + // ----------------------------------------------------------------------- + + use crate::spaces::credential::p256_jwk_to_verifying_key; + + #[test] + fn p256_keypair_generation_and_jwk_roundtrip() { + let keypair = generate_dpop_keypair().unwrap(); + + // Public JWK must have kty, crv, x, y + assert_eq!(keypair.public_jwk["kty"].as_str(), Some("EC")); + assert_eq!(keypair.public_jwk["crv"].as_str(), Some("P-256")); + assert!(keypair.public_jwk["x"].as_str().is_some()); + assert!(keypair.public_jwk["y"].as_str().is_some()); + + // Private JWK must have d + assert!(keypair.private_jwk["d"].as_str().is_some()); + + // Can reconstruct verifying key from public JWK + let vk = p256_jwk_to_verifying_key(&keypair.public_jwk).unwrap(); + let point = vk.to_encoded_point(false); + assert!(point.x().is_some()); + assert!(point.y().is_some()); + } + + #[test] + fn sign_and_verify_with_generated_keypair() { + let keypair = generate_dpop_keypair().unwrap(); + let now = now_secs(); + + let claims = SpaceCredentialClaims { + iss: "did:plc:owner".into(), + sub: "ats://did:plc:owner/com.example.forum/main".into(), + iat: now, + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), + }; + + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); + let verified = verify_credential(&token, &keypair.public_jwk).unwrap(); + + assert_eq!(verified.iss, claims.iss); + assert_eq!(verified.sub, claims.sub); + assert_eq!(verified.jti, claims.jti); + } + + #[test] + fn two_different_keypairs_do_not_cross_verify() { + let kp1 = generate_dpop_keypair().unwrap(); + let kp2 = generate_dpop_keypair().unwrap(); + let now = now_secs(); + + let claims = SpaceCredentialClaims { + iss: "did:plc:owner".into(), + sub: "ats://did:plc:owner/com.example.forum/main".into(), + iat: now, + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), + }; + + let token = sign_credential(&claims, &kp1.private_jwk).unwrap(); + let result = verify_credential(&token, &kp2.public_jwk); + assert!(result.is_err()); + } +} diff --git a/src/spaces/lthash.rs b/src/spaces/lthash.rs new file mode 100644 --- /dev/null +++ b/src/spaces/lthash.rs @@ -0,0 +1,180 @@ +use blake3::Hasher as Blake3Hasher; +use sha2::{Digest, Sha256}; + +const NUM_LANES: usize = 1024; +const STATE_BYTES: usize = NUM_LANES * 2; // 2048 + +pub struct LtHashState { + lanes: [u16; NUM_LANES], +} + +impl Default for LtHashState { + fn default() -> Self { + Self::new() + } +} + +impl LtHashState { + pub fn new() -> Self { + LtHashState { + lanes: [0u16; NUM_LANES], + } + } + + pub fn add(&mut self, element: &[u8]) { + let expanded = expand_element(element); + for (i, val) in expanded.iter().enumerate().take(NUM_LANES) { + self.lanes[i] = self.lanes[i].wrapping_add(*val); + } + } + + pub fn remove(&mut self, element: &[u8]) { + let expanded = expand_element(element); + for (i, val) in expanded.iter().enumerate().take(NUM_LANES) { + self.lanes[i] = self.lanes[i].wrapping_sub(*val); + } + } + + pub fn hash(&self) -> [u8; 32] { + Sha256::digest(self.as_bytes()).into() + } + + pub fn as_bytes(&self) -> [u8; STATE_BYTES] { + let mut bytes = [0u8; STATE_BYTES]; + for i in 0..NUM_LANES { + let le = self.lanes[i].to_le_bytes(); + bytes[i * 2] = le[0]; + bytes[i * 2 + 1] = le[1]; + } + bytes + } + + pub fn from_bytes(bytes: [u8; STATE_BYTES]) -> Self { + let mut lanes = [0u16; NUM_LANES]; + for i in 0..NUM_LANES { + lanes[i] = u16::from_le_bytes([bytes[i * 2], bytes[i * 2 + 1]]); + } + LtHashState { lanes } + } +} + +fn expand_element(element: &[u8]) -> [u16; NUM_LANES] { + let mut hasher = Blake3Hasher::new(); + hasher.update(element); + let mut xof = hasher.finalize_xof(); + let mut buf = [0u8; STATE_BYTES]; + xof.fill(&mut buf); + + let mut lanes = [0u16; NUM_LANES]; + for i in 0..NUM_LANES { + lanes[i] = u16::from_le_bytes([buf[i * 2], buf[i * 2 + 1]]); + } + lanes +} + +pub fn record_element(collection: &str, rkey: &str, cid: &str) -> Vec { + format!("{collection}/{rkey}/{cid}").into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_state_is_all_zeroes() { + let state = LtHashState::new(); + assert_eq!(state.as_bytes(), [0u8; 2048]); + let expected: [u8; 32] = sha2::Sha256::digest([0u8; 2048]).into(); + assert_eq!(state.hash(), expected); + } + + #[test] + fn add_then_remove_returns_to_empty() { + let mut state = LtHashState::new(); + let element = record_element("com.example.post", "3k2abc", "bafyreiabc123"); + state.add(&element); + assert_ne!(state.as_bytes(), [0u8; 2048]); + state.remove(&element); + assert_eq!(state.as_bytes(), [0u8; 2048]); + } + + #[test] + fn order_independent() { + let elem_a = record_element("com.example.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.post", "bbb", "bafyreibbb"); + + let mut state1 = LtHashState::new(); + state1.add(&elem_a); + state1.add(&elem_b); + + let mut state2 = LtHashState::new(); + state2.add(&elem_b); + state2.add(&elem_a); + + assert_eq!(state1.hash(), state2.hash()); + assert_eq!(state1.as_bytes(), state2.as_bytes()); + } + + #[test] + fn different_records_different_hashes() { + let elem_a = record_element("com.example.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.post", "bbb", "bafyreibbb"); + + let mut state_a = LtHashState::new(); + state_a.add(&elem_a); + + let mut state_b = LtHashState::new(); + state_b.add(&elem_b); + + assert_ne!(state_a.hash(), state_b.hash()); + } + + #[test] + fn record_element_format() { + let elem = record_element("com.example.post", "3k2abc", "bafyreiabc"); + assert_eq!(elem, b"com.example.post/3k2abc/bafyreiabc"); + } + + #[test] + fn from_bytes_roundtrip() { + let mut state = LtHashState::new(); + let elem = record_element("com.example.post", "3k2abc", "bafyreiabc"); + state.add(&elem); + let bytes = state.as_bytes(); + let restored = LtHashState::from_bytes(bytes); + assert_eq!(state.hash(), restored.hash()); + } + + #[test] + fn wrapping_arithmetic() { + let mut state = LtHashState::new(); + let elem = record_element("test", "key", "cid"); + // Adding the same element 65536 times should wrap back to zero + for _ in 0..65536 { + state.add(&elem); + } + assert_eq!(state.as_bytes(), [0u8; 2048]); + } + + #[test] + fn remove_standalone() { + let mut state = LtHashState::new(); + let elem = record_element("app.bsky.feed.post", "abc123", "bafydata"); + state.add(&elem); + assert_ne!(state.as_bytes(), LtHashState::new().as_bytes()); + state.remove(&elem); + assert_eq!(state.as_bytes(), LtHashState::new().as_bytes()); + assert_eq!(state.hash(), LtHashState::new().hash()); + } + + #[test] + fn from_bytes_roundtrip_modified() { + let mut state = LtHashState::new(); + state.add(&record_element("com.example.post", "rk1", "bafyabc")); + let original_hash = state.hash(); + let mut bytes = state.as_bytes(); + bytes[1024] ^= 0xFF; + let tampered = LtHashState::from_bytes(bytes); + assert_ne!(tampered.hash(), original_hash); + } +} diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs --- a/src/spaces/mod.rs +++ b/src/spaces/mod.rs @@ -1,9 +1,19 @@ pub mod auth; +pub mod client_attestation; +pub mod commit; pub mod credential; pub mod db; +pub mod lthash; pub mod members; +pub mod notifications; +pub mod oplog; pub mod routes; +pub mod scope; +pub mod simplespace; pub mod types; + +#[cfg(test)] +mod integration_tests; use crate::error::AppError; use std::fmt; diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs new file mode 100644 --- /dev/null +++ b/src/spaces/notifications.rs @@ -0,0 +1,93 @@ +use crate::db::{DatabaseBackend, now_rfc3339}; +use crate::error::AppError; +use crate::spaces::db; +use crate::spaces::types::NotifyRegistration; +use uuid::Uuid; + +const NOTIFY_REGISTRATION_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours + +pub async fn register( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + service_did: &str, + endpoint: &str, + registered_by: &str, +) -> Result { + let id = Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let expires_at = { + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + NOTIFY_REGISTRATION_TTL_SECS; + chrono::DateTime::from_timestamp(expiry as i64, 0) + .unwrap() + .to_rfc3339() + }; + let reg = NotifyRegistration { + id: id.clone(), + space_id: space_id.to_string(), + author_did: Some(service_did.to_string()), + endpoint: endpoint.to_string(), + registered_by: registered_by.to_string(), + expires_at, + created_at: now, + }; + db::register_notify(pool, backend, ®).await?; + Ok(id) +} + +#[allow(clippy::too_many_arguments)] +pub async fn dispatch_write_notification( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + http: &reqwest::Client, + space_id: &str, + author_did: &str, + collection: &str, + rkey: &str, + cid: Option<&str>, +) -> Result<(), AppError> { + let registrations = + db::list_notify_registrations(pool, backend, space_id, Some(author_did)).await?; + // Also include space-wide registrations (no author_did filter) + let space_wide = db::list_notify_registrations(pool, backend, space_id, None).await?; + + let all: Vec<&NotifyRegistration> = registrations + .iter() + .chain(space_wide.iter().filter(|r| r.author_did.is_none())) + .collect(); + + let payload = serde_json::json!({ + "space": space_id, + "did": author_did, + "collection": collection, + "rkey": rkey, + "cid": cid, + }); + + for reg in all { + let _ = http.post(®.endpoint).json(&payload).send().await; + } + + Ok(()) +} + +pub async fn dispatch_space_deleted( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + http: &reqwest::Client, + space_id: &str, +) -> Result<(), AppError> { + let registrations = db::list_notify_registrations(pool, backend, space_id, None).await?; + + let payload = serde_json::json!({ "space": space_id }); + + for reg in ®istrations { + let _ = http.post(®.endpoint).json(&payload).send().await; + } + + Ok(()) +} diff --git a/src/spaces/oplog.rs b/src/spaces/oplog.rs new file mode 100644 --- /dev/null +++ b/src/spaces/oplog.rs @@ -0,0 +1,98 @@ +use crate::db::{DatabaseBackend, adapt_sql}; +use crate::error::AppError; +use crate::spaces::types::{OplogAction, OplogEntry}; + +pub async fn append_op( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + entry: &OplogEntry, +) -> Result<(), AppError> { + let sql = adapt_sql( + "INSERT INTO happyview_space_record_oplog (id, space_id, author_did, rev, idx, action, collection, rkey, cid, prev, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + backend, + ); + sqlx::query(&sql) + .bind(&entry.id) + .bind(&entry.space_id) + .bind(&entry.author_did) + .bind(&entry.rev) + .bind(entry.idx) + .bind(entry.action.as_str()) + .bind(&entry.collection) + .bind(&entry.rkey) + .bind(&entry.cid) + .bind(&entry.prev) + .bind(&entry.created_at) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to append oplog entry: {e}")))?; + Ok(()) +} + +pub async fn list_ops( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: &str, + since_rev: Option<&str>, + limit: i64, +) -> Result, AppError> { + let sql = if since_rev.is_some() { + adapt_sql( + "SELECT id, space_id, author_did, rev, idx, action, collection, rkey, cid, prev, created_at FROM happyview_space_record_oplog WHERE space_id = ? AND author_did = ? AND rev > ? ORDER BY rev, idx LIMIT ?", + backend, + ) + } else { + adapt_sql( + "SELECT id, space_id, author_did, rev, idx, action, collection, rkey, cid, prev, created_at FROM happyview_space_record_oplog WHERE space_id = ? AND author_did = ? ORDER BY rev, idx LIMIT ?", + backend, + ) + }; + + type OplogRow = ( + String, + String, + String, + String, + i32, + String, + String, + String, + Option, + Option, + String, + ); + + let mut query = sqlx::query_as::<_, OplogRow>(&sql) + .bind(space_id) + .bind(author_did); + if let Some(rev) = since_rev { + query = query.bind(rev); + } + query = query.bind(limit); + + let rows = query + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list oplog entries: {e}")))?; + + rows.into_iter() + .map(|r| { + let action = OplogAction::parse(&r.5) + .ok_or_else(|| AppError::Internal(format!("invalid oplog action: {}", r.5)))?; + Ok(OplogEntry { + id: r.0, + space_id: r.1, + author_did: r.2, + rev: r.3, + idx: r.4, + action, + collection: r.6, + rkey: r.7, + cid: r.8, + prev: r.9, + created_at: r.10, + }) + }) + .collect() +} diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -1,8 +1,10 @@ use axum::extract::{Query, State}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; +use base64::Engine as _; +use k256; use serde::Deserialize; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -12,8 +14,9 @@ use crate::auth::XrpcClaims; use crate::db::{adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::lua::tid::generate_tid; +use crate::spaces::scope::{SpaceReadAccess, check_delegation_token_access, check_read_access}; use crate::spaces::types::*; -use crate::spaces::{SpaceUri, db, members}; +use crate::spaces::{SpaceUri, db, members, notifications, oplog}; // --------------------------------------------------------------------------- // Request / response types @@ -21,48 +24,62 @@ // --------------------------------------------------------------------------- #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct CreateSpaceInput { - #[serde(rename = "type")] - type_nsid: String, - skey: String, - display_name: Option, - description: Option, - access_mode: Option, - managing_app_did: Option, - config: Option, +struct RepoStateQuery { + space: String, + did: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct SpaceUriQuery { +struct ListRepoOpsQuery { space: String, + did: String, + limit: Option, + cursor: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct ListSpacesQuery { - did: Option, - limit: Option, - cursor: Option, +struct RegisterNotifyInput { + space: String, + service_did: String, + endpoint: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NotifyWriteInput { + space: String, + did: String, + collection: String, + rkey: String, + cid: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct DeleteSpaceInput { +struct NotifySpaceDeletedInput { + space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetDelegationTokenQuery { space: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct UpdateSpaceInput { +struct SpaceUriQuery { space: String, - display_name: Option>, - description: Option>, - access_mode: Option, - app_allowlist: Option>>, - app_denylist: Option>>, - managing_app_did: Option>, - config: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListSpacesQuery { + did: Option, + limit: Option, + cursor: Option, } #[derive(Deserialize)] @@ -105,22 +122,6 @@ } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct AddMemberInput { - space: String, - did: String, - access: Option, - is_delegation: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RemoveMemberInput { - space: String, - did: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] struct CreateInviteInput { space: String, access: Option, @@ -143,12 +144,6 @@ } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct GetMemberGrantInput { - space: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] struct GetSpaceCredentialInput { grant: String, } @@ -159,6 +154,13 @@ struct CreateRecordInput { space: String, collection: String, record: serde_json::Value, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetSpaceBlobQuery { + space: String, + cid: String, } #[derive(Deserialize)] @@ -196,78 +198,136 @@ // --------------------------------------------------------------------------- // Route registration // --------------------------------------------------------------------------- -const NS: &str = "dev.happyview"; +const PROTO_NS: &str = "com.atproto"; +const LEGACY_NS: &str = "dev.happyview"; pub fn space_routes() -> Router { Router::new() - // Space CRUD - .route(&format!("/xrpc/{NS}.space.createSpace"), post(create_space)) - .route(&format!("/xrpc/{NS}.space.getSpace"), get(get_space)) - .route(&format!("/xrpc/{NS}.space.listSpaces"), get(list_spaces)) - .route(&format!("/xrpc/{NS}.space.deleteSpace"), post(delete_space)) - .route(&format!("/xrpc/{NS}.space.updateSpace"), post(update_space)) - // Records + // Protocol-level routes (com.atproto.space.*) + .route(&format!("/xrpc/{PROTO_NS}.space.getSpace"), get(get_space)) + .route( + &format!("/xrpc/{PROTO_NS}.space.listSpaces"), + get(list_spaces), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getRecord"), + get(get_record), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.listRecords"), + get(list_records), + ) .route( - &format!("/xrpc/{NS}.space.createRecord"), + &format!("/xrpc/{PROTO_NS}.space.getRepoState"), + get(get_repo_state), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.listRepoOps"), + get(list_repo_ops), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.listRepos"), + get(list_repos), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getDelegationToken"), + get(get_delegation_token), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getSpaceCredential"), + post(get_space_credential), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.createRecord"), post(create_record), ) - .route(&format!("/xrpc/{NS}.space.putRecord"), post(put_record)) .route( - &format!("/xrpc/{NS}.space.deleteRecord"), + &format!("/xrpc/{PROTO_NS}.space.putRecord"), + post(put_record), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.deleteRecord"), post(delete_record), ) - .route(&format!("/xrpc/{NS}.space.applyWrites"), post(apply_writes)) - .route(&format!("/xrpc/{NS}.space.getRecord"), get(get_record)) - .route(&format!("/xrpc/{NS}.space.listRecords"), get(list_records)) - // Members - .route(&format!("/xrpc/{NS}.space.listMembers"), get(list_members)) - .route(&format!("/xrpc/{NS}.space.addMember"), post(add_member)) + .route( + &format!("/xrpc/{PROTO_NS}.space.applyWrites"), + post(apply_writes), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.registerNotify"), + post(register_notify), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.notifyWrite"), + post(notify_write), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.notifySpaceDeleted"), + post(notify_space_deleted), + ) .route( - &format!("/xrpc/{NS}.space.removeMember"), - post(remove_member), + &format!("/xrpc/{PROTO_NS}.space.getBlob"), + get(get_space_blob), ) - // Invites + // Invites (HappyView extension, no com.atproto equivalent) .route( - &format!("/xrpc/{NS}.space.createInvite"), + &format!("/xrpc/{LEGACY_NS}.space.createInvite"), post(create_invite), ) .route( - &format!("/xrpc/{NS}.space.redeemInvite"), - post(redeem_invite), + &format!("/xrpc/{LEGACY_NS}.space.acceptInvite"), + post(accept_invite), ) .route( - &format!("/xrpc/{NS}.space.revokeInvite"), + &format!("/xrpc/{LEGACY_NS}.space.revokeInvite"), post(revoke_invite), ) - .route(&format!("/xrpc/{NS}.space.listInvites"), get(list_invites)) - // Credentials + .route( + &format!("/xrpc/{LEGACY_NS}.space.listInvites"), + get(list_invites), + ) + // Backward-compatible aliases (dev.happyview.space.*) — kept until v3 + .route(&format!("/xrpc/{LEGACY_NS}.space.getSpace"), get(get_space)) + .route( + &format!("/xrpc/{LEGACY_NS}.space.listSpaces"), + get(list_spaces), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.getRecord"), + get(get_record), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.listRecords"), + get(list_records), + ) .route( - &format!("/xrpc/{NS}.space.getMemberGrant"), - post(get_member_grant), + &format!("/xrpc/{LEGACY_NS}.space.getMemberGrant"), + get(get_delegation_token), ) .route( - &format!("/xrpc/{NS}.space.getSpaceCredential"), + &format!("/xrpc/{LEGACY_NS}.space.getSpaceCredential"), post(get_space_credential), ) - // Legacy aliases (will be removed in a future release) - .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) - .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) - .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) - .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) - .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) .route( - &format!("/xrpc/{NS}.space.invite.create"), - post(create_invite), + &format!("/xrpc/{LEGACY_NS}.space.createRecord"), + post(create_record), ) .route( - &format!("/xrpc/{NS}.space.invite.redeem"), - post(redeem_invite), + &format!("/xrpc/{LEGACY_NS}.space.putRecord"), + post(put_record), ) .route( - &format!("/xrpc/{NS}.space.invite.revoke"), - post(revoke_invite), + &format!("/xrpc/{LEGACY_NS}.space.deleteRecord"), + post(delete_record), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.applyWrites"), + post(apply_writes), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.getBlob"), + get(get_space_blob), ) - .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites)) } // --------------------------------------------------------------------------- @@ -321,10 +381,13 @@ .ok_or_else(|| AppError::NotFound("Space not found".into())) } async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Result<(), AppError> { - if space.owner_did == did { + if space.authority_did == did { return Ok(()); } - let sql = adapt_sql("SELECT is_super FROM users WHERE did = ?", state.db_backend); + let sql = adapt_sql( + "SELECT is_super FROM happyview_users WHERE did = ?", + state.db_backend, + ); let row: Option<(i32,)> = sqlx::query_as(&sql) .bind(did) .fetch_optional(&state.db) @@ -334,7 +397,7 @@ if row.is_some_and(|(is_super,)| is_super != 0) { return Ok(()); } Err(AppError::Forbidden( - "Only the space owner can perform this action".into(), + "Only the space authority can perform this action".into(), )) } @@ -354,17 +417,14 @@ &state.config.plc_url, ) .await { - Ok(claims) if claims.space == space_uri => { - let access = match claims.scope.as_str() { - "write" => SpaceAccess::Write, - _ => SpaceAccess::Read, - }; - if require_write && !access.can_write() { + Ok(claims) if claims.sub == space_uri => { + // External credential grants read access; write is not supported via space credential + if require_write { return Err(AppError::Forbidden( "Write access is required for this action".into(), )); } - return Ok(access); + return Ok(SpaceAccess::Read); } Ok(_) => { // Credential is valid but for a different space — fall through @@ -392,78 +452,26 @@ let hash = Sha256::digest(&bytes); format!("bafyrei{}", hex::encode(&hash[..20])) } +async fn resolve_client_id_url( + state: &AppState, + client_key: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT client_id_url FROM happyview_api_clients WHERE client_key = ?", + state.db_backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(client_key) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?; + Ok(row.map(|(url,)| url)) +} + // --------------------------------------------------------------------------- -// Space CRUD handlers +// Space read handlers // --------------------------------------------------------------------------- -async fn create_space( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result { - let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); - - if input.type_nsid.is_empty() || input.skey.is_empty() { - return Err(AppError::BadRequest("type and skey are required".into())); - } - - let existing = db::get_space_by_address( - &state.db, - state.db_backend, - &did, - &input.type_nsid, - &input.skey, - ) - .await?; - if existing.is_some() { - return Err(AppError::Conflict( - "A space with this address already exists".into(), - )); - } - - let space = Space { - id: Uuid::new_v4().to_string(), - did: did.clone(), - owner_did: did.clone(), - type_nsid: input.type_nsid, - skey: input.skey, - display_name: input.display_name, - description: input.description, - access_mode: input.access_mode.unwrap_or(AccessMode::DefaultAllow), - app_allowlist: None, - app_denylist: None, - managing_app_did: input.managing_app_did, - config: input.config.unwrap_or_default(), - revision: None, - created_at: now_rfc3339(), - updated_at: now_rfc3339(), - }; - - db::create_space(&state.db, state.db_backend, &space).await?; - - // Auto-add the creator as a write member - let member = SpaceMember { - id: Uuid::new_v4().to_string(), - space_id: space.id.clone(), - did: did.clone(), - access: SpaceAccess::Write, - is_delegation: false, - granted_by: Some(did), - created_at: now_rfc3339(), - }; - db::add_member(&state.db, state.db_backend, &member).await?; - - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); - let body = serde_json::json!({ - "uri": space_uri, - }); - - let mut response = Json(body).into_response(); - *response.status_mut() = StatusCode::CREATED; - Ok(response) -} - async fn get_space( State(state): State, xrpc_claims: XrpcClaims, @@ -475,7 +483,7 @@ // If the space's membership is not public, require auth + membership if !space.config.membership_public { let claims = require_auth(&xrpc_claims)?; let did = claims.did(); - if space.owner_did != did { + if space.authority_did != did { members::is_member(&state.db, state.db_backend, &space.id, did) .await? .ok_or_else(|| AppError::NotFound("Space not found".into()))?; @@ -483,9 +491,16 @@ } } let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let simplespace_config = serde_json::json!({ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": space.mint_policy, + "appAccess": space.app_access, + "managingApp": space.managing_app_did, + }); Ok(Json(serde_json::json!({ "uri": space_uri, "space": space, + "config": simplespace_config, }))) } @@ -523,60 +538,6 @@ "cursor": cursor, }))) } -async fn delete_space( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - db::delete_space(&state.db, state.db_backend, &space.id).await?; - - Ok(Json(serde_json::json!({ "success": true }))) -} - -async fn update_space( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let mut space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - if let Some(name) = input.display_name { - space.display_name = name; - } - if let Some(desc) = input.description { - space.description = desc; - } - if let Some(mode) = input.access_mode { - space.access_mode = mode; - } - if let Some(list) = input.app_allowlist { - space.app_allowlist = list; - } - if let Some(list) = input.app_denylist { - space.app_denylist = list; - } - if let Some(did) = input.managing_app_did { - space.managing_app_did = did; - } - if let Some(config) = input.config { - space.config = config; - } - - db::update_space(&state.db, state.db_backend, &space).await?; - - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); - Ok(Json(serde_json::json!({ - "uri": space_uri, - "space": space, - }))) -} - // --------------------------------------------------------------------------- // Record handlers // --------------------------------------------------------------------------- @@ -857,7 +818,8 @@ Query(query): Query, ) -> Result, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; - require_membership( + let has_credential = xrpc_claims.space_credential.is_some(); + let membership = require_membership( &state, &space, &did, @@ -875,6 +837,9 @@ &query.rkey, ) .await? .ok_or_else(|| AppError::NotFound("Record not found".into()))?; + + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, &record.author_did, read_access, has_credential)?; Ok(Json(serde_json::json!({ "uri": record.uri, @@ -890,7 +855,8 @@ Query(query): Query, ) -> Result, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; - require_membership( + let has_credential = xrpc_claims.space_credential.is_some(); + let membership = require_membership( &state, &space, &did, @@ -899,13 +865,18 @@ xrpc_claims.space_credential.as_deref(), ) .await?; - let repo = query.repo.as_deref().or_else(|| { - if xrpc_claims.space_credential.is_some() { + let read_access = SpaceReadAccess::from_space_access(membership); + + // read_self members may only list their own records regardless of what the caller requests + let repo = if !has_credential && read_access == SpaceReadAccess::ReadSelf { + Some(did.as_str()) + } else { + query.repo.as_deref().or(if has_credential { None } else { Some(did.as_str()) - } - }); + }) + }; let limit = query.limit.unwrap_or(50).min(100); let reverse = query.reverse.unwrap_or(false); @@ -939,85 +910,6 @@ }))) } // --------------------------------------------------------------------------- -// Member handlers -// --------------------------------------------------------------------------- - -async fn list_members( - State(state): State, - xrpc_claims: XrpcClaims, - Query(query): Query, -) -> Result, AppError> { - let space = resolve_space(&state, &query.space).await?; - - if !space.config.membership_public { - let did = require_auth_or_credential(&state, &xrpc_claims).await?; - require_membership( - &state, - &space, - &did, - false, - xrpc_claims.space_credential.as_deref(), - ) - .await?; - } - - let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; - - Ok(Json(serde_json::json!({ "members": resolved }))) -} - -async fn add_member( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result { - let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - let existing = db::get_member(&state.db, state.db_backend, &space.id, &input.did).await?; - if existing.is_some() { - return Err(AppError::Conflict( - "Member already exists in this space".into(), - )); - } - - let member = SpaceMember { - id: Uuid::new_v4().to_string(), - space_id: space.id, - did: input.did, - access: input.access.unwrap_or(SpaceAccess::Read), - is_delegation: input.is_delegation.unwrap_or(false), - granted_by: Some(claims.did().to_string()), - created_at: now_rfc3339(), - }; - - db::add_member(&state.db, state.db_backend, &member).await?; - - let mut response = Json(serde_json::json!({ "member": member })).into_response(); - *response.status_mut() = StatusCode::CREATED; - Ok(response) -} - -async fn remove_member( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - let removed = db::remove_member(&state.db, state.db_backend, &space.id, &input.did).await?; - - if !removed { - return Err(AppError::NotFound("Member not found in this space".into())); - } - - Ok(Json(serde_json::json!({ "success": true }))) -} - -// --------------------------------------------------------------------------- // Invite handlers // --------------------------------------------------------------------------- @@ -1062,7 +954,7 @@ *response.status_mut() = StatusCode::CREATED; Ok(response) } -async fn redeem_invite( +async fn accept_invite( State(state): State, xrpc_claims: XrpcClaims, Json(input): Json, @@ -1177,48 +1069,271 @@ // --------------------------------------------------------------------------- // Credential handlers // --------------------------------------------------------------------------- -async fn get_member_grant( +async fn get_delegation_token( State(state): State, xrpc_claims: XrpcClaims, - Json(input): Json, + Query(params): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); - let space = resolve_space(&state, &input.space).await?; + let space = resolve_space(&state, ¶ms.space).await?; - require_membership(&state, &space, &did, false, None).await?; + let membership = require_membership(&state, &space, &did, false, None).await?; + let read_access = SpaceReadAccess::from_space_access(membership); + check_delegation_token_access(read_access, false)?; let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?; + + let signing_key = k256::ecdsa::SigningKey::from_bytes(encryption_key.into()) + .map_err(|e| AppError::Internal(format!("failed to derive delegation signing key: {e}")))?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let exp = now + crate::spaces::credential::GRANT_TTL_SECS; + let exp = now + crate::spaces::credential::DELEGATION_TOKEN_TTL_SECS; let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); - let grant_claims = crate::spaces::credential::MemberGrantClaims { - sub: did, - space: space_uri, - scope: "read".into(), + let space_host = format!("{}#atproto_space_host", space.did); + let delegation_claims = crate::spaces::credential::DelegationTokenClaims { + iss: did, + sub: space_uri, + aud: space_host, iat: now, exp, + jti: crate::spaces::credential::make_jti(), }; - let grant = crate::spaces::credential::sign_grant(&grant_claims, encryption_key)?; + let grant = crate::spaces::credential::sign_delegation_token(&delegation_claims, &signing_key)?; let expires_at = chrono::DateTime::from_timestamp(exp as i64, 0) .map(|dt| dt.to_rfc3339()) .unwrap_or_default(); Ok(Json(serde_json::json!({ - "grant": grant, + "delegationToken": grant, "expiresAt": expires_at, }))) } +// --------------------------------------------------------------------------- +// Protocol endpoint implementations +// --------------------------------------------------------------------------- + +async fn get_repo_state( + State(state): State, + claims: XrpcClaims, + Query(params): Query, +) -> Result { + let did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, ¶ms.space).await?; + let has_credential = claims.space_credential.is_some(); + let membership = require_membership( + &state, + &space, + &did, + false, + claims.space_credential.as_deref(), + ) + .await?; + + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, ¶ms.did, read_access, has_credential)?; + + let repo_state = + db::get_or_create_repo_state(&state.db, state.db_backend, &space.id, ¶ms.did).await?; + + Ok(Json(serde_json::json!({ + "rev": repo_state.rev, + "commit": repo_state.hash.as_ref().map(|h| { + serde_json::json!({ + "hash": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(h), + "ikm": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.ikm.as_deref().unwrap_or_default()), + "sig": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.sig.as_deref().unwrap_or_default()), + "mac": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.mac.as_deref().unwrap_or_default()), + "rev": repo_state.rev, + }) + }), + }))) +} + +async fn list_repo_ops( + State(state): State, + claims: XrpcClaims, + Query(params): Query, +) -> Result { + let did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, ¶ms.space).await?; + let has_credential = claims.space_credential.is_some(); + let membership = require_membership( + &state, + &space, + &did, + false, + claims.space_credential.as_deref(), + ) + .await?; + + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, ¶ms.did, read_access, has_credential)?; + + let limit = params.limit.unwrap_or(100).min(1000); + let ops = oplog::list_ops( + &state.db, + state.db_backend, + &space.id, + ¶ms.did, + params.cursor.as_deref(), + limit, + ) + .await?; + + Ok(Json(serde_json::json!({ "ops": ops }))) +} + +async fn list_repos( + State(state): State, + claims: XrpcClaims, + Query(params): Query, +) -> Result { + let _did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, ¶ms.space).await?; + + let repos = db::list_space_repos(&state.db, state.db_backend, &space.id).await?; + Ok(Json(serde_json::json!({ "repos": repos }))) +} + +async fn get_space_blob( + State(state): State, + claims: XrpcClaims, + Query(params): Query, +) -> Result { + let did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, ¶ms.space).await?; + let has_credential = claims.space_credential.is_some(); + let membership = require_membership( + &state, + &space, + &did, + false, + claims.space_credential.as_deref(), + ) + .await?; + + let author_did = db::find_blob_author_did(&state.db, state.db_backend, &space.id, ¶ms.cid) + .await? + .ok_or_else(|| AppError::NotFound("Blob not found in this space".into()))?; + + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, &author_did, read_access, has_credential)?; + + let pds_endpoint = + crate::profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, &author_did) + .await?; + + let url = format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + pds_endpoint, + urlencoding::encode(&author_did), + urlencoding::encode(¶ms.cid), + ); + + let resp = state + .http + .get(&url) + .send() + .await + .map_err(|e| AppError::BadGateway(format!("blob fetch failed: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(AppError::BadGateway(format!( + "PDS returned {status} for blob cid={}", + params.cid + ))); + } + + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let bytes = resp + .bytes() + .await + .map_err(|e| AppError::BadGateway(format!("failed to read blob body: {e}")))?; + + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + content_type + .parse() + .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()), + ); + + Ok((status, headers, bytes)) +} + +async fn register_notify( + State(state): State, + claims: XrpcClaims, + Json(input): Json, +) -> Result { + let did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, &input.space).await?; + + let id = notifications::register( + &state.db, + state.db_backend, + &space.id, + &input.service_did, + &input.endpoint, + &did, + ) + .await?; + + Ok(Json(serde_json::json!({ "id": id }))) +} + +async fn notify_write( + State(state): State, + _claims: XrpcClaims, + Json(input): Json, +) -> Result { + let space = resolve_space(&state, &input.space).await?; + + notifications::dispatch_write_notification( + &state.db, + state.db_backend, + &state.http, + &space.id, + &input.did, + &input.collection, + &input.rkey, + input.cid.as_deref(), + ) + .await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn notify_space_deleted( + State(state): State, + _claims: XrpcClaims, + Json(input): Json, +) -> Result { + let space = resolve_space(&state, &input.space).await?; + + notifications::dispatch_space_deleted(&state.db, state.db_backend, &state.http, &space.id) + .await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + async fn get_space_credential( State(state): State, xrpc_claims: XrpcClaims, @@ -1230,18 +1345,44 @@ let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?; - let grant_claims = crate::spaces::credential::verify_grant(&input.grant, encryption_key)?; + let verifying_key = { + let signing_key = + k256::ecdsa::SigningKey::from_bytes(encryption_key.into()).map_err(|e| { + AppError::Internal(format!("failed to derive delegation signing key: {e}")) + })?; + k256::ecdsa::VerifyingKey::from(&signing_key) + }; + + let delegation_claims = { + let unverified_sub = crate::spaces::credential::peek_delegation_sub(&input.grant) + .ok_or_else(|| AppError::Auth("invalid delegation token".into()))?; + let space_did = crate::spaces::SpaceUri::parse(&unverified_sub) + .map(|u| u.did.clone()) + .unwrap_or_default(); + let expected_aud = format!("{space_did}#atproto_space_host"); + crate::spaces::credential::verify_delegation_token( + &input.grant, + &verifying_key, + &expected_aud, + )? + }; - let space = resolve_space(&state, &grant_claims.space).await?; + let space = resolve_space(&state, &delegation_claims.sub).await?; - let client_id = claims.client_key().map(|k| k.to_string()); + let client_id = if let Some(key) = claims.client_key() { + resolve_client_id_url(&state, key).await? + } else { + None + }; let issued = crate::spaces::auth::issue_credential( &state.db, state.db_backend, + &state.http, encryption_key, &space, - &grant_claims.sub, + &delegation_claims.iss, client_id.as_deref(), + &space.authority_did, ) .await?; diff --git a/src/spaces/scope.rs b/src/spaces/scope.rs new file mode 100644 --- /dev/null +++ b/src/spaces/scope.rs @@ -0,0 +1,141 @@ +use crate::error::AppError; +use crate::spaces::types::SpaceAccess; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpaceReadAccess { + Read, // whole space — can getDelegationToken, read any repo + ReadSelf, // own repo only — no delegation token, only own records +} + +impl SpaceReadAccess { + pub fn from_space_access(access: SpaceAccess) -> Self { + match access { + SpaceAccess::ReadSelf => SpaceReadAccess::ReadSelf, + SpaceAccess::Read | SpaceAccess::Write => SpaceReadAccess::Read, + } + } +} + +/// Check whether the caller may read a specific target repo. +/// +/// Space credentials always grant full read (they were already authorized by the +/// credential issuance flow). OAuth/session callers are limited by their membership +/// access level. +pub fn check_read_access( + caller_did: &str, + target_repo_did: &str, + access: SpaceReadAccess, + has_space_credential: bool, +) -> Result<(), AppError> { + if has_space_credential { + return Ok(()); + } + match access { + SpaceReadAccess::Read => Ok(()), + SpaceReadAccess::ReadSelf => { + if caller_did == target_repo_did { + Ok(()) + } else { + Err(AppError::Forbidden( + "read_self access only permits reading your own repo".into(), + )) + } + } + } +} + +/// Check whether the caller may call getDelegationToken. +/// +/// Requires full `read` access — `read_self` members cannot obtain delegation tokens. +pub fn check_delegation_token_access( + access: SpaceReadAccess, + has_space_credential: bool, +) -> Result<(), AppError> { + if has_space_credential { + return Ok(()); + } + match access { + SpaceReadAccess::Read => Ok(()), + SpaceReadAccess::ReadSelf => Err(AppError::Forbidden( + "read_self access does not permit obtaining delegation tokens".into(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_access_allows_any_repo() { + assert!( + check_read_access("did:plc:alice", "did:plc:bob", SpaceReadAccess::Read, false).is_ok() + ); + } + + #[test] + fn read_self_allows_own_repo() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:alice", + SpaceReadAccess::ReadSelf, + false + ) + .is_ok() + ); + } + + #[test] + fn read_self_denies_other_repo() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + false + ) + .is_err() + ); + } + + #[test] + fn space_credential_bypasses_read_self() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + true + ) + .is_ok() + ); + } + + #[test] + fn delegation_token_requires_read() { + assert!(check_delegation_token_access(SpaceReadAccess::Read, false).is_ok()); + assert!(check_delegation_token_access(SpaceReadAccess::ReadSelf, false).is_err()); + } + + #[test] + fn delegation_token_space_credential_bypasses() { + assert!(check_delegation_token_access(SpaceReadAccess::ReadSelf, true).is_ok()); + } + + #[test] + fn from_space_access_mapping() { + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Read), + SpaceReadAccess::Read + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Write), + SpaceReadAccess::Read + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::ReadSelf), + SpaceReadAccess::ReadSelf + ); + } +} diff --git a/src/spaces/simplespace.rs b/src/spaces/simplespace.rs new file mode 100644 --- /dev/null +++ b/src/spaces/simplespace.rs @@ -0,0 +1,466 @@ +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::db::now_rfc3339; +use crate::error::AppError; +use crate::spaces::types::*; +use crate::spaces::{SpaceUri, db, members}; + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateSpaceInput { + #[serde(rename = "type")] + pub type_nsid: String, + pub skey: String, + pub display_name: Option, + pub description: Option, + pub mint_policy: Option, + pub app_access: Option, + pub managing_app_did: Option, + pub config: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SpaceUriQuery { + pub space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeleteSpaceInput { + pub space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateSpaceInput { + pub space: String, + pub display_name: Option>, + pub description: Option>, + pub mint_policy: Option, + pub app_access: Option, + pub managing_app_did: Option>, + pub config: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AddMemberInput { + pub space: String, + pub did: String, + pub access: Option, + pub is_delegation: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RemoveMemberInput { + pub space: String, + pub did: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateConfigInput { + pub space: String, + pub mint_policy: Option, + pub app_access: Option, + pub managing_app: Option>, +} + +// --------------------------------------------------------------------------- +// Route registration +// --------------------------------------------------------------------------- + +const NS: &str = "com.atproto"; +const LEGACY_NS: &str = "dev.happyview"; + +pub fn simplespace_routes() -> Router { + Router::new() + // Management routes (com.atproto.simplespace.*) + .route( + &format!("/xrpc/{NS}.simplespace.createSpace"), + post(create_space), + ) + .route( + &format!("/xrpc/{NS}.simplespace.updateSpace"), + post(update_space), + ) + .route( + &format!("/xrpc/{NS}.simplespace.deleteSpace"), + post(delete_space), + ) + .route( + &format!("/xrpc/{NS}.simplespace.addMember"), + post(add_member), + ) + .route( + &format!("/xrpc/{NS}.simplespace.removeMember"), + post(remove_member), + ) + .route( + &format!("/xrpc/{NS}.simplespace.listMembers"), + get(list_members), + ) + .route( + &format!("/xrpc/{NS}.simplespace.getConfig"), + get(get_config), + ) + .route( + &format!("/xrpc/{NS}.simplespace.updateConfig"), + post(update_config), + ) + // Backward-compatible aliases (dev.happyview.space.*) — kept until v3 + .route( + &format!("/xrpc/{LEGACY_NS}.space.createSpace"), + post(create_space), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.updateSpace"), + post(update_space), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.deleteSpace"), + post(delete_space), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.addMember"), + post(add_member), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.removeMember"), + post(remove_member), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.listMembers"), + get(list_members), + ) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { + claims + .identity + .as_ref() + .ok_or_else(|| AppError::Auth("This endpoint requires authentication".into())) +} + +async fn resolve_space(state: &AppState, space_uri: &str) -> Result { + let uri = SpaceUri::parse(space_uri)?; + db::get_space_by_address( + &state.db, + state.db_backend, + &uri.did, + &uri.type_nsid, + &uri.skey, + ) + .await? + .ok_or_else(|| AppError::NotFound("Space not found".into())) +} + +async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Result<(), AppError> { + use crate::db::adapt_sql; + if space.authority_did == did { + return Ok(()); + } + let sql = adapt_sql( + "SELECT is_super FROM happyview_users WHERE did = ?", + state.db_backend, + ); + let row: Option<(i32,)> = sqlx::query_as(&sql) + .bind(did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check admin status: {e}")))?; + if row.is_some_and(|(is_super,)| is_super != 0) { + return Ok(()); + } + Err(AppError::Forbidden( + "Only the space authority can perform this action".into(), + )) +} + +// --------------------------------------------------------------------------- +// Space management handlers +// --------------------------------------------------------------------------- + +async fn create_space( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + + if input.type_nsid.is_empty() || input.skey.is_empty() { + return Err(AppError::BadRequest("type and skey are required".into())); + } + + let existing = db::get_space_by_address( + &state.db, + state.db_backend, + &did, + &input.type_nsid, + &input.skey, + ) + .await?; + if existing.is_some() { + return Err(AppError::Conflict( + "A space with this address already exists".into(), + )); + } + + // Optionally resolve the space type declaration from the lexicon registry. + // If the type NSID maps to a stored space declaration, use its collections + // as the default `allowed_collections` in the space config. + let mut config = input.config.unwrap_or_default(); + if let Some(decl) = state.lexicons.get_space_declaration(&input.type_nsid).await + && let Some(collections) = decl.space_collections + && !collections.is_empty() + && !config.extra.contains_key("allowedCollections") + { + config.extra.insert( + "allowedCollections".to_string(), + serde_json::Value::Array( + collections + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + ); + } + + let space = Space { + id: Uuid::new_v4().to_string(), + did: did.clone(), + authority_did: did.clone(), + creator_did: did.clone(), + type_nsid: input.type_nsid, + skey: input.skey, + display_name: input.display_name, + description: input.description, + mint_policy: input.mint_policy.unwrap_or(MintPolicy::MemberList), + app_access: input.app_access.unwrap_or_default(), + managing_app_did: input.managing_app_did, + config, + revision: None, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + }; + + db::create_space(&state.db, state.db_backend, &space).await?; + + // Auto-provision #atproto_space verification method if TOKEN_ENCRYPTION_KEY is available + if let Some(encryption_key) = &state.config.token_encryption_key + && let Err(e) = crate::verification_methods::ensure_atproto_space_method( + &state.db, + state.db_backend, + encryption_key, + ) + .await + { + tracing::warn!("failed to auto-provision #atproto_space verification method: {e}"); + } + + let member = SpaceMember { + id: Uuid::new_v4().to_string(), + space_id: space.id.clone(), + did: did.clone(), + access: SpaceAccess::Write, + is_delegation: false, + granted_by: Some(did), + created_at: now_rfc3339(), + }; + db::add_member(&state.db, state.db_backend, &member).await?; + + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let body = serde_json::json!({ + "uri": space_uri, + }); + + let mut response = Json(body).into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn delete_space( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + db::delete_space(&state.db, state.db_backend, &space.id).await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn update_space( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let mut space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + if let Some(name) = input.display_name { + space.display_name = name; + } + if let Some(desc) = input.description { + space.description = desc; + } + if let Some(policy) = input.mint_policy { + space.mint_policy = policy; + } + if let Some(access) = input.app_access { + space.app_access = access; + } + if let Some(did) = input.managing_app_did { + space.managing_app_did = did; + } + if let Some(config) = input.config { + space.config = config; + } + + db::update_space(&state.db, state.db_backend, &space).await?; + + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + Ok(Json(serde_json::json!({ + "uri": space_uri, + "space": space, + }))) +} + +async fn list_members( + State(state): State, + xrpc_claims: XrpcClaims, + Query(query): Query, +) -> Result, AppError> { + let space = resolve_space(&state, &query.space).await?; + + if !space.config.membership_public { + let claims = require_auth(&xrpc_claims)?; + let member = + members::is_member(&state.db, state.db_backend, &space.id, claims.did()).await?; + member.ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; + } + + let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; + + Ok(Json(serde_json::json!({ "members": resolved }))) +} + +async fn add_member( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let existing = db::get_member(&state.db, state.db_backend, &space.id, &input.did).await?; + if existing.is_some() { + return Err(AppError::Conflict( + "Member already exists in this space".into(), + )); + } + + let member = SpaceMember { + id: Uuid::new_v4().to_string(), + space_id: space.id, + did: input.did, + access: input.access.unwrap_or(SpaceAccess::Read), + is_delegation: input.is_delegation.unwrap_or(false), + granted_by: Some(claims.did().to_string()), + created_at: now_rfc3339(), + }; + + db::add_member(&state.db, state.db_backend, &member).await?; + + let mut response = Json(serde_json::json!({ "member": member })).into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn remove_member( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let removed = db::remove_member(&state.db, state.db_backend, &space.id, &input.did).await?; + + if !removed { + return Err(AppError::NotFound("Member not found in this space".into())); + } + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn get_config( + State(state): State, + xrpc_claims: XrpcClaims, + Query(query): Query, +) -> Result, AppError> { + let space = resolve_space(&state, &query.space).await?; + let claims = require_auth(&xrpc_claims)?; + require_space_admin(&state, &space, claims.did()).await?; + + Ok(Json(serde_json::json!({ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": space.mint_policy, + "appAccess": space.app_access, + "managingApp": space.managing_app_did, + }))) +} + +async fn update_config( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let mut space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + if let Some(policy) = input.mint_policy { + space.mint_policy = policy; + } + if let Some(access) = input.app_access { + space.app_access = access; + } + if let Some(managing_app) = input.managing_app { + space.managing_app_did = managing_app; + } + + db::update_space(&state.db, state.db_backend, &space).await?; + + Ok(Json(serde_json::json!({ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": space.mint_policy, + "appAccess": space.app_access, + "managingApp": space.managing_app_did, + }))) +} diff --git a/src/spaces/types.rs b/src/spaces/types.rs --- a/src/spaces/types.rs +++ b/src/spaces/types.rs @@ -2,9 +2,10 @@ use serde::{Deserialize, Serialize}; use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum SpaceAccess { Read, + ReadSelf, Write, } @@ -12,6 +13,7 @@ impl SpaceAccess { pub fn as_str(&self) -> &'static str { match self { SpaceAccess::Read => "read", + SpaceAccess::ReadSelf => "read_self", SpaceAccess::Write => "write", } } @@ -19,6 +21,7 @@ pub fn parse(s: &str) -> Option { match s { "read" => Some(SpaceAccess::Read), + "read_self" => Some(SpaceAccess::ReadSelf), "write" => Some(SpaceAccess::Write), _ => None, } @@ -40,48 +43,119 @@ } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AccessMode { - DefaultAllow, - DefaultDeny, +pub enum MintPolicy { + #[serde(rename = "member-list")] + MemberList, + #[serde(rename = "public")] + Public, + #[serde(rename = "managing-app")] + ManagingApp, } -impl AccessMode { +impl MintPolicy { pub fn as_str(&self) -> &'static str { match self { - AccessMode::DefaultAllow => "default_allow", - AccessMode::DefaultDeny => "default_deny", + MintPolicy::MemberList => "member-list", + MintPolicy::Public => "public", + MintPolicy::ManagingApp => "managing-app", } } pub fn parse(s: &str) -> Option { match s { - "default_allow" => Some(AccessMode::DefaultAllow), - "default_deny" => Some(AccessMode::DefaultDeny), + "member-list" => Some(MintPolicy::MemberList), + "public" => Some(MintPolicy::Public), + "managing-app" => Some(MintPolicy::ManagingApp), _ => None, } } } -impl fmt::Display for AccessMode { +impl fmt::Display for MintPolicy { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum AppAccess { + #[default] + Open, + AllowList { + allowed: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OplogAction { + Create, + Update, + Delete, +} + +impl OplogAction { + pub fn as_str(&self) -> &'static str { + match self { + OplogAction::Create => "create", + OplogAction::Update => "update", + OplogAction::Delete => "delete", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "create" => Some(OplogAction::Create), + "update" => Some(OplogAction::Update), + "delete" => Some(OplogAction::Delete), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OplogEntry { + pub id: String, + pub space_id: String, + pub author_did: String, + pub rev: String, + pub idx: i32, + pub action: OplogAction, + pub collection: String, + pub rkey: String, + pub cid: Option, + pub prev: Option, + pub created_at: String, +} + +#[derive(Debug, Clone)] +pub struct RepoState { + pub id: String, + pub space_id: String, + pub author_did: String, + pub lthash_state: Vec, + pub rev: Option, + pub hash: Option>, + pub ikm: Option>, + pub sig: Option>, + pub mac: Option>, + pub updated_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Space { pub id: String, pub did: String, - pub owner_did: String, + pub authority_did: String, + pub creator_did: String, #[serde(rename = "type")] pub type_nsid: String, pub skey: String, pub display_name: Option, pub description: Option, - pub access_mode: AccessMode, - pub app_allowlist: Option>, - pub app_denylist: Option>, + pub mint_policy: MintPolicy, + pub app_access: AppAccess, pub managing_app_did: Option, pub config: SpaceConfig, pub revision: Option, @@ -129,6 +203,17 @@ pub indexed_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotifyRegistration { + pub id: String, + pub space_id: String, + pub author_did: Option, + pub endpoint: String, + pub registered_by: String, + pub expires_at: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceInvite { pub id: String, pub space_id: String, @@ -149,10 +234,12 @@ #[test] fn space_access_roundtrip() { assert_eq!(SpaceAccess::parse("read"), Some(SpaceAccess::Read)); + assert_eq!(SpaceAccess::parse("read_self"), Some(SpaceAccess::ReadSelf)); assert_eq!(SpaceAccess::parse("write"), Some(SpaceAccess::Write)); assert_eq!(SpaceAccess::parse("admin"), None); assert_eq!(SpaceAccess::Read.as_str(), "read"); + assert_eq!(SpaceAccess::ReadSelf.as_str(), "read_self"); assert_eq!(SpaceAccess::Write.as_str(), "write"); } @@ -160,21 +247,71 @@ #[test] fn space_access_permissions() { assert!(SpaceAccess::Read.can_read()); assert!(!SpaceAccess::Read.can_write()); + assert!(SpaceAccess::ReadSelf.can_read()); + assert!(!SpaceAccess::ReadSelf.can_write()); assert!(SpaceAccess::Write.can_read()); assert!(SpaceAccess::Write.can_write()); } #[test] - fn access_mode_roundtrip() { + fn mint_policy_roundtrip() { assert_eq!( - AccessMode::parse("default_allow"), - Some(AccessMode::DefaultAllow) + MintPolicy::parse("member-list"), + Some(MintPolicy::MemberList) ); + assert_eq!(MintPolicy::parse("public"), Some(MintPolicy::Public)); assert_eq!( - AccessMode::parse("default_deny"), - Some(AccessMode::DefaultDeny) + MintPolicy::parse("managing-app"), + Some(MintPolicy::ManagingApp) ); - assert_eq!(AccessMode::parse("open"), None); + assert_eq!(MintPolicy::parse("invalid"), None); + + assert_eq!(MintPolicy::MemberList.as_str(), "member-list"); + assert_eq!(MintPolicy::Public.as_str(), "public"); + assert_eq!(MintPolicy::ManagingApp.as_str(), "managing-app"); + } + + #[test] + fn mint_policy_serialization() { + let json = serde_json::to_string(&MintPolicy::MemberList).unwrap(); + assert_eq!(json, "\"member-list\""); + let parsed: MintPolicy = serde_json::from_str("\"public\"").unwrap(); + assert_eq!(parsed, MintPolicy::Public); + } + + #[test] + fn app_access_open_serialization() { + let access = AppAccess::Open; + let json = serde_json::to_string(&access).unwrap(); + assert_eq!(json, r#"{"type":"open"}"#); + let parsed: AppAccess = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, AppAccess::Open)); + } + + #[test] + fn app_access_allowlist_serialization() { + let access = AppAccess::AllowList { + allowed: vec!["https://app.example.com/client-metadata.json".into()], + }; + let json = serde_json::to_string(&access).unwrap(); + let parsed: AppAccess = serde_json::from_str(&json).unwrap(); + match parsed { + AppAccess::AllowList { allowed } => { + assert_eq!( + allowed, + vec!["https://app.example.com/client-metadata.json"] + ); + } + _ => panic!("expected AllowList"), + } + } + + #[test] + fn oplog_action_roundtrip() { + assert_eq!(OplogAction::parse("create"), Some(OplogAction::Create)); + assert_eq!(OplogAction::parse("update"), Some(OplogAction::Update)); + assert_eq!(OplogAction::parse("delete"), Some(OplogAction::Delete)); + assert_eq!(OplogAction::parse("invalid"), None); } #[test] @@ -198,11 +335,17 @@ fn space_access_serialization() { let json = serde_json::to_string(&SpaceAccess::Read).unwrap(); assert_eq!(json, "\"read\""); + let json = serde_json::to_string(&SpaceAccess::ReadSelf).unwrap(); + assert_eq!(json, "\"read_self\""); + let json = serde_json::to_string(&SpaceAccess::Write).unwrap(); assert_eq!(json, "\"write\""); let parsed: SpaceAccess = serde_json::from_str("\"read\"").unwrap(); assert_eq!(parsed, SpaceAccess::Read); + + let parsed: SpaceAccess = serde_json::from_str("\"read_self\"").unwrap(); + assert_eq!(parsed, SpaceAccess::ReadSelf); let parsed: SpaceAccess = serde_json::from_str("\"write\"").unwrap(); assert_eq!(parsed, SpaceAccess::Write); diff --git a/src/verification_methods.rs b/src/verification_methods.rs new file mode 100644 --- /dev/null +++ b/src/verification_methods.rs @@ -0,0 +1,249 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use chrono::Utc; +use p256::ecdsa::SigningKey; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sqlx::AnyPool; +use uuid::Uuid; + +use crate::db::{DatabaseBackend, adapt_sql}; +use crate::error::AppError; +use crate::plc::private_key_to_did_key; +use crate::plugin::encryption::{decrypt, encrypt}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationMethod { + pub id: String, + pub fragment_id: String, + pub key_type: String, + pub public_key_multibase: String, + pub created_at: String, +} + +type VerificationMethodRow = (String, String, String, String, String); + +fn parse_row(r: VerificationMethodRow) -> VerificationMethod { + VerificationMethod { + id: r.0, + fragment_id: r.1, + key_type: r.2, + public_key_multibase: r.3, + created_at: r.4, + } +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +pub async fn list_methods( + db: &AnyPool, + backend: DatabaseBackend, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, fragment_id, key_type, public_key_multibase, created_at FROM happyview_verification_methods ORDER BY created_at", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(db) + .await + .map_err(|e| AppError::Internal(format!("failed to list verification methods: {e}")))?; + + Ok(rows.into_iter().map(parse_row).collect()) +} + +pub async fn get_method_by_fragment( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, fragment_id, key_type, public_key_multibase, created_at FROM happyview_verification_methods WHERE fragment_id = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(fragment_id) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to get verification method: {e}")))?; + + Ok(row.map(parse_row)) +} + +pub async fn create_method( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, + encryption_key: &[u8; 32], +) -> Result { + let (private_key_bytes, public_key_multibase) = generate_p256_keypair()?; + + let encrypted = encrypt(encryption_key, &private_key_bytes) + .map_err(|e| AppError::Internal(format!("failed to encrypt verification key: {e}")))?; + let encrypted_b64 = STANDARD.encode(&encrypted); + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + + let sql = adapt_sql( + "INSERT INTO happyview_verification_methods (id, fragment_id, key_type, public_key_multibase, private_key_enc, created_at) VALUES (?, ?, 'Multikey', ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(&id) + .bind(fragment_id) + .bind(&public_key_multibase) + .bind(encrypted_b64.as_bytes()) + .bind(&now) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to create verification method: {e}")))?; + + Ok(VerificationMethod { + id, + fragment_id: fragment_id.to_string(), + key_type: "Multikey".to_string(), + public_key_multibase, + created_at: now, + }) +} + +pub async fn delete_method( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM happyview_verification_methods WHERE fragment_id = ?", + backend, + ); + + let result = sqlx::query(&sql) + .bind(fragment_id) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to delete verification method: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +pub async fn get_private_key_bytes( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, + encryption_key: &[u8; 32], +) -> Result>, AppError> { + let sql = adapt_sql( + "SELECT private_key_enc FROM happyview_verification_methods WHERE fragment_id = ?", + backend, + ); + + let row: Option<(Vec,)> = sqlx::query_as(&sql) + .bind(fragment_id) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch verification key: {e}")))?; + + let Some((encrypted_raw,)) = row else { + return Ok(None); + }; + + let encrypted_b64 = String::from_utf8(encrypted_raw) + .map_err(|e| AppError::Internal(format!("invalid private_key_enc encoding: {e}")))?; + let encrypted = STANDARD + .decode(&encrypted_b64) + .map_err(|e| AppError::Internal(format!("failed to decode private_key_enc: {e}")))?; + let key_bytes = decrypt(encryption_key, &encrypted) + .map_err(|e| AppError::Internal(format!("failed to decrypt verification key: {e}")))?; + + Ok(Some(key_bytes)) +} + +// --------------------------------------------------------------------------- +// Key generation +// --------------------------------------------------------------------------- + +fn generate_p256_keypair() -> Result<(Vec, String), AppError> { + let mut rng_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut rng_bytes); + + let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into()) + .map_err(|e| AppError::Internal(format!("failed to generate verification key: {e}")))?; + + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + + // Multikey format: 0x8024 varint for P-256 + compressed public key, base58btc + let mut multikey_bytes = vec![0x80, 0x24]; + multikey_bytes.extend_from_slice(compressed.as_bytes()); + let public_key_multibase = multibase::encode(multibase::Base::Base58Btc, &multikey_bytes); + + Ok((rng_bytes.to_vec(), public_key_multibase)) +} + +pub fn private_key_bytes_to_signing_key(key_bytes: &[u8]) -> Result { + SigningKey::from_bytes(key_bytes.into()) + .map_err(|e| AppError::Internal(format!("invalid verification signing key: {e}"))) +} + +pub fn private_key_bytes_to_did_key(key_bytes: &[u8]) -> Result { + private_key_to_did_key(key_bytes) +} + +// --------------------------------------------------------------------------- +// Auto-provision +// --------------------------------------------------------------------------- + +/// Ensure `#atproto_space` verification method exists; create it if not. +pub async fn ensure_atproto_space_method( + db: &AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], +) -> Result { + if let Some(existing) = get_method_by_fragment(db, backend, "#atproto_space").await? { + return Ok(existing); + } + create_method(db, backend, "#atproto_space", encryption_key).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_p256_keypair_produces_multibase_key() { + let (key_bytes, multibase) = generate_p256_keypair().unwrap(); + assert_eq!(key_bytes.len(), 32); + // base58btc multibase starts with 'z' + assert!( + multibase.starts_with('z'), + "expected base58btc prefix: {multibase}" + ); + } + + #[test] + fn private_key_bytes_to_signing_key_roundtrip() { + let (key_bytes, _) = generate_p256_keypair().unwrap(); + let signing_key = private_key_bytes_to_signing_key(&key_bytes).unwrap(); + // Re-derive bytes should equal original + assert_eq!(signing_key.to_bytes().as_slice(), key_bytes.as_slice()); + } + + #[test] + fn private_key_bytes_to_did_key_format() { + let (key_bytes, _) = generate_p256_keypair().unwrap(); + let did_key = private_key_bytes_to_did_key(&key_bytes).unwrap(); + assert!( + did_key.starts_with("did:key:z"), + "expected did:key: prefix: {did_key}" + ); + } +} diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -59,7 +59,7 @@ } // Check token scope against outbound XRPCs declared by the script let outbound_sql = adapt_sql( - "SELECT outbound_xrpcs FROM scripts WHERE id = ?", + "SELECT outbound_xrpcs FROM happyview_scripts WHERE id = ?", state.db_backend, ); if let Ok(Some((Some(json_str),))) = sqlx::query_as::<_, (Option,)>(&outbound_sql) @@ -246,7 +246,7 @@ let rkey = uri.split('/').next_back().unwrap_or_default(); let record_str = serde_json::to_string(&record).unwrap_or_default(); let sql = adapt_sql( r#" - INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) + INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -334,7 +334,7 @@ let record_str = serde_json::to_string(&record).unwrap_or_default(); let now = now_rfc3339(); let sql = adapt_sql( r#" - INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) + INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, @@ -401,7 +401,7 @@ .await .map_err(|e| AppError::Internal(format!("failed to read PDS response: {e}")))?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); + let sql = adapt_sql("DELETE FROM happyview_records WHERE uri = ?", backend); let _ = sqlx::query(&sql).bind(uri).execute(&state.db).await; Ok(( diff --git a/src/xrpc/query.rs b/src/xrpc/query.rs --- a/src/xrpc/query.rs +++ b/src/xrpc/query.rs @@ -66,7 +66,7 @@ let backend = state.db_backend; let rows: Vec<(String, String, String)> = if let Some(did) = did { let sql = adapt_sql( - "SELECT uri, did, record FROM records WHERE collection = ? AND did = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", + "SELECT uri, did, record FROM happyview_records WHERE collection = ? AND did = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", backend, ); sqlx::query_as(&sql) @@ -79,7 +79,7 @@ .await .map_err(|e| AppError::Internal(format!("DB query failed: {e}")))? } else { let sql = adapt_sql( - "SELECT uri, did, record FROM records WHERE collection = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", + "SELECT uri, did, record FROM happyview_records WHERE collection = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", backend, ); sqlx::query_as(&sql) @@ -118,7 +118,10 @@ } pub(super) async fn handle_get_record(state: &AppState, uri: &str) -> Result { let backend = state.db_backend; - let sql = adapt_sql("SELECT record FROM records WHERE uri = ?", backend); + let sql = adapt_sql( + "SELECT record FROM happyview_records WHERE uri = ?", + backend, + ); let row: Option<(String,)> = sqlx::query_as(&sql) .bind(uri) .fetch_optional(&state.db) diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -70,7 +70,7 @@ default_rate_limit_refill_rate: 2.0, }; let sql = adapt_sql( - "INSERT INTO users (id, did, is_super, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO happyview_users (id, did, is_super, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); sqlx::query(&sql) @@ -245,7 +245,7 @@ .as_ref() .map(|o| serde_json::to_string(o).unwrap_or_else(|_| "[]".to_string())); let sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", self.state.db_backend, ); diff --git a/tests/common/db.rs b/tests/common/db.rs --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -48,7 +48,7 @@ let backend = test_backend(); match backend { DatabaseBackend::Postgres => { sqlx::query( - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, scripts, dead_letter_scripts, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients, delegated_accounts, account_delegates, service_identity, service_entries, service_entry_xrpcs RESTART IDENTITY CASCADE", + "TRUNCATE happyview_records, happyview_lexicons, happyview_backfill_jobs, happyview_users, happyview_user_permissions, happyview_api_keys, happyview_event_logs, happyview_script_variables, happyview_scripts, happyview_dead_letter_scripts, happyview_dead_letter_hooks, happyview_record_refs, happyview_labeler_subscriptions, happyview_labels, happyview_instance_settings, happyview_domains, happyview_dpop_sessions, happyview_dpop_keys, happyview_api_clients, happyview_delegated_accounts, happyview_account_delegates, happyview_service_identity, happyview_service_entries, happyview_service_entry_xrpcs RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -56,30 +56,30 @@ .expect("failed to truncate tables"); } DatabaseBackend::Sqlite => { let tables = [ - "service_entry_xrpcs", - "service_entries", - "service_identity", - "account_delegates", - "delegated_accounts", - "dpop_sessions", - "dpop_keys", - "api_clients", - "records", - "lexicons", - "backfill_jobs", - "users", - "user_permissions", - "api_keys", - "event_logs", - "script_variables", - "scripts", - "dead_letter_scripts", - "dead_letter_hooks", - "record_refs", - "labeler_subscriptions", - "labels", - "instance_settings", - "domains", + "happyview_service_entry_xrpcs", + "happyview_service_entries", + "happyview_service_identity", + "happyview_account_delegates", + "happyview_delegated_accounts", + "happyview_dpop_sessions", + "happyview_dpop_keys", + "happyview_api_clients", + "happyview_records", + "happyview_lexicons", + "happyview_backfill_jobs", + "happyview_users", + "happyview_user_permissions", + "happyview_api_keys", + "happyview_event_logs", + "happyview_script_variables", + "happyview_scripts", + "happyview_dead_letter_scripts", + "happyview_dead_letter_hooks", + "happyview_record_refs", + "happyview_labeler_subscriptions", + "happyview_labels", + "happyview_instance_settings", + "happyview_domains", ]; for table in tables { sqlx::query(&format!("DELETE FROM {table}")) diff --git a/tests/dev_happyview.rs b/tests/dev_happyview.rs --- a/tests/dev_happyview.rs +++ b/tests/dev_happyview.rs @@ -300,7 +300,7 @@ // Insert a client owned by user_did. let client_id = uuid::Uuid::new_v4().to_string(); let now = happyview::db::now_rfc3339(); let sql = happyview::db::adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", app.state.db_backend, ); @@ -413,7 +413,7 @@ // Insert a child client owned by user_did directly. let client_id = uuid::Uuid::new_v4().to_string(); let now = happyview::db::now_rfc3339(); let sql = happyview::db::adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at, owner_did) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", app.state.db_backend, ); diff --git a/tests/e2e_admin.rs b/tests/e2e_admin.rs --- a/tests/e2e_admin.rs +++ b/tests/e2e_admin.rs @@ -149,7 +149,7 @@ let app = TestApp::new().await; let backend = app.state.db_backend; // Clear the seeded user so the table is empty. - sqlx::query("DELETE FROM users") + sqlx::query("DELETE FROM happyview_users") .execute(&app.state.db) .await .unwrap(); @@ -171,7 +171,10 @@ // The first user should be auto-bootstrapped as admin. assert_eq!(resp.status(), StatusCode::OK); // Verify the DID was inserted. - let sql = adapt_sql("SELECT COUNT(*) FROM users WHERE did = ?", backend); + let sql = adapt_sql( + "SELECT COUNT(*) FROM happyview_users WHERE did = ?", + backend, + ); let count: (i64,) = sqlx::query_as(&sql) .bind(bootstrap_did) .fetch_one(&app.state.db) @@ -447,7 +450,7 @@ "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "properties": {} } } } }); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO lexicons (id, lexicon_json, created_at) VALUES (?, ?, ?)", + "INSERT INTO happyview_lexicons (id, lexicon_json, created_at) VALUES (?, ?, ?)", backend, ); sqlx::query(&sql) @@ -461,7 +464,7 @@ // Seed records directly let record_val = serde_json::json!({"title": "test"}); let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -572,7 +575,7 @@ // Insert a running job directly so we don't need a real relay. let job_id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'running', 'discovering_repos', ?, ?)", + "INSERT INTO happyview_backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'running', 'discovering_repos', ?, ?)", backend, ); sqlx::query(&sql) @@ -610,7 +613,7 @@ let job_id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'cancelling', 'fetching_records', ?, ?)", + "INSERT INTO happyview_backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'cancelling', 'fetching_records', ?, ?)", backend, ); sqlx::query(&sql) @@ -647,7 +650,7 @@ let job_id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO backfill_jobs (id, status, stage, completed_at, created_at) VALUES (?, 'completed', 'completed', ?, ?)", + "INSERT INTO happyview_backfill_jobs (id, status, stage, completed_at, created_at) VALUES (?, 'completed', 'completed', ?, ?)", backend, ); sqlx::query(&sql) diff --git a/tests/e2e_admin_service_entries.rs b/tests/e2e_admin_service_entries.rs --- a/tests/e2e_admin_service_entries.rs +++ b/tests/e2e_admin_service_entries.rs @@ -819,7 +819,7 @@ let rotation_key_enc = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &encrypted); let sql = happyview::db::adapt_sql( - "UPDATE service_identity SET rotation_key_enc = ? WHERE id = 1", + "UPDATE happyview_service_identity SET rotation_key_enc = ? WHERE id = 1", app.state.db_backend, ); sqlx::query(&sql) diff --git a/tests/e2e_api_clients.rs b/tests/e2e_api_clients.rs --- a/tests/e2e_api_clients.rs +++ b/tests/e2e_api_clients.rs @@ -691,7 +691,7 @@ let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_cc".as_bytes())); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql) @@ -796,7 +796,7 @@ let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_dd".as_bytes())); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql) @@ -916,7 +916,7 @@ let child1_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e1".as_bytes())); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql) @@ -944,7 +944,7 @@ let child2_key = format!("hvc_{}", hex::encode([0xE2u8; 16])); let child2_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e2".as_bytes())); let sql2 = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql2) @@ -1045,7 +1045,7 @@ let now = now_rfc3339(); let owner_did = "did:plc:childowner"; let sql = adapt_sql( - "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql) diff --git a/tests/e2e_delegation.rs b/tests/e2e_delegation.rs --- a/tests/e2e_delegation.rs +++ b/tests/e2e_delegation.rs @@ -968,7 +968,7 @@ } async fn update_session_pds_url(app: &common::app::TestApp, user_did: &str, pds_url: &str) { let sql = adapt_sql( - "UPDATE dpop_sessions SET pds_url = ? WHERE user_did = ?", + "UPDATE happyview_dpop_sessions SET pds_url = ? WHERE user_did = ?", app.state.db_backend, ); sqlx::query(&sql) diff --git a/tests/e2e_domains.rs b/tests/e2e_domains.rs --- a/tests/e2e_domains.rs +++ b/tests/e2e_domains.rs @@ -66,7 +66,7 @@ async fn seed_domain(app: &TestApp, id: &str, url: &str, is_primary: bool) { let now = happyview::db::now_rfc3339(); let sql = happyview::db::adapt_sql( - "INSERT INTO domains (id, url, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO happyview_domains (id, url, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql) diff --git a/tests/e2e_feature_flags.rs b/tests/e2e_feature_flags.rs --- a/tests/e2e_feature_flags.rs +++ b/tests/e2e_feature_flags.rs @@ -62,7 +62,7 @@ .router .clone() .oneshot( Request::builder() - .uri("/xrpc/dev.happyview.space.list") + .uri("/xrpc/com.atproto.space.listSpaces") .body(Body::empty()) .unwrap(), ) @@ -99,7 +99,7 @@ .router .clone() .oneshot( Request::builder() - .uri("/xrpc/dev.happyview.space.list") + .uri("/xrpc/com.atproto.space.listSpaces") .body(Body::empty()) .unwrap(), ) @@ -151,7 +151,7 @@ .router .clone() .oneshot( Request::builder() - .uri("/xrpc/dev.happyview.space.list") + .uri("/xrpc/com.atproto.space.listSpaces") .body(Body::empty()) .unwrap(), ) diff --git a/tests/e2e_labelers.rs b/tests/e2e_labelers.rs --- a/tests/e2e_labelers.rs +++ b/tests/e2e_labelers.rs @@ -365,7 +365,7 @@ .unwrap(); // Seed some labels from that labeler let sql = adapt_sql( - "INSERT INTO labels (src, uri, val, cts) VALUES (?, ?, ?, ?)", + "INSERT INTO happyview_labels (src, uri, val, cts) VALUES (?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -389,7 +389,10 @@ .await .unwrap(); // Verify labels were also removed - let sql = adapt_sql("SELECT COUNT(*) FROM labels WHERE src = ?", backend); + let sql = adapt_sql( + "SELECT COUNT(*) FROM happyview_labels WHERE src = ?", + backend, + ); let count: (i64,) = sqlx::query_as(&sql) .bind("did:plc:lab1") .fetch_one(&app.state.db) diff --git a/tests/e2e_network_lexicons.rs b/tests/e2e_network_lexicons.rs --- a/tests/e2e_network_lexicons.rs +++ b/tests/e2e_network_lexicons.rs @@ -48,7 +48,7 @@ let lexicon_json = fixtures::game_record_lexicon(); let backend = app.state.db_backend; let sql = adapt_sql( r#" - INSERT INTO lexicons (id, lexicon_json, backfill, source, authority_did, last_fetched_at, created_at) + INSERT INTO happyview_lexicons (id, lexicon_json, backfill, source, authority_did, last_fetched_at, created_at) VALUES (?, ?, 0, 'network', ?, ?, ?) ON CONFLICT (id) DO NOTHING "#, @@ -136,7 +136,7 @@ assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Verify lexicon is removed. let sql = adapt_sql( - "SELECT COUNT(*) FROM lexicons WHERE id = ? AND source = 'network'", + "SELECT COUNT(*) FROM happyview_lexicons WHERE id = ? AND source = 'network'", backend, ); let count: (i64,) = sqlx::query_as(&sql) diff --git a/tests/e2e_scripts.rs b/tests/e2e_scripts.rs --- a/tests/e2e_scripts.rs +++ b/tests/e2e_scripts.rs @@ -143,7 +143,7 @@ rkey: &str, body: Value, ) { let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", app.state.db_backend, ); @@ -162,7 +162,7 @@ } async fn count_records(app: &TestApp, uri: &str) -> i64 { let (count,): (i64,) = sqlx::query_as(&adapt_sql( - "SELECT COUNT(*) FROM records WHERE uri = ?", + "SELECT COUNT(*) FROM happyview_records WHERE uri = ?", app.state.db_backend, )) .bind(uri) @@ -174,7 +174,7 @@ } async fn fetch_record_body(app: &TestApp, uri: &str) -> Option { let row: Option<(String,)> = sqlx::query_as(&adapt_sql( - "SELECT record FROM records WHERE uri = ?", + "SELECT record FROM happyview_records WHERE uri = ?", app.state.db_backend, )) .bind(uri) @@ -589,7 +589,7 @@ // The script's log("hello from script") should land in event_logs // as a `script.log` row whose subject is the trigger id. let row: (String, String) = sqlx::query_as(&adapt_sql( - "SELECT subject, detail FROM event_logs + "SELECT subject, detail FROM happyview_event_logs WHERE event_type = 'script.log' ORDER BY id DESC LIMIT 1", app.state.db_backend, @@ -713,7 +713,7 @@ "labeler.apply:_actor", // Sentinel: write a row into records-table-as-flag so we can // detect that the script ran. "function handle() \ - db.raw('INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) \ + db.raw('INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) \ VALUES (?, ?, ?, ?, ?, ?, ?)', \ {'at://did:plc:flag/flag.col/k', 'did:plc:flag', 'flag.col', 'k', '{}', 'b', '2026-05-01'}) \ return event \ @@ -794,7 +794,7 @@ assert_eq!(body["text"], "untouched"); // A dead-letter row exists with the NO_PDS_AUTH message. let dl: (String,) = sqlx::query_as(&adapt_sql( - "SELECT error FROM dead_letter_scripts WHERE host_kind = 'label' \ + "SELECT error FROM happyview_dead_letter_scripts WHERE host_kind = 'label' \ AND host_id = 'did:plc:labeler' ORDER BY id DESC LIMIT 1", app.state.db_backend, )) diff --git a/tests/e2e_xrpc.rs b/tests/e2e_xrpc.rs --- a/tests/e2e_xrpc.rs +++ b/tests/e2e_xrpc.rs @@ -87,7 +87,7 @@ async fn seed_record(app: &TestApp, uri: &str, did: &str, collection: &str, record: &Value) { let rkey = uri.split('/').next_back().unwrap_or("1"); let backend = app.state.db_backend; let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -439,7 +439,10 @@ let record = json!({"title": "To Delete", "$type": "games.gamesgamesgamesgames.game"}); seed_record(&app, uri, did, "games.gamesgamesgamesgames.game", &record).await; // Verify record exists - let sql = adapt_sql("SELECT COUNT(*) FROM records WHERE uri = ?", backend); + let sql = adapt_sql( + "SELECT COUNT(*) FROM happyview_records WHERE uri = ?", + backend, + ); let count: (i64,) = sqlx::query_as(&sql) .bind(uri) .fetch_one(&app.state.db) diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -115,7 +115,7 @@ did: &str, record: serde_json::Value, ) { let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -141,7 +141,7 @@ exp: Option<&str>, ) { if let Some(exp) = exp { let sql = adapt_sql( - "INSERT INTO labels (src, uri, val, cts, exp) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO happyview_labels (src, uri, val, cts, exp) VALUES (?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -155,7 +155,7 @@ .await .expect("failed to seed label"); } else { let sql = adapt_sql( - "INSERT INTO labels (src, uri, val, cts) VALUES (?, ?, ?, ?)", + "INSERT INTO happyview_labels (src, uri, val, cts) VALUES (?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -205,7 +205,7 @@ let state = test_state_with_pool(pool, backend).await; let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", + "SELECT src, uri, val FROM happyview_labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -255,7 +255,7 @@ let state = test_state_with_pool(pool, backend).await; let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", + "SELECT src, uri, val FROM happyview_labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -289,7 +289,10 @@ } }); seed_record(&pool, backend, uri, "did:plc:author", record.clone()).await; - let sql = adapt_sql("SELECT did, record FROM records WHERE uri = ?", backend); + let sql = adapt_sql( + "SELECT did, record FROM happyview_records WHERE uri = ?", + backend, + ); let fetched: Option<(String, String)> = sqlx::query_as(&sql) .bind(uri) .fetch_optional(&pool) @@ -332,7 +335,7 @@ .await; let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", + "SELECT src, uri, val FROM happyview_labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -370,7 +373,7 @@ ) .await; let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -391,7 +394,7 @@ seed_label(&pool, backend, "did:plc:labeler2", uri2, "violence", None).await; let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri IN (?, ?) AND (exp IS NULL OR exp > ?)", + "SELECT src, uri, val FROM happyview_labels WHERE uri IN (?, ?) AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -424,7 +427,7 @@ let uri2 = "at://did:plc:test/test.collection/rkey2"; let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri IN (?, ?) AND (exp IS NULL OR exp > ?)", + "SELECT src, uri, val FROM happyview_labels WHERE uri IN (?, ?) AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -457,7 +460,7 @@ seed_label(&pool, backend, "did:plc:labeler1", uri, "nudity", None).await; // Verify it exists let sql = adapt_sql( - "SELECT COUNT(*) FROM labels WHERE src = ? AND uri = ? AND val = ?", + "SELECT COUNT(*) FROM happyview_labels WHERE src = ? AND uri = ? AND val = ?", backend, ); let count: (i64,) = sqlx::query_as(&sql) @@ -471,7 +474,7 @@ assert_eq!(count.0, 1); // Simulate negation (same logic as labeler.rs) let sql = adapt_sql( - "DELETE FROM labels WHERE src = ? AND uri = ? AND val = ?", + "DELETE FROM happyview_labels WHERE src = ? AND uri = ? AND val = ?", backend, ); sqlx::query(&sql) @@ -484,7 +487,7 @@ .unwrap(); // Verify it's gone let sql = adapt_sql( - "SELECT COUNT(*) FROM labels WHERE src = ? AND uri = ? AND val = ?", + "SELECT COUNT(*) FROM happyview_labels WHERE src = ? AND uri = ? AND val = ?", backend, ); let count: (i64,) = sqlx::query_as(&sql) @@ -513,10 +516,10 @@ let uri = "at://did:plc:test/test.collection/rkey1"; let upsert_sql = match backend { DatabaseBackend::Postgres => { - "INSERT INTO labels (src, uri, val, cts) VALUES ($1, $2, $3, $4) ON CONFLICT (src, uri, val) DO UPDATE SET cts = EXCLUDED.cts".to_string() + "INSERT INTO happyview_labels (src, uri, val, cts) VALUES ($1, $2, $3, $4) ON CONFLICT (src, uri, val) DO UPDATE SET cts = EXCLUDED.cts".to_string() } DatabaseBackend::Sqlite => { - "INSERT INTO labels (src, uri, val, cts) VALUES (?, ?, ?, ?) ON CONFLICT (src, uri, val) DO UPDATE SET cts = excluded.cts".to_string() + "INSERT INTO happyview_labels (src, uri, val, cts) VALUES (?, ?, ?, ?) ON CONFLICT (src, uri, val) DO UPDATE SET cts = excluded.cts".to_string() } }; @@ -533,7 +536,7 @@ .unwrap(); } let sql = adapt_sql( - "SELECT COUNT(*) FROM labels WHERE src = ? AND uri = ? AND val = ?", + "SELECT COUNT(*) FROM happyview_labels WHERE src = ? AND uri = ? AND val = ?", backend, ); let count: (i64,) = sqlx::query_as(&sql) diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -140,7 +140,7 @@ ]; let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); for (uri, did, collection, rkey, record, cid) in &records { @@ -334,7 +334,7 @@ let lua = setup_lua(&state); let result: mlua::Table = lua .load( - r#"return db.raw("SELECT COUNT(*) as cnt FROM records WHERE collection = $1", {"test.collection"})"#, + r#"return db.raw("SELECT COUNT(*) as cnt FROM happyview_records WHERE collection = $1", {"test.collection"})"#, ) .eval_async() .await diff --git a/tests/lua_record_api.rs b/tests/lua_record_api.rs --- a/tests/lua_record_api.rs +++ b/tests/lua_record_api.rs @@ -125,7 +125,7 @@ rkey: &str, record: serde_json::Value, ) { let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); @@ -143,7 +143,10 @@ .expect("failed to seed record"); } async fn count_records(pool: &sqlx::AnyPool, backend: DatabaseBackend, uri: &str) -> i64 { - let sql = adapt_sql("SELECT COUNT(*) FROM records WHERE uri = ?", backend); + let sql = adapt_sql( + "SELECT COUNT(*) FROM happyview_records WHERE uri = ?", + backend, + ); let (count,): (i64,) = sqlx::query_as(&sql) .bind(uri) .fetch_one(pool) @@ -157,7 +160,10 @@ pool: &sqlx::AnyPool, backend: DatabaseBackend, uri: &str, ) -> Option { - let sql = adapt_sql("SELECT record FROM records WHERE uri = ?", backend); + let sql = adapt_sql( + "SELECT record FROM happyview_records WHERE uri = ?", + backend, + ); let row: Option<(String,)> = sqlx::query_as(&sql) .bind(uri) .fetch_optional(pool) diff --git a/tests/plugin_logging.rs b/tests/plugin_logging.rs --- a/tests/plugin_logging.rs +++ b/tests/plugin_logging.rs @@ -61,7 +61,7 @@ flush_spawned_tasks().await; let sql = adapt_sql( - "SELECT severity, subject, detail FROM event_logs WHERE event_type = ? ORDER BY created_at ASC", + "SELECT severity, subject, detail FROM happyview_event_logs WHERE event_type = ? ORDER BY created_at ASC", backend, ); let rows: Vec<(String, Option, String)> = sqlx::query_as(&sql) @@ -122,7 +122,7 @@ flush_spawned_tasks().await; let sql = adapt_sql( - "SELECT COUNT(*) FROM event_logs WHERE event_type = ?", + "SELECT COUNT(*) FROM happyview_event_logs WHERE event_type = ?", backend, ); let count: i64 = sqlx::query_scalar(&sql) diff --git a/tests/spaces_db.rs b/tests/spaces_db.rs new file mode 100644 --- /dev/null +++ b/tests/spaces_db.rs @@ -0,0 +1,653 @@ +mod common; + +use happyview::db::now_rfc3339; +use happyview::spaces::db as spaces_db; +use happyview::spaces::notifications; +use happyview::spaces::oplog; +use happyview::spaces::types::*; +use serial_test::serial; +use uuid::Uuid; + +use common::db as test_db; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn new_id() -> String { + Uuid::new_v4().to_string() +} + +fn make_space(id: &str, did: &str, type_nsid: &str, skey: &str) -> Space { + let now = now_rfc3339(); + Space { + id: id.to_string(), + did: did.to_string(), + authority_did: did.to_string(), + creator_did: did.to_string(), + type_nsid: type_nsid.to_string(), + skey: skey.to_string(), + display_name: Some("Test Space".to_string()), + description: None, + mint_policy: MintPolicy::MemberList, + app_access: AppAccess::Open, + managing_app_did: None, + config: SpaceConfig::default(), + revision: None, + created_at: now.clone(), + updated_at: now, + } +} + +// --------------------------------------------------------------------------- +// Space CRUD +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn create_and_get_space_roundtrip() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let id = new_id(); + let did = "did:plc:spaces-test-owner"; + let space = make_space(&id, did, "com.example.test", "myspace"); + + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let fetched = spaces_db::get_space(&pool, backend, &id) + .await + .expect("get_space failed") + .expect("space not found after creation"); + + assert_eq!(fetched.id, id); + assert_eq!(fetched.did, did); + assert_eq!(fetched.type_nsid, "com.example.test"); + assert_eq!(fetched.skey, "myspace"); + assert_eq!(fetched.display_name, Some("Test Space".to_string())); + assert_eq!(fetched.mint_policy, MintPolicy::MemberList); + assert!(matches!(fetched.app_access, AppAccess::Open)); +} + +#[tokio::test] +#[serial] +async fn get_space_by_address_roundtrip() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let id = new_id(); + let did = "did:plc:addr-test"; + let space = make_space(&id, did, "com.example.addr", "addr-skey"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let fetched = + spaces_db::get_space_by_address(&pool, backend, did, "com.example.addr", "addr-skey") + .await + .expect("get_space_by_address failed") + .expect("space not found by address"); + + assert_eq!(fetched.id, id); +} + +#[tokio::test] +#[serial] +async fn list_spaces_by_owner() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let owner = "did:plc:list-owner"; + let other = "did:plc:other-owner"; + + for i in 0..3 { + let space = make_space(&new_id(), owner, "com.example.list", &format!("space-{i}")); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + } + let space = make_space(&new_id(), other, "com.example.list", "other-space"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let owned = spaces_db::list_spaces_by_owner(&pool, backend, owner) + .await + .expect("list_spaces_by_owner failed"); + assert_eq!(owned.len(), 3); + + let other_owned = spaces_db::list_spaces_by_owner(&pool, backend, other) + .await + .expect("list_spaces_by_owner failed"); + assert_eq!(other_owned.len(), 1); +} + +#[tokio::test] +#[serial] +async fn delete_space_removes_it() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let id = new_id(); + let space = make_space(&id, "did:plc:del-owner", "com.example.del", "del-skey"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let deleted = spaces_db::delete_space(&pool, backend, &id) + .await + .expect("delete_space failed"); + assert!(deleted); + + let after = spaces_db::get_space(&pool, backend, &id) + .await + .expect("get_space failed"); + assert!(after.is_none()); +} + +// --------------------------------------------------------------------------- +// Repo state +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_or_create_repo_state_creates_default() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:repo-owner", + "com.example.repo", + "repo-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:repo-author"; + let state = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("get_or_create_repo_state failed"); + + assert_eq!(state.space_id, space_id); + assert_eq!(state.author_did, author_did); + assert_eq!(state.lthash_state, vec![0u8; 2048]); + assert!(state.rev.is_none()); + assert!(state.hash.is_none()); +} + +#[tokio::test] +#[serial] +async fn get_or_create_repo_state_is_idempotent() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:idem-owner", + "com.example.idem", + "idem-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:idem-author"; + let first = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("first call failed"); + let second = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("second call failed"); + + assert_eq!(first.id, second.id); +} + +#[tokio::test] +#[serial] +async fn update_repo_state_persists_fields() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:update-owner", + "com.example.upd", + "upd-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:update-author"; + let mut state = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("get_or_create failed"); + + state.rev = Some("rev-001".to_string()); + state.hash = Some(vec![0xde, 0xad, 0xbe, 0xef]); + + spaces_db::update_repo_state(&pool, backend, &state) + .await + .expect("update_repo_state failed"); + + let reloaded = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("reload failed"); + + assert_eq!(reloaded.rev, Some("rev-001".to_string())); + assert_eq!(reloaded.hash, Some(vec![0xde, 0xad, 0xbe, 0xef])); +} + +// --------------------------------------------------------------------------- +// Oplog +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn oplog_append_and_list() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:oplog-owner", + "com.example.oplog", + "oplog-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:oplog-author"; + + for (i, action) in [ + OplogAction::Create, + OplogAction::Update, + OplogAction::Delete, + ] + .iter() + .enumerate() + { + let entry = OplogEntry { + id: new_id(), + space_id: space_id.clone(), + author_did: author_did.to_string(), + rev: format!("rev-{:04}", i + 1), + idx: 0, + action: *action, + collection: "com.example.item".to_string(), + rkey: format!("item-{i}"), + cid: Some(format!("bafy{i}")), + prev: if i > 0 { + Some(format!("rev-{:04}", i)) + } else { + None + }, + created_at: now_rfc3339(), + }; + oplog::append_op(&pool, backend, &entry) + .await + .expect("append_op failed"); + } + + let all_ops = oplog::list_ops(&pool, backend, &space_id, author_did, None, 10) + .await + .expect("list_ops failed"); + assert_eq!(all_ops.len(), 3); + assert!(matches!(all_ops[0].action, OplogAction::Create)); + assert!(matches!(all_ops[1].action, OplogAction::Update)); + assert!(matches!(all_ops[2].action, OplogAction::Delete)); +} + +#[tokio::test] +#[serial] +async fn oplog_list_with_since_rev_cursor() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:cursor-owner", + "com.example.cursor", + "cursor-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:cursor-author"; + + for i in 0..5 { + let entry = OplogEntry { + id: new_id(), + space_id: space_id.clone(), + author_did: author_did.to_string(), + rev: format!("rev-{:04}", i + 1), + idx: 0, + action: OplogAction::Create, + collection: "com.example.item".to_string(), + rkey: format!("item-{i}"), + cid: Some(format!("bafy{i}")), + prev: None, + created_at: now_rfc3339(), + }; + oplog::append_op(&pool, backend, &entry) + .await + .expect("append_op failed"); + } + + let after_rev2 = oplog::list_ops(&pool, backend, &space_id, author_did, Some("rev-0002"), 10) + .await + .expect("list_ops with cursor failed"); + + assert_eq!(after_rev2.len(), 3); + assert_eq!(after_rev2[0].rev, "rev-0003"); +} + +// --------------------------------------------------------------------------- +// Notification registrations +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn register_and_list_notify_registrations() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:notify-owner", + "com.example.notify", + "notify-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let service_did = "did:plc:notify-service"; + let endpoint = "https://service.example.com/notify"; + let registered_by = "did:plc:notify-owner"; + + let reg_id = notifications::register( + &pool, + backend, + &space_id, + service_did, + endpoint, + registered_by, + ) + .await + .expect("register failed"); + assert!(!reg_id.is_empty()); + + let all_regs = spaces_db::list_notify_registrations(&pool, backend, &space_id, None) + .await + .expect("list_notify_registrations failed"); + assert_eq!(all_regs.len(), 1); + assert_eq!(all_regs[0].id, reg_id); + assert_eq!(all_regs[0].endpoint, endpoint); + assert_eq!(all_regs[0].author_did, Some(service_did.to_string())); + + let by_did = spaces_db::list_notify_registrations(&pool, backend, &space_id, Some(service_did)) + .await + .expect("list_notify_registrations by did failed"); + assert_eq!(by_did.len(), 1); +} + +#[tokio::test] +#[serial] +async fn delete_notify_registration_removes_it() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:del-notify-owner", + "com.example.delnotify", + "dn-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let reg_id = notifications::register( + &pool, + backend, + &space_id, + "did:plc:svc", + "https://svc.example.com/n", + "did:plc:del-notify-owner", + ) + .await + .expect("register failed"); + + let deleted = spaces_db::delete_notify_registration(&pool, backend, ®_id) + .await + .expect("delete_notify_registration failed"); + assert!(deleted); + + let remaining = spaces_db::list_notify_registrations(&pool, backend, &space_id, None) + .await + .expect("list after delete failed"); + assert!(remaining.is_empty()); +} + +// --------------------------------------------------------------------------- +// Space members +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn add_and_get_member() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:member-owner", + "com.example.member", + "member-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let member_did = "did:plc:new-member"; + let member = SpaceMember { + id: new_id(), + space_id: space_id.clone(), + did: member_did.to_string(), + access: SpaceAccess::Read, + is_delegation: false, + granted_by: Some("did:plc:member-owner".to_string()), + created_at: now_rfc3339(), + }; + spaces_db::add_member(&pool, backend, &member) + .await + .expect("add_member failed"); + + let fetched = spaces_db::get_member(&pool, backend, &space_id, member_did) + .await + .expect("get_member failed") + .expect("member not found"); + + assert_eq!(fetched.did, member_did); + assert_eq!(fetched.access, SpaceAccess::Read); + assert!(!fetched.is_delegation); +} + +#[tokio::test] +#[serial] +async fn remove_member() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space(&space_id, "did:plc:rm-owner", "com.example.rm", "rm-skey"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let member_did = "did:plc:rm-member"; + let member = SpaceMember { + id: new_id(), + space_id: space_id.clone(), + did: member_did.to_string(), + access: SpaceAccess::Write, + is_delegation: false, + granted_by: None, + created_at: now_rfc3339(), + }; + spaces_db::add_member(&pool, backend, &member) + .await + .expect("add_member failed"); + + let removed = spaces_db::remove_member(&pool, backend, &space_id, member_did) + .await + .expect("remove_member failed"); + assert!(removed); + + let after = spaces_db::get_member(&pool, backend, &space_id, member_did) + .await + .expect("get_member after remove failed"); + assert!(after.is_none()); +} + +// --------------------------------------------------------------------------- +// Space records +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn upsert_and_get_space_record() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:rec-owner", + "com.example.rec", + "rec-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:rec-author"; + let uri = format!("at://{author_did}/com.example.item/rec001"); + let record = SpaceRecord { + uri: uri.clone(), + space_id: space_id.clone(), + author_did: author_did.to_string(), + collection: "com.example.item".to_string(), + rkey: "rec001".to_string(), + record: serde_json::json!({"title": "hello"}), + cid: "bafycid001".to_string(), + indexed_at: now_rfc3339(), + }; + + spaces_db::upsert_space_record(&pool, backend, &record) + .await + .expect("upsert_space_record failed"); + + let fetched = spaces_db::get_space_record(&pool, backend, &uri) + .await + .expect("get_space_record failed") + .expect("record not found"); + + assert_eq!(fetched.uri, uri); + assert_eq!(fetched.record["title"], "hello"); + assert_eq!(fetched.cid, "bafycid001"); +} + +#[tokio::test] +#[serial] +async fn upsert_space_record_overwrites_existing() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:upsert-owner", + "com.example.upsert", + "upsert-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let uri = "at://did:plc:upsert-author/com.example.item/upd001"; + let first = SpaceRecord { + uri: uri.to_string(), + space_id: space_id.clone(), + author_did: "did:plc:upsert-author".to_string(), + collection: "com.example.item".to_string(), + rkey: "upd001".to_string(), + record: serde_json::json!({"v": 1}), + cid: "cid-v1".to_string(), + indexed_at: now_rfc3339(), + }; + spaces_db::upsert_space_record(&pool, backend, &first) + .await + .expect("first upsert failed"); + + let second = SpaceRecord { + record: serde_json::json!({"v": 2}), + cid: "cid-v2".to_string(), + ..first + }; + spaces_db::upsert_space_record(&pool, backend, &second) + .await + .expect("second upsert failed"); + + let fetched = spaces_db::get_space_record(&pool, backend, uri) + .await + .expect("get_space_record failed") + .expect("record not found"); + assert_eq!(fetched.record["v"], 2); + assert_eq!(fetched.cid, "cid-v2"); +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -32,6 +32,7 @@ "lexicon-delete.spec.ts", "script-delete.spec.ts", "record-delete.spec.ts", "proxy-config.spec.ts", + "spaces.spec.ts", ], dependencies: ["setup"], use: { browserName: "chromium" }, diff --git a/web/tests/e2e/auth-helper.ts b/web/tests/e2e/auth-helper.ts --- a/web/tests/e2e/auth-helper.ts +++ b/web/tests/e2e/auth-helper.ts @@ -52,7 +52,7 @@ try { const id = "e2e-test-user-id" const now = new Date().toISOString() await client.query( - `INSERT INTO users (id, did, is_super, created_at) + `INSERT INTO happyview_users (id, did, is_super, created_at) VALUES ($1, $2, 1, $3) ON CONFLICT (did) DO NOTHING`, [id, did, now], @@ -66,7 +66,7 @@ export async function resetServiceIdentity(): Promise { const client = new pg.Client(DB_URL) await client.connect() try { - await client.query("DELETE FROM service_identity") + await client.query("DELETE FROM happyview_service_identity") } finally { await client.end() } @@ -81,7 +81,7 @@ await client.connect() try { const now = new Date().toISOString() await client.query( - `INSERT INTO service_identity (id, mode, did, attached_account_did, setup_complete, created_at, updated_at) + `INSERT INTO happyview_service_identity (id, mode, did, attached_account_did, setup_complete, created_at, updated_at) VALUES (1, $1, $2, $3, TRUE, $4, $4) ON CONFLICT (id) DO UPDATE SET mode = $1, diff --git a/web/tests/e2e/lexicon-services.spec.ts b/web/tests/e2e/lexicon-services.spec.ts --- a/web/tests/e2e/lexicon-services.spec.ts +++ b/web/tests/e2e/lexicon-services.spec.ts @@ -119,17 +119,10 @@ await expect(sheet).toBeVisible({ timeout: 5000 }) await expect(sheet.getByRole("heading", { name: "Services" })).toBeVisible() // Should show either service entries or "No services have access" - const hasServices = await sheet - .locator("table tbody tr") - .first() - .isVisible({ timeout: 3000 }) - .catch(() => false) - const hasNoServicesMessage = await sheet - .getByText(/no services have access/i) - .isVisible() - .catch(() => false) - - expect(hasServices || hasNoServicesMessage).toBe(true) + // (must wait for the loading state to resolve before checking) + const serviceRow = sheet.locator("table tbody tr").first() + const noServicesMsg = sheet.getByText(/no services have access/i) + await expect(serviceRow.or(noServicesMsg)).toBeVisible({ timeout: 5000 }) // Clean up the service entry await page.goto("/dashboard/settings/service-identity") diff --git a/web/tests/e2e/record-delete.spec.ts b/web/tests/e2e/record-delete.spec.ts --- a/web/tests/e2e/record-delete.spec.ts +++ b/web/tests/e2e/record-delete.spec.ts @@ -42,7 +42,7 @@ for (let i = 1; i <= 3; i++) { const uri = `at://did:plc:e2e-test-admin/${COLLECTION}/record-${i}` const now = new Date().toISOString() await client.query( - `INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + `INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (uri) DO NOTHING`, [ @@ -68,7 +68,7 @@ ) { const client = new pg.Client(DB_URL) await client.connect() try { - await client.query("DELETE FROM records WHERE collection = $1", [COLLECTION]) + await client.query("DELETE FROM happyview_records WHERE collection = $1", [COLLECTION]) } finally { await client.end() } diff --git a/web/tests/e2e/spaces.spec.ts b/web/tests/e2e/spaces.spec.ts new file mode 100644 --- /dev/null +++ b/web/tests/e2e/spaces.spec.ts @@ -0,0 +1,371 @@ +import { test, expect } from "@playwright/test" +import pg from "pg" +import { loginAsTestAdmin } from "./auth-helper" + +const TEST_TYPE_NSID = "com.example.testspace" +const TEST_SKEY = "e2e-test-space" +const DB_URL = "postgres://happyview:happyview@localhost:5434/happyview_test" + +async function enableSpacesFeature(): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + const now = new Date().toISOString() + await client.query( + `INSERT INTO happyview_instance_settings (key, value, updated_at) + VALUES ('feature.spaces_enabled', 'true', $1) + ON CONFLICT (key) DO UPDATE SET value = 'true', updated_at = $1`, + [now], + ) + } finally { + await client.end() + } +} + +test.describe("Spaces API", () => { + let createdSpaceUri: string | null = null + + test.beforeEach(async ({ page }) => { + await enableSpacesFeature() + await loginAsTestAdmin(page) + }) + + test.afterEach(async ({ page }) => { + if (!createdSpaceUri) return + await page.request.post("/xrpc/com.atproto.simplespace.deleteSpace", { + data: { space: createdSpaceUri }, + }) + createdSpaceUri = null + }) + + test("create space and verify it appears in listSpaces", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY, + displayName: "E2E Test Space", + mintPolicy: "member-list", + }, + }, + ) + + if (!createResp.ok()) { + const errBody = await createResp.text() + throw new Error(`createSpace failed (${createResp.status()}): ${errBody}`) + } + const createBody = await createResp.json() + expect(createBody).toHaveProperty("uri") + expect(createBody.uri).toMatch(/^ats:\/\//) + createdSpaceUri = createBody.uri + + const listResp = await page.request.get( + "/xrpc/com.atproto.space.listSpaces", + ) + expect(listResp.ok()).toBe(true) + const listBody = await listResp.json() + expect(listBody).toHaveProperty("spaces") + + const found = listBody.spaces.some( + (s: { uri: string }) => s.uri === createdSpaceUri, + ) + expect(found).toBe(true) + }) + + test("getSpace returns the created space", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-get", + displayName: "GetSpace Test", + }, + }, + ) + expect(createResp.ok()).toBe(true) + const { uri } = await createResp.json() + createdSpaceUri = uri + + const getResp = await page.request.get("/xrpc/com.atproto.space.getSpace", { + params: { space: uri }, + }) + expect(getResp.ok()).toBe(true) + const getBody = await getResp.json() + expect(getBody.space.display_name).toBe("GetSpace Test") + expect(getBody.space.mint_policy).toBe("member-list") + }) + + test("create duplicate space returns conflict", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-dup", + }, + }, + ) + expect(createResp.ok()).toBe(true) + createdSpaceUri = (await createResp.json()).uri + + const dupResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-dup", + }, + }, + ) + expect(dupResp.status()).toBe(409) + }) + + test("updateSpace changes display name", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-update", + displayName: "Before Update", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const updateResp = await page.request.post( + "/xrpc/com.atproto.simplespace.updateSpace", + { data: { space: uri, displayName: "After Update" } }, + ) + expect(updateResp.ok()).toBe(true) + const updateBody = await updateResp.json() + expect(updateBody.space.display_name).toBe("After Update") + }) + + test("deleteSpace removes the space", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-delete", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + + const deleteResp = await page.request.post( + "/xrpc/com.atproto.simplespace.deleteSpace", + { data: { space: uri } }, + ) + expect(deleteResp.ok()).toBe(true) + + const getResp = await page.request.get( + "/xrpc/com.atproto.space.getSpace", + { params: { space: uri } }, + ) + expect(getResp.status()).toBe(404) + }) + + test("addMember returns 201", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-add-member", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const addResp = await page.request.post( + "/xrpc/com.atproto.simplespace.addMember", + { data: { space: uri, did: "did:plc:test-member", access: "read" } }, + ) + expect(addResp.status()).toBe(201) + const addBody = await addResp.json() + expect(addBody.member.did).toBe("did:plc:test-member") + }) + + test("removeMember removes a previously added member", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-remove-member", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + await page.request.post("/xrpc/com.atproto.simplespace.addMember", { + data: { space: uri, did: "did:plc:test-member-rm", access: "read" }, + }) + + const removeResp = await page.request.post( + "/xrpc/com.atproto.simplespace.removeMember", + { data: { space: uri, did: "did:plc:test-member-rm" } }, + ) + expect(removeResp.ok()).toBe(true) + }) + + test("listMembers includes added member", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-list-members", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + await page.request.post("/xrpc/com.atproto.simplespace.addMember", { + data: { space: uri, did: "did:plc:test-member-list", access: "write" }, + }) + + const listResp = await page.request.get( + "/xrpc/com.atproto.simplespace.listMembers", + { params: { space: uri } }, + ) + expect(listResp.ok()).toBe(true) + const listBody = await listResp.json() + expect(listBody.members).toBeInstanceOf(Array) + const found = listBody.members.some( + (m: { did: string }) => m.did === "did:plc:test-member-list", + ) + expect(found).toBe(true) + }) + + test("getConfig returns space configuration", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-get-config", + mintPolicy: "member-list", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const configResp = await page.request.get( + "/xrpc/com.atproto.simplespace.getConfig", + { params: { space: uri } }, + ) + expect(configResp.ok()).toBe(true) + const configBody = await configResp.json() + expect(configBody.mintPolicy).toBe("member-list") + }) + + test("updateConfig changes mint policy", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-update-config", + mintPolicy: "member-list", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const updateResp = await page.request.post( + "/xrpc/com.atproto.simplespace.updateConfig", + { data: { space: uri, mintPolicy: "public" } }, + ) + expect(updateResp.ok()).toBe(true) + + const configResp = await page.request.get( + "/xrpc/com.atproto.simplespace.getConfig", + { params: { space: uri } }, + ) + expect(configResp.ok()).toBe(true) + const configBody = await configResp.json() + expect(configBody.mintPolicy).toBe("public") + }) +}) + +async function disableSpacesFeature(): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + await client.query( + `DELETE FROM happyview_instance_settings WHERE key = 'feature.spaces_enabled'`, + ) + } finally { + await client.end() + } +} + +test.describe("Spaces Feature Flag", () => { + test.beforeEach(async ({ page }) => { + await disableSpacesFeature() + await loginAsTestAdmin(page) + }) + + test("spaces endpoints return 404 when feature is disabled", async ({ + page, + }) => { + const resp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: "com.example.test", + skey: "flag-test", + }, + }, + ) + expect(resp.status()).toBe(404) + const body = await resp.json() + expect(body.error).toBe("FeatureDisabled") + }) +})