this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rename appview → indexer across codebase

Opake's service is an atproto appview in protocol role, but serves no
rendered views — all payloads are ciphertext. "Appview" was a constant
explanatory tax; "indexer" is what it actually does.

Changes:
- Elixir: apps/appview/ → apps/indexer/; mix app :opake_appview →
:opake_indexer; namespaces OpakeAppview.* → OpakeIndexer.*;
OpakeAppviewWeb.* → OpakeIndexerWeb.*; internal OpakeAppview.Indexer
module (firehose consumer) → OpakeIndexer.Firehose (resolves collision
from service rename). DB name opake_appview_* → opake_indexer_*.
- Rust: client/appview*.rs → client/indexer*.rs; Error::Appview → Indexer;
DEFAULT_APPVIEW_URL → DEFAULT_INDEXER_URL; resolve_appview_url,
set_appview_url, appview_url field, etc.
- TS: appviewUrl → indexerUrl in schemas/types; setAppviewUrl →
setIndexerUrl; OpakeError kind "Appview" → "Indexer". Regenerated wasm
bindings.
- Lexicon: app.opake.accountConfig field appviewUrl → indexerUrl; update
record descriptions.
- Infra: Containerfile.appview → Containerfile.indexer; env vars
OPAKE_APPVIEW_URL → OPAKE_INDEXER_URL; VITE_APPVIEW_URL →
VITE_INDEXER_URL; URLs appview.opake.app → indexer.opake.app;
k8s deployment/opake-appview → deployment/opake-indexer; justfile
recipes; docker-compose service name; .tangled workflows.
- Tools: tools/appview-auth.py → tools/indexer-auth.py.
- Docs: docs/appview.md → docs/indexer.md; all prose references updated.
CHANGELOG.md preserved (historical record). Blog post preserved (refers
to generic atproto appview protocol role). ARCHITECTURE.md adds one
clarifying sentence acknowledging the protocol-role connection.

No existing accountConfig records to migrate — pre-release, two dev
accounts. Next record write overwrites cleanly.

Build verified: cargo check --workspace clean; cargo test 556 pass;
mix compile --warnings-as-errors clean; @opake/sdk 32 tests pass;
@opake/react 32 tests pass; apps/web tsc clean. Pre-existing test
failures (9 web unit, 4 Elixir) unrelated.

+1140 -1138
+3 -3
.claude/agents/review.md
··· 35 35 opake-sdk — thin TS wrappers. No business logic. initWasm -> adapter -> wasm.call(). 36 36 apps/web — React UI. Calls SDK, never WASM directly. 37 37 apps/cli — Rust binary. Calls core directly. 38 - apps/appview — Elixir. Indexes PDS firehose, serves workspace queries. 38 + apps/indexer — Elixir. Indexes PDS firehose, serves workspace queries. 39 39 ``` 40 40 41 41 **The rule:** if both CLI and web need it, it lives in core. If it's JS-specific glue, it lives in the SDK. If it reimplements core logic in TS, it's wrong — export through WASM. ··· 76 76 2. `OPAKE_COLLECTIONS` in `crate::scope` (compile-time test enforces this) 77 77 3. Lexicon JSON in `lexicons/` 78 78 4. Permission set `app.opake.authFullAccess.json` 79 - 5. If appview-indexed: `@wanted_collections` in `consumer.ex` + parser + dispatch 79 + 5. If indexer-indexed: `@wanted_collections` in `consumer.ex` + parser + dispatch 80 80 81 81 A test enforces #1-#2 sync. The rest are manual. Flag any PR that adds a collection and misses any of these. 82 82 ··· 165 165 - MockTransport: FIFO response queue. 166 166 - Contract tests, not implementation tests. 167 167 - Bug regressions: named after the bug. 168 - - Appview: DataCase (queries, async: true), ConnCase (controllers, async: false). 168 + - Indexer: DataCase (queries, async: true), ConnCase (controllers, async: false). 169 169 170 170 ## How to Review 171 171
+3 -3
.envrc
··· 2 2 # Production: https://opake.app/devices/cli-callback 3 3 export OPAKE_FRONTEND_URL=http://localhost:5173 4 4 5 - # AppView URL for CLI commands 6 - # Production: https://appview.opake.app 7 - export OPAKE_APPVIEW_URL=http://localhost:6100 5 + # Indexer URL for CLI commands 6 + # Production: https://indexer.opake.app 7 + export OPAKE_INDEXER_URL=http://localhost:6100
+2 -2
.gitignore
··· 1 1 .DS_Store 2 2 /target 3 - apps/appview/_build/ 4 - apps/appview/deps/ 3 + apps/indexer/_build/ 4 + apps/indexer/deps/ 5 5 node_modules/ 6 6 bun.lock 7 7 apps/web/dist/
+9 -9
.tangled/workflows/deploy-staging.yml
··· 12 12 echo "Authenticating as: $ZOT_USERNAME" 13 13 buildah login -u "$ZOT_USERNAME" -p "$ZOT_PASSWORD" zot.sans-self.org 14 14 15 - - name: "Build and push appview" 15 + - name: "Build and push indexer" 16 16 command: | 17 17 buildah bud \ 18 - -f Containerfile.appview \ 19 - -t zot.sans-self.org/opake/appview:staging \ 20 - -t zot.sans-self.org/opake/appview:staging-$TANGLED_COMMIT_SHA \ 21 - appview 22 - buildah push zot.sans-self.org/opake/appview:staging 23 - buildah push zot.sans-self.org/opake/appview:staging-$TANGLED_COMMIT_SHA 18 + -f Containerfile.indexer \ 19 + -t zot.sans-self.org/opake/indexer:staging \ 20 + -t zot.sans-self.org/opake/indexer:staging-$TANGLED_COMMIT_SHA \ 21 + indexer 22 + buildah push zot.sans-self.org/opake/indexer:staging 23 + buildah push zot.sans-self.org/opake/indexer:staging-$TANGLED_COMMIT_SHA 24 24 25 25 - name: "Build and push web" 26 26 command: | 27 27 buildah bud \ 28 28 -f Containerfile.web \ 29 - --build-arg VITE_APPVIEW_URL=https://appview.staging.opake.app \ 29 + --build-arg VITE_INDEXER_URL=https://indexer.staging.opake.app \ 30 30 --build-arg VITE_SITE_URL=https://staging.opake.app \ 31 31 -t zot.sans-self.org/opake/web:staging \ 32 32 -t zot.sans-self.org/opake/web:staging-$TANGLED_COMMIT_SHA \ ··· 38 38 command: | 39 39 curl -sLO "https://dl.k8s.io/release/v1.32.0/bin/linux/arm64/kubectl" 40 40 chmod +x kubectl 41 - ./kubectl rollout restart deployment/opake-web deployment/opake-appview -n opake-staging 41 + ./kubectl rollout restart deployment/opake-web deployment/opake-indexer -n opake-staging
+3 -3
.tangled/workflows/test-elixir.yml
··· 10 10 - name: "Install deps" 11 11 command: | 12 12 apt-get update -y && apt-get install -y --no-install-recommends build-essential git 13 - cd appview 13 + cd indexer 14 14 mix local.hex --force && mix local.rebar --force 15 15 mix deps.get 16 16 17 17 - name: "Compile (warnings as errors)" 18 18 command: | 19 - cd appview 19 + cd indexer 20 20 MIX_ENV=test mix compile --warnings-as-errors 21 21 22 22 - name: "Test" 23 23 command: | 24 - cd appview 24 + cd indexer 25 25 MIX_ENV=test mix test
+5 -5
CLAUDE.md
··· 24 24 6. **Public keys as PDS records.** atproto DID docs only have signing keys. Opake publishes X25519 encryption public keys as `app.opake.publicKey/self` singleton records. 25 25 7. **Multi-device: seed phrase.** Identity keypairs are derived from a BIP-39 24-word mnemonic via PBKDF2 + HKDF. The seed phrase is the default identity creation path — no random keypair fallback. Recovery via `opake recover` (CLI) or the web UI. 26 26 8. **Storage trait in opake-core.** Config, Identity, Session types and the `Storage` trait live in core so both CLI (`FileStorage`, filesystem) and web (`IndexedDbStorage`, IndexedDB) share the same contract. Platform-specific I/O is injected, never imported. 27 - 9. **Domain API: `Opake` → `FileManager` / `WorkspaceAdmin`.** The `Opake<T, R, S>` struct bundles client + identity + RNG + time + storage. All CLI commands route through Opake (sole holdout: `pair request` on a new device with no identity). Call `.file_context(workspace_name?)` + `.file_manager(&context)` for file ops, `.workspace_admin()` for membership ops (add/remove member, leave). Opake itself handles workspace CRUD, sharing, identity, pairing, config, maintenance. All mutations auto-persist sessions via `#[signoff]` (FileManager) or `#[signoff(self)]` (Opake). Raw functions are `pub(crate)`; the domain types ARE the public API. Live workspace-list state is kept in a `WorkspaceKeeper` (parallel to `TreeKeeper` for directory trees) — bootstrapped by `listWorkspaces`, patched incrementally by SSE `keyring:upsert` / `keyring:delete` events. Incoming shares are tracked in `InboxKeeper` — bootstrapped by `listInbox`, patched by SSE `grant:upsert` / `grant:delete` events (appview fans both out to owner and recipient personal topics). 27 + 9. **Domain API: `Opake` → `FileManager` / `WorkspaceAdmin`.** The `Opake<T, R, S>` struct bundles client + identity + RNG + time + storage. All CLI commands route through Opake (sole holdout: `pair request` on a new device with no identity). Call `.file_context(workspace_name?)` + `.file_manager(&context)` for file ops, `.workspace_admin()` for membership ops (add/remove member, leave). Opake itself handles workspace CRUD, sharing, identity, pairing, config, maintenance. All mutations auto-persist sessions via `#[signoff]` (FileManager) or `#[signoff(self)]` (Opake). Raw functions are `pub(crate)`; the domain types ARE the public API. Live workspace-list state is kept in a `WorkspaceKeeper` (parallel to `TreeKeeper` for directory trees) — bootstrapped by `listWorkspaces`, patched incrementally by SSE `keyring:upsert` / `keyring:delete` events. Incoming shares are tracked in `InboxKeeper` — bootstrapped by `listInbox`, patched by SSE `grant:upsert` / `grant:delete` events (indexer fans both out to owner and recipient personal topics). 28 28 10. **Workspace is the domain concept.** Keyrings are crypto plumbing. The `Workspace` type wraps keyring data with domain semantics. CLI uses `opake workspace`, not `opake keyring`. Lexicon stays `app.opake.keyring` (wire format). 29 29 11. **Sensitive types auto-zeroize.** `RedactedDebug` derive macro generates `Zeroize + Drop` for `#[redact]` fields. ContentKey, Identity, DpopKeyPair, Session types are all zeroized on drop. Nested structs chain — dropping an OAuthSession also zeroizes its DpopKeyPair. 30 30 12. **WASM is the security boundary.** Tokens, DPoP keys, session credentials, and all crypto MUST live in WASM (opake-core). JS cannot zeroize memory — strings are immutable and GC'd on the runtime's schedule. The OAuth login flow itself runs in WASM (`startOAuthLogin`, `completeOAuthLogin`, `loginWithAppPasswordWasm`). Token expiry is checked via `tokenExpiresAt()` (returns only the timestamp). Refresh runs via `proactiveRefresh()` (calls `refresh_token` directly). JS never calls `session()` for auth state — that leaks tokens to the GC. Exception: `PendingLogin` state crosses the boundary during redirect flows (DPoP key in sessionStorage), bounded by a 10-minute TTL and auto-cleared on read. 31 - 13. **Granular OAuth scopes.** Per-collection `repo:app.opake.*` scopes instead of the catch-all `transition:generic`. The scope string is built from `crate::scope::OPAKE_COLLECTIONS` — single source of truth. Adding a new collection means adding it to `OPAKE_COLLECTIONS` (compile-time test enforces this), the lexicon JSON, the permission set (`app.opake.authFullAccess`), and the appview consumer if indexed. 31 + 13. **Granular OAuth scopes.** Per-collection `repo:app.opake.*` scopes instead of the catch-all `transition:generic`. The scope string is built from `crate::scope::OPAKE_COLLECTIONS` — single source of truth. Adding a new collection means adding it to `OPAKE_COLLECTIONS` (compile-time test enforces this), the lexicon JSON, the permission set (`app.opake.authFullAccess`), and the indexer consumer if indexed. 32 32 33 33 ## Documentation 34 34 ··· 39 39 - **[docs/AUTH.md](docs/AUTH.md)** — OAuth/DPoP authentication, multi-account, device pairing 40 40 - **[docs/CRYPTO.md](docs/CRYPTO.md)** — Algorithms, constants, key hierarchy, operation reference 41 41 - **[docs/FLOWS.md](docs/FLOWS.md)** — Sequence diagrams for every operation 42 - - **[docs/appview.md](docs/appview.md)** — AppView config, auth, API endpoints 42 + - **[docs/indexer.md](docs/indexer.md)** — Indexer config, auth, API endpoints 43 43 - **[lexicons/README.md](lexicons/README.md)** — Full lexicon schema reference 44 44 - **[lexicons/EXAMPLES.md](lexicons/EXAMPLES.md)** — Annotated example records 45 45 - **[docs/LICENSING.md](docs/LICENSING.md)** — AGPL-3.0 implications for self-hosters, plugin devs, contributors 46 46 - **[SECURITY.md](SECURITY.md)** — Vulnerability reporting, scope, response timeline 47 47 - **[CONTRIBUTING.md](CONTRIBUTING.md)** — Code style, testing, architecture overview 48 48 49 - ## AppView (Elixir) 49 + ## Indexer (Elixir) 50 50 51 - See **[docs/appview.md](docs/appview.md)** for tables, endpoints, deployment, and firehose details. 51 + See **[docs/indexer.md](docs/indexer.md)** for tables, endpoints, deployment, and firehose details. 52 52 53 53 ### Conventions for agents 54 54
+2 -2
CONTRIBUTING.md
··· 54 54 - FileStorage (impl Storage over filesystem, TOML + JSON) 55 55 - user interaction (prompts, formatting) 56 56 57 - appview/ Elixir/Phoenix indexer + REST API for grant/keyring discovery 57 + indexer/ Elixir/Phoenix indexer + REST API for grant/keyring discovery 58 58 - Jetstream firehose consumer (WebSockex) 59 59 - PostgreSQL storage (Ecto) 60 60 - Phoenix API with DID-scoped Ed25519 auth (Erlang :crypto) ··· 96 96 cargo test -p opake-cli # CLI only 97 97 cargo test -- --test-output # show println output 98 98 99 - cd apps/appview && mix test # appview tests (Elixir/ExUnit) 99 + cd apps/indexer && mix test # indexer tests (Elixir/ExUnit) 100 100 cd apps/web && bun run test # web frontend tests (Vitest + fake-indexeddb) 101 101 ``` 102 102
+1 -1
Containerfile.appview Containerfile.indexer
··· 33 33 RUN groupadd -g 1000 opake && useradd -u 1000 -g opake -m opake 34 34 35 35 WORKDIR /app 36 - COPY --from=build --chown=1000:1000 /app/_build/prod/rel/opake_appview ./ 36 + COPY --from=build --chown=1000:1000 /app/_build/prod/rel/opake_indexer ./ 37 37 COPY --chown=1000:1000 rel/entrypoint.sh ./ 38 38 39 39 USER 1000:1000
+2 -2
Containerfile.web
··· 15 15 16 16 FROM web-deps AS web-builder 17 17 18 - ARG VITE_APPVIEW_URL=https://appview.opake.app 18 + ARG VITE_INDEXER_URL=https://indexer.opake.app 19 19 ARG VITE_SITE_URL=https://opake.app 20 - ENV VITE_APPVIEW_URL=$VITE_APPVIEW_URL 20 + ENV VITE_INDEXER_URL=$VITE_INDEXER_URL 21 21 ENV VITE_SITE_URL=$VITE_SITE_URL 22 22 23 23 COPY apps/web/ ./
+2 -2
README.md
··· 56 56 57 57 - `opake-core/` — Platform-agnostic library (Rust/WASM). 58 58 - `opake-cli/` — CLI implementation. 59 - - `appview/` — Elixir/Phoenix indexer for grant discovery. 59 + - `indexer/` — Elixir/Phoenix indexer for grant discovery. 60 60 - `web/` — React SPA (Vite + TanStack). 61 61 - `lexicons/` — AT Protocol schemas (`app.opake.*`). 62 62 ··· 65 65 ```sh 66 66 cargo test # Rust tests 67 67 bun run wasm:build # Build WASM for web 68 - mix setup # Setup AppView 68 + mix setup # Setup Indexer 69 69 ``` 70 70 71 71 See [CONTRIBUTING.md](CONTRIBUTING.md) for the "mini-nuke" policy and commit conventions.
+3 -3
SECURITY.md
··· 10 10 11 11 - A description of the vulnerability 12 12 - Steps to reproduce (or a proof of concept) 13 - - The component affected (core, CLI, web, appview, WASM, lexicons) 13 + - The component affected (core, CLI, web, indexer, WASM, lexicons) 14 14 - Your assessment of severity, if you have one 15 15 16 16 ## Scope ··· 21 21 - **opake-cli** — command handling, file storage, credential management 22 22 - **opake-wasm** — WASM bindings and the web worker bridge 23 23 - **web/** — the React SPA (auth flows, state management, UI rendering of sensitive data) 24 - - **appview/** — the Elixir indexer (API auth, grant/keyring discovery, rate limiting) 24 + - **indexer/** — the Elixir indexer (API auth, grant/keyring discovery, rate limiting) 25 25 - **lexicons** — schema definitions under `app.opake.*` 26 26 27 27 ### Out of scope ··· 36 36 - Key material exposure — content keys, identity keys, or seed phrases leaked to logs, network, disk, or memory beyond their intended scope 37 37 - Auth bypass — accessing documents, grants, or keyrings without proper authorization 38 38 - Grant escalation — obtaining access beyond what a grant permits 39 - - Injection / XSS — in the web app or appview API 39 + - Injection / XSS — in the web app or indexer API 40 40 - Cryptographic weakness — flaws in the encryption scheme, key derivation, or key wrapping that reduce the effective security level 41 41 42 42 ## Encryption model (summary)
apps/appview/.dockerignore apps/indexer/.dockerignore
apps/appview/.formatter.exs apps/indexer/.formatter.exs
+1 -1
apps/appview/.gitignore apps/indexer/.gitignore
··· 23 23 /tmp/ 24 24 25 25 # Ignore package tarball (built via "mix hex.build"). 26 - opake_appview-*.tar 26 + opake_indexer-*.tar 27 27
+5 -5
apps/appview/config/config.exs apps/indexer/config/config.exs
··· 1 1 import Config 2 2 3 - config :opake_appview, 4 - ecto_repos: [OpakeAppview.Repo], 3 + config :opake_indexer, 4 + ecto_repos: [OpakeIndexer.Repo], 5 5 generators: [timestamp_type: :utc_datetime_usec], 6 6 # Subscription mode for the Jetstream consumer. 7 - # See OpakeAppview.Jetstream.Consumer for the full set of options. 7 + # See OpakeIndexer.Jetstream.Consumer for the full set of options. 8 8 firehose_mode: :full, 9 9 # zstd-compressed binary frames using the vendored bsky dictionary. 10 10 # Set to :none to receive raw JSON instead (debugging only). ··· 13 13 # matter how many events flow through the indexer in between. 14 14 cursor_save_interval_ms: 5_000 15 15 16 - config :opake_appview, OpakeAppviewWeb.Endpoint, 16 + config :opake_indexer, OpakeIndexerWeb.Endpoint, 17 17 url: [host: "localhost"], 18 18 adapter: Bandit.PhoenixAdapter, 19 19 # SSE connections are long-lived — raise the idle timeout so keepalive 20 20 # chunks (every 15s) don't race the default limit. 21 21 thousand_island_options: [read_timeout: 86_400_000], 22 22 render_errors: [ 23 - formats: [json: OpakeAppviewWeb.ErrorJSON], 23 + formats: [json: OpakeIndexerWeb.ErrorJSON], 24 24 layout: false 25 25 ] 26 26
+5 -5
apps/appview/config/dev.exs apps/indexer/config/dev.exs
··· 1 1 import Config 2 2 3 - config :opake_appview, OpakeAppview.Repo, 3 + config :opake_indexer, OpakeIndexer.Repo, 4 4 username: "postgres", 5 5 password: "postgres", 6 6 hostname: "localhost", 7 - database: "opake_appview_dev", 7 + database: "opake_indexer_dev", 8 8 stacktrace: true, 9 9 show_sensitive_data_on_connection_error: true, 10 10 pool_size: 10 11 11 12 - config :opake_appview, OpakeAppviewWeb.Endpoint, 12 + config :opake_indexer, OpakeIndexerWeb.Endpoint, 13 13 http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "6100")], 14 14 check_origin: false, 15 15 code_reloader: true, ··· 17 17 secret_key_base: "BFc2e5YOPLjLIEFGAPZ2kemmnuc7VOwv5ctKtiaYOX/r2bTLG8X2sVVcI6cYPjK1", 18 18 watchers: [] 19 19 20 - config :opake_appview, 20 + config :opake_indexer, 21 21 jetstream_url: "wss://jetstream2.us-east.bsky.network/subscribe", 22 22 firehose_mode: :opake_only, 23 23 cors_origin: "*" 24 24 25 - config :opake_appview, dev_routes: true 25 + config :opake_indexer, dev_routes: true 26 26 27 27 config :logger, level: :info 28 28 config :logger, :default_formatter, format: "[$level] $message\n"
apps/appview/config/prod.exs apps/indexer/config/prod.exs
+6 -6
apps/appview/config/runtime.exs apps/indexer/config/runtime.exs
··· 1 1 import Config 2 2 3 3 if System.get_env("PHX_SERVER") not in [nil, "false", "0"] do 4 - config :opake_appview, OpakeAppviewWeb.Endpoint, server: true 4 + config :opake_indexer, OpakeIndexerWeb.Endpoint, server: true 5 5 end 6 6 7 7 if System.get_env("INDEXER_ENABLED") in ["false", "0"] do 8 - config :opake_appview, :indexer_enabled, false 8 + config :opake_indexer, :indexer_enabled, false 9 9 end 10 10 11 11 if jetstream_url = System.get_env("JETSTREAM_URL") do 12 - config :opake_appview, :jetstream_url, jetstream_url 12 + config :opake_indexer, :jetstream_url, jetstream_url 13 13 end 14 14 15 15 if cors_origin = System.get_env("CORS_ORIGIN") do 16 - config :opake_appview, :cors_origin, cors_origin 16 + config :opake_indexer, :cors_origin, cors_origin 17 17 end 18 18 19 19 if config_env() == :prod do ··· 23 23 24 24 maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] 25 25 26 - config :opake_appview, OpakeAppview.Repo, 26 + config :opake_indexer, OpakeIndexer.Repo, 27 27 url: database_url, 28 28 pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 29 29 socket_options: maybe_ipv6 ··· 35 35 host = System.get_env("PHX_HOST") || "localhost" 36 36 port = String.to_integer(System.get_env("PORT") || "6100") 37 37 38 - config :opake_appview, OpakeAppviewWeb.Endpoint, 38 + config :opake_indexer, OpakeIndexerWeb.Endpoint, 39 39 url: [host: host, port: 443, scheme: "https"], 40 40 http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port], 41 41 secret_key_base: secret_key_base
-25
apps/appview/config/test.exs
··· 1 - import Config 2 - 3 - config :opake_appview, OpakeAppview.Repo, 4 - username: "postgres", 5 - password: "postgres", 6 - hostname: "localhost", 7 - database: "opake_appview_test#{System.get_env("MIX_TEST_PARTITION")}", 8 - pool: Ecto.Adapters.SQL.Sandbox, 9 - pool_size: System.schedulers_online() * 2 10 - 11 - config :opake_appview, OpakeAppviewWeb.Endpoint, 12 - http: [ip: {127, 0, 0, 1}, port: 4002], 13 - secret_key_base: "qLFGZ/oib3gMumgPqHDETV3VM0klUHbVSFijSctQvjmTDRoshDjL+HyCbHS8IS3k", 14 - server: false 15 - 16 - config :opake_appview, :indexer_enabled, false 17 - 18 - # Tests don't talk to Jetstream — they call Indexer.process_message 19 - # directly. Make cursor saves immediate so the existing pipeline tests 20 - # behave identically to before the time-based throttling. 21 - config :opake_appview, :cursor_save_interval_ms, 0 22 - config :opake_appview, :firehose_mode, {:custom, []} 23 - 24 - config :logger, level: :warning 25 - config :phoenix, :plug_init_mode, :runtime
+5 -5
apps/appview/lib/mix/tasks/opake.resync.ex apps/indexer/lib/mix/tasks/opake.resync.ex
··· 1 1 defmodule Mix.Tasks.Opake.Resync do 2 2 @moduledoc """ 3 - Backfill a DID's `app.opake.*` records from their PDS into the appview DB. 3 + Backfill a DID's `app.opake.*` records from their PDS into the indexer DB. 4 4 5 5 Fetches keyrings, directories, documents, and grants via the public 6 6 `com.atproto.repo.listRecords` endpoint and upserts them through the ··· 14 14 mix opake.resync --all 15 15 16 16 `--all` backfills every DID found in the `keyring_members` table (i.e., 17 - every DID the appview has ever seen as a workspace member). 17 + every DID the indexer has ever seen as a workspace member). 18 18 """ 19 19 20 20 use Mix.Task 21 21 22 - @shortdoc "Backfill opake records from a PDS into the appview" 22 + @shortdoc "Backfill opake records from a PDS into the indexer" 23 23 24 24 @impl Mix.Task 25 25 def run(argv) do ··· 32 32 33 33 cond do 34 34 opts[:all] -> 35 - OpakeAppview.Backfill.backfill_known_dids() 35 + OpakeIndexer.Backfill.backfill_known_dids() 36 36 Mix.shell().info("Backfill complete for all known DIDs.") 37 37 38 38 length(args) == 1 -> 39 39 identifier = hd(args) 40 40 did = resolve_identifier(identifier) 41 41 42 - case OpakeAppview.Backfill.backfill_did(did) do 42 + case OpakeIndexer.Backfill.backfill_did(did) do 43 43 :ok -> 44 44 Mix.shell().info("Backfill complete for #{did}.") 45 45
+5 -5
apps/appview/lib/mix/tasks/opake.tail.ex apps/indexer/lib/mix/tasks/opake.tail.ex
··· 31 31 32 32 use Mix.Task 33 33 34 - alias OpakeAppview.Jetstream.{Compression, Event} 34 + alias OpakeIndexer.Jetstream.{Compression, Event} 35 35 36 36 @opake_collections [ 37 37 "app.opake.grant", ··· 85 85 Mix.shell().info("[opake.tail] connecting to #{url}") 86 86 87 87 {:ok, _pid} = 88 - OpakeAppview.Mix.Tail.Listener.start_link(%{ 88 + OpakeIndexer.Mix.Tail.Listener.start_link(%{ 89 89 url: url, 90 90 max: max, 91 91 pretty?: pretty?, ··· 116 116 end 117 117 118 118 defp build_url(mode, compression) do 119 - base = Application.fetch_env!(:opake_appview, :jetstream_url) 119 + base = Application.fetch_env!(:opake_indexer, :jetstream_url) 120 120 121 121 params = 122 122 mode ··· 172 172 end 173 173 end 174 174 175 - defmodule OpakeAppview.Mix.Tail.Listener do 175 + defmodule OpakeIndexer.Mix.Tail.Listener do 176 176 @moduledoc false 177 177 # WebSockex client used by `mix opake.tail`. Defined in the same file as 178 178 # the task because it has no purpose outside it. 179 179 180 180 use WebSockex 181 181 182 - alias OpakeAppview.Jetstream.Compression 182 + alias OpakeIndexer.Jetstream.Compression 183 183 184 184 def start_link(state) do 185 185 WebSockex.start_link(state.url, __MODULE__, state)
-53
apps/appview/lib/opake_appview/application.ex
··· 1 - defmodule OpakeAppview.Application do 2 - @moduledoc """ 3 - OTP application for the Opake AppView — a read-only Jetstream indexer and 4 - query API for encrypted sharing metadata on the AT Protocol. 5 - 6 - Supervision tree: 7 - - Repo (Ecto/Postgres connection pool) 8 - - Auth.KeyCache (GenServer + ETS for Ed25519 public key caching) 9 - - Endpoint (Phoenix/Bandit HTTP server — always started, `server: false` skips binding) 10 - - Jetstream.Consumer (WebSockex — conditional on `:indexer_enabled` config) 11 - """ 12 - 13 - use Application 14 - 15 - @impl true 16 - def start(_type, _args) do 17 - OpakeAppview.Indexer.init_state() 18 - OpakeAppview.Jetstream.Compression.init() 19 - OpakeAppview.SSE.TokenStore.init_table() 20 - OpakeAppview.SSE.ConnectionTracker.init_table() 21 - 22 - children = 23 - [ 24 - OpakeAppview.Repo, 25 - {Phoenix.PubSub, name: OpakeAppview.PubSub}, 26 - OpakeAppview.SSE.TokenStore, 27 - OpakeAppview.Auth.KeyCache, 28 - OpakeAppviewWeb.Endpoint 29 - ] ++ 30 - maybe_indexer_children() 31 - 32 - opts = [strategy: :one_for_one, name: OpakeAppview.Supervisor] 33 - Supervisor.start_link(children, opts) 34 - end 35 - 36 - defp maybe_indexer_children do 37 - if Application.get_env(:opake_appview, :indexer_enabled, true) do 38 - [ 39 - OpakeAppview.TombstoneCleanup, 40 - OpakeAppview.Jetstream.Consumer, 41 - OpakeAppview.Indexer.Heartbeat 42 - ] 43 - else 44 - [] 45 - end 46 - end 47 - 48 - @impl true 49 - def config_change(changed, _new, removed) do 50 - OpakeAppviewWeb.Endpoint.config_change(changed, removed) 51 - :ok 52 - end 53 - end
+1 -1
apps/appview/lib/opake_appview/auth/base64.ex apps/indexer/lib/opake_indexer/auth/base64.ex
··· 1 - defmodule OpakeAppview.Auth.Base64 do 1 + defmodule OpakeIndexer.Auth.Base64 do 2 2 @moduledoc """ 3 3 Flexible base64 decoding that handles both padded and unpadded input. 4 4 AT Protocol encodes keys and signatures inconsistently.
+2 -2
apps/appview/lib/opake_appview/auth/key_cache.ex apps/indexer/lib/opake_indexer/auth/key_cache.ex
··· 1 - defmodule OpakeAppview.Auth.KeyCache do 1 + defmodule OpakeIndexer.Auth.KeyCache do 2 2 @moduledoc """ 3 3 In-memory cache for Ed25519 signing public keys, keyed by DID with a 5-minute 4 4 TTL. Reads go directly to ETS (no bottleneck). Writes are serialized through ··· 71 71 end 72 72 73 73 defp key_fetcher do 74 - Application.get_env(:opake_appview, :key_fetcher, OpakeAppview.Auth.KeyFetcher) 74 + Application.get_env(:opake_indexer, :key_fetcher, OpakeIndexer.Auth.KeyFetcher) 75 75 end 76 76 end
+3 -3
apps/appview/lib/opake_appview/auth/key_fetcher.ex apps/indexer/lib/opake_indexer/auth/key_fetcher.ex
··· 1 - defmodule OpakeAppview.Auth.KeyFetcher do 1 + defmodule OpakeIndexer.Auth.KeyFetcher do 2 2 @moduledoc """ 3 3 Resolves a DID to its Ed25519 signing public key by walking the chain: 4 4 DID → DID document → PDS service endpoint → `app.opake.publicKey/self` record ··· 8 8 Implements `KeyFetcherBehaviour` so tests can substitute a Mox mock. 9 9 """ 10 10 11 - @behaviour OpakeAppview.Auth.KeyFetcherBehaviour 11 + @behaviour OpakeIndexer.Auth.KeyFetcherBehaviour 12 12 13 13 require Logger 14 14 ··· 73 73 end 74 74 75 75 defp decode_pubkey_bytes(bytes_b64) do 76 - with {:ok, bytes} <- OpakeAppview.Auth.Base64.decode(bytes_b64) do 76 + with {:ok, bytes} <- OpakeIndexer.Auth.Base64.decode(bytes_b64) do 77 77 if byte_size(bytes) == 32 do 78 78 {:ok, bytes} 79 79 else
+1 -1
apps/appview/lib/opake_appview/auth/key_fetcher_behaviour.ex apps/indexer/lib/opake_indexer/auth/key_fetcher_behaviour.ex
··· 1 - defmodule OpakeAppview.Auth.KeyFetcherBehaviour do 1 + defmodule OpakeIndexer.Auth.KeyFetcherBehaviour do 2 2 @moduledoc """ 3 3 Behaviour for resolving a DID to a 32-byte Ed25519 signing public key. 4 4 The real implementation hits the network; tests use a Mox mock.
+3 -3
apps/appview/lib/opake_appview/auth/plug.ex apps/indexer/lib/opake_indexer/auth/plug.ex
··· 1 - defmodule OpakeAppview.Auth.Plug do 1 + defmodule OpakeIndexer.Auth.Plug do 2 2 @moduledoc """ 3 3 Plug that verifies `Opake-Ed25519` authentication headers. 4 4 ··· 40 40 {:ok, timestamp} <- parse_timestamp(timestamp_str), 41 41 :ok <- check_timestamp_drift(timestamp), 42 42 :ok <- check_did_scope(conn, did), 43 - {:ok, signature} <- OpakeAppview.Auth.Base64.decode(signature_b64), 44 - {:ok, pubkey} <- OpakeAppview.Auth.KeyCache.get_key(did) do 43 + {:ok, signature} <- OpakeIndexer.Auth.Base64.decode(signature_b64), 44 + {:ok, pubkey} <- OpakeIndexer.Auth.KeyCache.get_key(did) do 45 45 method = conn.method 46 46 path = conn.request_path 47 47 message = "#{method}:#{path}:#{timestamp}:#{did}"
+2 -2
apps/appview/lib/opake_appview/backfill.ex apps/indexer/lib/opake_indexer/backfill.ex
··· 1 - defmodule OpakeAppview.Backfill do 1 + defmodule OpakeIndexer.Backfill do 2 2 @moduledoc """ 3 3 Backfill records from a PDS when the firehose cursor is absent or stale. 4 4 ··· 29 29 30 30 require Logger 31 31 32 - alias OpakeAppview.Queries.{DirectoryQueries, DocumentQueries, GrantQueries, KeyringQueries} 32 + alias OpakeIndexer.Queries.{DirectoryQueries, DocumentQueries, GrantQueries, KeyringQueries} 33 33 34 34 @keyring_collection "app.opake.keyring" 35 35 @directory_collection "app.opake.directory"
+14 -14
apps/appview/lib/opake_appview/indexer.ex apps/indexer/lib/opake_indexer/firehose.ex
··· 1 - defmodule OpakeAppview.Indexer do 1 + defmodule OpakeIndexer.Firehose do 2 2 @moduledoc """ 3 3 Dispatches parsed Jetstream events to query modules and maintains the 4 - shared `OpakeAppview.Indexer.State`. 4 + shared `OpakeIndexer.Firehose.State`. 5 5 6 6 ## Per-event flow 7 7 ··· 15 15 16 16 maybe_save_cursor (time-throttled, see @cursor_save_interval_ms) 17 17 18 - :telemetry.execute (see :opake_appview events below) 18 + :telemetry.execute (see :opake_indexer events below) 19 19 20 20 ## Cursor save policy 21 21 ··· 26 26 27 27 ## Telemetry events 28 28 29 - * `[:opake_appview, :indexer, :event]` 29 + * `[:opake_indexer, :indexer, :event]` 30 30 Measurement: `%{count: 1}` 31 31 Metadata: `%{collection: "app.opake.grant", action: :upsert | :delete | :ignore, status: :ok | :error | :ignored}` 32 32 33 - * `[:opake_appview, :indexer, :cursor_saved]` 33 + * `[:opake_indexer, :indexer, :cursor_saved]` 34 34 Measurement: `%{time_us: integer}` 35 35 Metadata: `%{}` 36 36 ··· 41 41 42 42 require Logger 43 43 44 - alias OpakeAppview.Jetstream.Event 45 - alias OpakeAppview.Indexer.State 44 + alias OpakeIndexer.Jetstream.Event 45 + alias OpakeIndexer.Firehose.State 46 46 47 - alias OpakeAppview.Queries.{ 47 + alias OpakeIndexer.Queries.{ 48 48 CursorQueries, 49 49 DirectoryQueries, 50 50 DocumentQueries, ··· 53 53 KeyringQueries 54 54 } 55 55 56 - alias OpakeAppview.SSE.Broadcaster 56 + alias OpakeIndexer.SSE.Broadcaster 57 57 58 - @telemetry_prefix [:opake_appview, :indexer] 58 + @telemetry_prefix [:opake_indexer, :indexer] 59 59 60 60 # -- State init -- 61 61 62 62 @doc """ 63 63 Creates the `:indexer_state` ETS table. Called once from 64 - `OpakeAppview.Application.start/2`. 64 + `OpakeIndexer.Application.start/2`. 65 65 """ 66 66 defdelegate init_state, to: State, as: :init 67 67 ··· 72 72 # -- Configuration -- 73 73 74 74 defp cursor_save_interval_ms do 75 - Application.get_env(:opake_appview, :cursor_save_interval_ms, 5_000) 75 + Application.get_env(:opake_indexer, :cursor_save_interval_ms, 5_000) 76 76 end 77 77 78 78 # -- Public entry point -- ··· 113 113 # upstream. So per-event info logging is safe: there's no flooding 114 114 # risk from bsky traffic. Opake events are rare and meaningful, and 115 115 # their log lines are exactly the operational signal you want to see 116 - # ("the appview just indexed a real user action"). 116 + # ("the indexer just indexed a real user action"). 117 117 # 118 118 # Errors are logged but not raised — one bad event cannot stall the 119 119 # whole indexer. ··· 328 328 329 329 log_query_error(result, "keyring update upsert", attrs.uri) 330 330 331 - # Immediate visibility: "leave" removes the member from the AppView's index 331 + # Immediate visibility: "leave" removes the member from the Indexer's index 332 332 if attrs.action_type == "leave" do 333 333 Logger.info("[Indexer] keyring leave: #{attrs.author_did} left #{attrs.keyring_uri}") 334 334 KeyringQueries.remove_member(attrs.keyring_uri, attrs.author_did)
+3 -3
apps/appview/lib/opake_appview/indexer/heartbeat.ex apps/indexer/lib/opake_indexer/firehose/heartbeat.ex
··· 1 - defmodule OpakeAppview.Indexer.Heartbeat do 1 + defmodule OpakeIndexer.Firehose.Heartbeat do 2 2 @moduledoc """ 3 3 Periodic liveness logger for the Jetstream indexer. 4 4 5 - Reads `OpakeAppview.Indexer.State` every #{div(30_000, 1000)}s and logs a 5 + Reads `OpakeIndexer.Firehose.State` every #{div(30_000, 1000)}s and logs a 6 6 one-line summary so operators can verify the firehose is flowing without 7 7 having to hit the health endpoint or wait for an opake-specific event. 8 8 ··· 18 18 use GenServer 19 19 require Logger 20 20 21 - alias OpakeAppview.Indexer.State 21 + alias OpakeIndexer.Firehose.State 22 22 23 23 @default_interval_ms 30_000 24 24 # How many per-collection counts to include in the log line.
+1 -1
apps/appview/lib/opake_appview/indexer/state.ex apps/indexer/lib/opake_indexer/firehose/state.ex
··· 1 - defmodule OpakeAppview.Indexer.State do 1 + defmodule OpakeIndexer.Firehose.State do 2 2 @moduledoc """ 3 3 Shared mutable state for the indexer pipeline. 4 4
+3 -3
apps/appview/lib/opake_appview/jetstream/compression.ex apps/indexer/lib/opake_indexer/jetstream/compression.ex
··· 1 - defmodule OpakeAppview.Jetstream.Compression do 1 + defmodule OpakeIndexer.Jetstream.Compression do 2 2 @moduledoc """ 3 3 zstandard decompression for the Jetstream binary frame protocol. 4 4 ··· 35 35 36 36 require Logger 37 37 38 - @dict_path Application.app_dir(:opake_appview, "priv/jetstream_zstd_dict") 38 + @dict_path Application.app_dir(:opake_indexer, "priv/jetstream_zstd_dict") 39 39 @external_resource @dict_path 40 40 @dict_bytes File.read!(@dict_path) 41 41 ··· 61 61 `@expected_dict_id` and raises a clear error if it doesn't (which 62 62 would indicate a vendoring mistake or a stale build artifact). 63 63 64 - Called from `OpakeAppview.Application.start/2` so the ddict is ready 64 + Called from `OpakeIndexer.Application.start/2` so the ddict is ready 65 65 before the consumer connects. 66 66 """ 67 67 @spec init() :: :ok
+13 -13
apps/appview/lib/opake_appview/jetstream/consumer.ex apps/indexer/lib/opake_indexer/jetstream/consumer.ex
··· 1 - defmodule OpakeAppview.Jetstream.Consumer do 1 + defmodule OpakeIndexer.Jetstream.Consumer do 2 2 @moduledoc """ 3 3 WebSocket client that subscribes to a Jetstream firehose endpoint and 4 - routes every received frame through `OpakeAppview.Indexer`. 4 + routes every received frame through `OpakeIndexer.Firehose`. 5 5 6 6 ## Subscription mode 7 7 ··· 29 29 `?compress=true` query param. Cuts wire bandwidth by ~56% 30 30 compared to the raw JSON firehose. Frames arrive as binary 31 31 websocket messages and are decompressed via 32 - `OpakeAppview.Jetstream.Compression` using a vendored dictionary. 32 + `OpakeIndexer.Jetstream.Compression` using a vendored dictionary. 33 33 34 34 * `:none` — request raw JSON frames. Use only if zstd decoding is 35 35 somehow broken in your environment, or for debugging. ··· 42 42 use WebSockex 43 43 require Logger 44 44 45 - alias OpakeAppview.Indexer 46 - alias OpakeAppview.Indexer.State 47 - alias OpakeAppview.Jetstream.Compression 48 - alias OpakeAppview.Queries.CursorQueries 45 + alias OpakeIndexer.Firehose 46 + alias OpakeIndexer.Firehose.State 47 + alias OpakeIndexer.Jetstream.Compression 48 + alias OpakeIndexer.Queries.CursorQueries 49 49 50 50 @initial_backoff_ms 1_000 51 51 @max_backoff_ms 60_000 ··· 121 121 122 122 case Compression.decompress(state.decompression_context, msg) do 123 123 {:ok, json} -> 124 - event_count = Indexer.process_message(json, state.event_count) 124 + event_count = Firehose.process_message(json, state.event_count) 125 125 {:ok, %{state | event_count: event_count}} 126 126 127 127 {:error, reason} -> ··· 134 134 # when compression is requested for control frames). 135 135 @impl true 136 136 def handle_frame({:text, msg}, state) do 137 - event_count = Indexer.process_message(msg, state.event_count) 137 + event_count = Firehose.process_message(msg, state.event_count) 138 138 {:ok, %{state | event_count: event_count}} 139 139 end 140 140 ··· 144 144 @impl true 145 145 def handle_frame({:binary, msg}, %__MODULE__{compression: :none} = state) do 146 146 Logger.warning("[Jetstream] unexpected binary frame in :none mode, treating as JSON") 147 - event_count = Indexer.process_message(msg, state.event_count) 147 + event_count = Firehose.process_message(msg, state.event_count) 148 148 {:ok, %{state | event_count: event_count}} 149 149 end 150 150 ··· 235 235 defp compression_param(:none), do: [] 236 236 237 237 defp firehose_mode do 238 - Application.get_env(:opake_appview, :firehose_mode, :full) 238 + Application.get_env(:opake_indexer, :firehose_mode, :full) 239 239 end 240 240 241 241 defp compression_mode do 242 - Application.get_env(:opake_appview, :compression, :zstd) 242 + Application.get_env(:opake_indexer, :compression, :zstd) 243 243 end 244 244 245 245 defp jetstream_url do 246 - Application.fetch_env!(:opake_appview, :jetstream_url) 246 + Application.fetch_env!(:opake_indexer, :jetstream_url) 247 247 end 248 248 end
+2 -2
apps/appview/lib/opake_appview/jetstream/event.ex apps/indexer/lib/opake_indexer/jetstream/event.ex
··· 1 - defmodule OpakeAppview.Jetstream.Event do 1 + defmodule OpakeIndexer.Jetstream.Event do 2 2 @moduledoc """ 3 3 Parses raw Jetstream JSON messages into tagged tuples for the indexer. 4 4 ··· 150 150 {:delete_keyring_update, %{uri: uri}} 151 151 152 152 # Heartbeat signal: web app writes accountConfig periodically. Logged 153 - # only — not persisted to the appview DB. Provides a regular proof-of-life 153 + # only — not persisted to the indexer DB. Provides a regular proof-of-life 154 154 # for the indexer when no real Opake activity is happening. 155 155 {@account_config_collection, op} when op in ["create", "update"] -> 156 156 {:account_config_seen, %{did: did, op: op}}
+3 -3
apps/appview/lib/opake_appview/queries/cursor_queries.ex apps/indexer/lib/opake_indexer/queries/cursor_queries.ex
··· 1 - defmodule OpakeAppview.Queries.CursorQueries do 1 + defmodule OpakeIndexer.Queries.CursorQueries do 2 2 @moduledoc """ 3 3 Read/write the singleton Jetstream cursor. The cursor tracks how far 4 4 the indexer has consumed the firehose, enabling resumption after restarts. 5 5 """ 6 6 7 - alias OpakeAppview.Repo 8 - alias OpakeAppview.Schemas.Cursor 7 + alias OpakeIndexer.Repo 8 + alias OpakeIndexer.Schemas.Cursor 9 9 10 10 @spec load_cursor() :: Cursor.t() | nil 11 11 def load_cursor do
+5 -5
apps/appview/lib/opake_appview/queries/directory_queries.ex apps/indexer/lib/opake_indexer/queries/directory_queries.ex
··· 1 - defmodule OpakeAppview.Queries.DirectoryQueries do 1 + defmodule OpakeIndexer.Queries.DirectoryQueries do 2 2 @moduledoc """ 3 3 Directory CRUD and tree/sync queries. Directories are upserted on 4 4 create/update events and soft-deleted on delete events. Supports ··· 7 7 8 8 import Ecto.Query 9 9 10 - alias OpakeAppview.Repo 11 - alias OpakeAppview.Schemas.{Directory, DirectoryUpdate, Document} 12 - alias OpakeAppview.Queries.Pagination 10 + alias OpakeIndexer.Repo 11 + alias OpakeIndexer.Schemas.{Directory, DirectoryUpdate, Document} 12 + alias OpakeIndexer.Queries.Pagination 13 13 14 14 @spec upsert_directory(map()) :: {:ok, Directory.t()} | {:error, Ecto.Changeset.t()} 15 15 def upsert_directory(attrs) do ··· 157 157 """ 158 158 @spec member_directory_updates(String.t()) :: [DirectoryUpdate.t()] 159 159 def member_directory_updates(keyring_uri) do 160 - alias OpakeAppview.Schemas.KeyringMember 160 + alias OpakeIndexer.Schemas.KeyringMember 161 161 162 162 from(du in DirectoryUpdate, 163 163 join: km in KeyringMember,
+3 -3
apps/appview/lib/opake_appview/queries/document_queries.ex apps/indexer/lib/opake_indexer/queries/document_queries.ex
··· 1 - defmodule OpakeAppview.Queries.DocumentQueries do 1 + defmodule OpakeIndexer.Queries.DocumentQueries do 2 2 @moduledoc """ 3 3 Document CRUD queries. Documents are upserted on create/update events 4 4 and soft-deleted on delete events. ··· 6 6 7 7 import Ecto.Query 8 8 9 - alias OpakeAppview.Repo 10 - alias OpakeAppview.Schemas.Document 9 + alias OpakeIndexer.Repo 10 + alias OpakeIndexer.Schemas.Document 11 11 12 12 @spec upsert_document(map()) :: {:ok, Document.t()} | {:error, Ecto.Changeset.t()} 13 13 def upsert_document(attrs) do
+6 -6
apps/appview/lib/opake_appview/queries/document_update_queries.ex apps/indexer/lib/opake_indexer/queries/document_update_queries.ex
··· 1 - defmodule OpakeAppview.Queries.DocumentUpdateQueries do 1 + defmodule OpakeIndexer.Queries.DocumentUpdateQueries do 2 2 @moduledoc """ 3 3 Document update index queries. Indexed from `app.opake.documentUpdate` 4 4 firehose events. Surfaces pending updates for the document owner to apply. ··· 6 6 7 7 import Ecto.Query 8 8 9 - alias OpakeAppview.Repo 10 - alias OpakeAppview.Schemas.DocumentUpdate 11 - alias OpakeAppview.Queries.Pagination 9 + alias OpakeIndexer.Repo 10 + alias OpakeIndexer.Schemas.DocumentUpdate 11 + alias OpakeIndexer.Queries.Pagination 12 12 13 13 @spec upsert_document_update(map()) :: {:ok, DocumentUpdate.t()} | {:error, Ecto.Changeset.t()} 14 14 def upsert_document_update(attrs) do ··· 64 64 """ 65 65 @spec member_document_updates(String.t()) :: [DocumentUpdate.t()] 66 66 def member_document_updates(keyring_uri) do 67 - alias OpakeAppview.Schemas.{Document, KeyringMember} 67 + alias OpakeIndexer.Schemas.{Document, KeyringMember} 68 68 69 69 from(du in DocumentUpdate, 70 70 join: d in Document, ··· 82 82 limit = Keyword.get(opts, :limit, 50) 83 83 cursor = Keyword.get(opts, :cursor) 84 84 85 - alias OpakeAppview.Schemas.Document 85 + alias OpakeIndexer.Schemas.Document 86 86 87 87 query = 88 88 from(du in DocumentUpdate,
+4 -4
apps/appview/lib/opake_appview/queries/grant_queries.ex apps/indexer/lib/opake_indexer/queries/grant_queries.ex
··· 1 - defmodule OpakeAppview.Queries.GrantQueries do 1 + defmodule OpakeIndexer.Queries.GrantQueries do 2 2 @moduledoc """ 3 3 Grant CRUD and inbox queries. Grants are upserted on create/update events 4 4 and deleted on delete events. `list_inbox/3` returns grants for a recipient ··· 7 7 8 8 import Ecto.Query 9 9 10 - alias OpakeAppview.Repo 11 - alias OpakeAppview.Schemas.Grant 12 - alias OpakeAppview.Queries.Pagination 10 + alias OpakeIndexer.Repo 11 + alias OpakeIndexer.Schemas.Grant 12 + alias OpakeIndexer.Queries.Pagination 13 13 14 14 @spec upsert_grant(map()) :: {:ok, Grant.t()} | {:error, Ecto.Changeset.t()} 15 15 def upsert_grant(attrs) do
+4 -4
apps/appview/lib/opake_appview/queries/keyring_queries.ex apps/indexer/lib/opake_indexer/queries/keyring_queries.ex
··· 1 - defmodule OpakeAppview.Queries.KeyringQueries do 1 + defmodule OpakeIndexer.Queries.KeyringQueries do 2 2 @moduledoc """ 3 3 Keyring membership CRUD and queries. Upserts are transactional 4 4 delete-all-then-reinsert to match the "full member list" semantics of ··· 9 9 10 10 import Ecto.Query 11 11 12 - alias OpakeAppview.Repo 13 - alias OpakeAppview.Schemas.{Keyring, KeyringMember, KeyringUpdate} 14 - alias OpakeAppview.Queries.Pagination 12 + alias OpakeIndexer.Repo 13 + alias OpakeIndexer.Schemas.{Keyring, KeyringMember, KeyringUpdate} 14 + alias OpakeIndexer.Queries.Pagination 15 15 16 16 @spec upsert_keyring(String.t(), String.t(), [map()]) :: {:ok, term()} | {:error, term()} 17 17 def upsert_keyring(keyring_uri, owner_did, member_entries) do
+1 -1
apps/appview/lib/opake_appview/queries/pagination.ex apps/indexer/lib/opake_indexer/queries/pagination.ex
··· 1 - defmodule OpakeAppview.Queries.Pagination do 1 + defmodule OpakeIndexer.Queries.Pagination do 2 2 @moduledoc """ 3 3 Shared cursor-based pagination helpers for query modules. 4 4
+6 -6
apps/appview/lib/opake_appview/release.ex apps/indexer/lib/opake_indexer/release.ex
··· 1 - defmodule OpakeAppview.Release do 1 + defmodule OpakeIndexer.Release do 2 2 @moduledoc """ 3 3 Release tasks for running outside of Mix (e.g. in a Docker container). 4 4 ··· 10 10 11 11 require Ecto.Query 12 12 13 - @app :opake_appview 13 + @app :opake_indexer 14 14 15 15 def create_db do 16 16 load_app() ··· 41 41 load_app() 42 42 43 43 {:ok, _, _} = 44 - Ecto.Migrator.with_repo(OpakeAppview.Repo, fn repo -> 45 - cursor = repo.get(OpakeAppview.Schemas.Cursor, 1) 44 + Ecto.Migrator.with_repo(OpakeIndexer.Repo, fn repo -> 45 + cursor = repo.get(OpakeIndexer.Schemas.Cursor, 1) 46 46 47 47 grant_count = 48 - repo.aggregate(OpakeAppview.Schemas.Grant, :count) 48 + repo.aggregate(OpakeIndexer.Schemas.Grant, :count) 49 49 50 50 keyring_count = 51 51 repo.one( 52 - Ecto.Query.from(km in OpakeAppview.Schemas.KeyringMember, 52 + Ecto.Query.from(km in OpakeIndexer.Schemas.KeyringMember, 53 53 select: count(km.keyring_uri, :distinct) 54 54 ) 55 55 )
-5
apps/appview/lib/opake_appview/repo.ex
··· 1 - defmodule OpakeAppview.Repo do 2 - use Ecto.Repo, 3 - otp_app: :opake_appview, 4 - adapter: Ecto.Adapters.Postgres 5 - end
+1 -1
apps/appview/lib/opake_appview/schemas/cursor.ex apps/indexer/lib/opake_indexer/schemas/cursor.ex
··· 1 - defmodule OpakeAppview.Schemas.Cursor do 1 + defmodule OpakeIndexer.Schemas.Cursor do 2 2 @moduledoc """ 3 3 Singleton row tracking the Jetstream cursor position. The `id = 1` CHECK 4 4 constraint enforces exactly one row. `time_us` is the Jetstream event
+2 -2
apps/appview/lib/opake_appview/schemas/directory.ex apps/indexer/lib/opake_indexer/schemas/directory.ex
··· 1 - defmodule OpakeAppview.Schemas.Directory do 1 + defmodule OpakeIndexer.Schemas.Directory do 2 2 @moduledoc """ 3 3 An indexed `app.opake.directory` record. Directories organize documents within 4 - workspaces or cabinets. Cabinet directories have no keyring_uri. The appview 4 + workspaces or cabinets. Cabinet directories have no keyring_uri. The indexer 5 5 indexes these from the firehose for tree/sync queries. 6 6 """ 7 7
+1 -1
apps/appview/lib/opake_appview/schemas/directory_update.ex apps/indexer/lib/opake_indexer/schemas/directory_update.ex
··· 1 - defmodule OpakeAppview.Schemas.DirectoryUpdate do 1 + defmodule OpakeIndexer.Schemas.DirectoryUpdate do 2 2 @moduledoc """ 3 3 A proposed change to a workspace directory. Written by a member to their own 4 4 PDS, indexed here so workspace members can discover pending directory actions
+2 -2
apps/appview/lib/opake_appview/schemas/document.ex apps/indexer/lib/opake_indexer/schemas/document.ex
··· 1 - defmodule OpakeAppview.Schemas.Document do 1 + defmodule OpakeIndexer.Schemas.Document do 2 2 @moduledoc """ 3 3 An indexed `app.opake.document` record. Documents are encrypted files stored 4 4 on a PDS. Workspace documents reference a keyring; cabinet documents don't. 5 - The appview indexes these from the firehose for tree/sync queries. 5 + The indexer indexes these from the firehose for tree/sync queries. 6 6 """ 7 7 8 8 use Ecto.Schema
+1 -1
apps/appview/lib/opake_appview/schemas/document_update.ex apps/indexer/lib/opake_indexer/schemas/document_update.ex
··· 1 - defmodule OpakeAppview.Schemas.DocumentUpdate do 1 + defmodule OpakeIndexer.Schemas.DocumentUpdate do 2 2 @moduledoc """ 3 3 A proposed update to a document in a workspace. Written by an editor to 4 4 their own PDS, indexed here for the document owner to discover pending
+2 -2
apps/appview/lib/opake_appview/schemas/grant.ex apps/indexer/lib/opake_indexer/schemas/grant.ex
··· 1 - defmodule OpakeAppview.Schemas.Grant do 1 + defmodule OpakeIndexer.Schemas.Grant do 2 2 @moduledoc """ 3 3 An indexed `app.opake.grant` record. Grants are sharing permissions — the 4 - owner authorizes a recipient to decrypt a specific document. The appview 4 + owner authorizes a recipient to decrypt a specific document. The indexer 5 5 indexes these from the Jetstream firehose so recipients can discover incoming 6 6 grants via the `/api/inbox` endpoint. 7 7 """
+1 -1
apps/appview/lib/opake_appview/schemas/keyring.ex apps/indexer/lib/opake_indexer/schemas/keyring.ex
··· 1 - defmodule OpakeAppview.Schemas.Keyring do 1 + defmodule OpakeIndexer.Schemas.Keyring do 2 2 @moduledoc """ 3 3 An indexed `app.opake.keyring` record. Stores keyring-level data — 4 4 rotation counter, encrypted metadata — so the `/api/keyrings` endpoint
+2 -2
apps/appview/lib/opake_appview/schemas/keyring_member.ex apps/indexer/lib/opake_indexer/schemas/keyring_member.ex
··· 1 - defmodule OpakeAppview.Schemas.KeyringMember do 1 + defmodule OpakeIndexer.Schemas.KeyringMember do 2 2 @moduledoc """ 3 3 A denormalized (keyring_uri, member_did) row. Keyrings group encrypted 4 - document keys for a set of members. The appview flattens the members array 4 + document keys for a set of members. The indexer flattens the members array 5 5 into individual rows so it can efficiently answer "which keyrings include 6 6 this DID?" via the `/api/keyrings` endpoint. Each row also stores the 7 7 member's `wrapped_key` (the crypto payload clients need to unwrap the
+1 -1
apps/appview/lib/opake_appview/schemas/keyring_update.ex apps/indexer/lib/opake_indexer/schemas/keyring_update.ex
··· 1 - defmodule OpakeAppview.Schemas.KeyringUpdate do 1 + defmodule OpakeIndexer.Schemas.KeyringUpdate do 2 2 @moduledoc "A proposed change to a workspace keyring, written by a member." 3 3 use Ecto.Schema 4 4 import Ecto.Changeset
+4 -4
apps/appview/lib/opake_appview/sse/broadcaster.ex apps/indexer/lib/opake_indexer/sse/broadcaster.ex
··· 1 - defmodule OpakeAppview.SSE.Broadcaster do 1 + defmodule OpakeIndexer.SSE.Broadcaster do 2 2 @moduledoc """ 3 3 Broadcasts indexed events to SSE subscribers via Phoenix PubSub. 4 4 ··· 13 13 14 14 require Logger 15 15 16 - alias OpakeAppview.SSE.Topics 17 - alias OpakeAppviewWeb.TreeHelpers 16 + alias OpakeIndexer.SSE.Topics 17 + alias OpakeIndexerWeb.TreeHelpers 18 18 19 - @pubsub OpakeAppview.PubSub 19 + @pubsub OpakeIndexer.PubSub 20 20 21 21 # -- Public API -- 22 22
+1 -1
apps/appview/lib/opake_appview/sse/connection_tracker.ex apps/indexer/lib/opake_indexer/sse/connection_tracker.ex
··· 1 - defmodule OpakeAppview.SSE.ConnectionTracker do 1 + defmodule OpakeIndexer.SSE.ConnectionTracker do 2 2 @moduledoc """ 3 3 Tracks active SSE connections per DID to prevent runaway reconnection 4 4 loops from exhausting BEAM processes. Simple ETS counter.
+1 -1
apps/appview/lib/opake_appview/sse/token_store.ex apps/indexer/lib/opake_indexer/sse/token_store.ex
··· 1 - defmodule OpakeAppview.SSE.TokenStore do 1 + defmodule OpakeIndexer.SSE.TokenStore do 2 2 @moduledoc """ 3 3 ETS-backed single-use token store for SSE authentication. 4 4
+1 -1
apps/appview/lib/opake_appview/sse/topics.ex apps/indexer/lib/opake_indexer/sse/topics.ex
··· 1 - defmodule OpakeAppview.SSE.Topics do 1 + defmodule OpakeIndexer.SSE.Topics do 2 2 @moduledoc """ 3 3 Canonical PubSub topic construction for SSE event routing. 4 4
+2 -2
apps/appview/lib/opake_appview/tombstone_cleanup.ex apps/indexer/lib/opake_indexer/tombstone_cleanup.ex
··· 1 - defmodule OpakeAppview.TombstoneCleanup do 1 + defmodule OpakeIndexer.TombstoneCleanup do 2 2 @moduledoc """ 3 3 Periodic cleanup of soft-deleted directory and document records. 4 4 Runs every hour, purges tombstones older than 7 days. ··· 7 7 use GenServer 8 8 require Logger 9 9 10 - alias OpakeAppview.Queries.DirectoryQueries 10 + alias OpakeIndexer.Queries.DirectoryQueries 11 11 12 12 @cleanup_interval :timer.hours(1) 13 13 @tombstone_ttl_days 7
+1 -1
apps/appview/lib/opake_appview_web.ex apps/indexer/lib/opake_appview_web.ex
··· 1 - defmodule OpakeAppviewWeb do 1 + defmodule OpakeIndexerWeb do 2 2 def router do 3 3 quote do 4 4 use Phoenix.Router, helpers: false
+4 -4
apps/appview/lib/opake_appview_web/controllers/cabinet_controller.ex apps/indexer/lib/opake_indexer_web/controllers/cabinet_controller.ex
··· 1 - defmodule OpakeAppviewWeb.CabinetController do 1 + defmodule OpakeIndexerWeb.CabinetController do 2 2 @moduledoc """ 3 3 Cabinet endpoints. Returns the authenticated user's personal (non-workspace) 4 4 directories and documents. No keyring membership check — cabinet data belongs 5 5 to the authenticated DID. 6 6 """ 7 7 8 - use OpakeAppviewWeb, :controller 8 + use OpakeIndexerWeb, :controller 9 9 10 - alias OpakeAppview.Queries.DirectoryQueries 11 - import OpakeAppviewWeb.TreeHelpers 10 + alias OpakeIndexer.Queries.DirectoryQueries 11 + import OpakeIndexerWeb.TreeHelpers 12 12 13 13 @spec snapshot(Plug.Conn.t(), map()) :: Plug.Conn.t() 14 14 def snapshot(conn, _params) do
+1 -1
apps/appview/lib/opake_appview_web/controllers/error_json.ex apps/indexer/lib/opake_indexer_web/controllers/error_json.ex
··· 1 - defmodule OpakeAppviewWeb.ErrorJSON do 1 + defmodule OpakeIndexerWeb.ErrorJSON do 2 2 def render("400.json", _assigns), do: %{error: "bad request"} 3 3 def render("401.json", _assigns), do: %{error: "unauthorized"} 4 4 def render("404.json", _assigns), do: %{error: "not found"}
+5 -5
apps/appview/lib/opake_appview_web/controllers/events_controller.ex apps/indexer/lib/opake_indexer_web/controllers/events_controller.ex
··· 1 - defmodule OpakeAppviewWeb.EventsController do 1 + defmodule OpakeIndexerWeb.EventsController do 2 2 @moduledoc """ 3 3 SSE event streaming + token exchange endpoint. 4 4 ··· 16 16 comment is sent every 15 seconds to prevent proxy timeouts. 17 17 """ 18 18 19 - use OpakeAppviewWeb, :controller 19 + use OpakeIndexerWeb, :controller 20 20 21 21 require Logger 22 22 23 - alias OpakeAppview.SSE.{TokenStore, ConnectionTracker, Topics} 24 - alias OpakeAppview.Queries.KeyringQueries 23 + alias OpakeIndexer.SSE.{TokenStore, ConnectionTracker, Topics} 24 + alias OpakeIndexer.Queries.KeyringQueries 25 25 26 26 @keepalive_interval_ms 15_000 27 - @pubsub OpakeAppview.PubSub 27 + @pubsub OpakeIndexer.PubSub 28 28 29 29 # -- Token exchange -- 30 30
+4 -4
apps/appview/lib/opake_appview_web/controllers/health_controller.ex apps/indexer/lib/opake_indexer_web/controllers/health_controller.ex
··· 1 - defmodule OpakeAppviewWeb.HealthController do 1 + defmodule OpakeIndexerWeb.HealthController do 2 2 @moduledoc """ 3 3 Unauthenticated health endpoint. Surfaces enough indexer state for an 4 4 operator (or a monitoring probe) to distinguish "WS connected and ··· 24 24 } 25 25 """ 26 26 27 - use OpakeAppviewWeb, :controller 27 + use OpakeIndexerWeb, :controller 28 28 29 - alias OpakeAppview.Indexer.State 30 - alias OpakeAppview.Queries.CursorQueries 29 + alias OpakeIndexer.Firehose.State 30 + alias OpakeIndexer.Queries.CursorQueries 31 31 32 32 @micros_per_second 1_000_000 33 33
+4 -4
apps/appview/lib/opake_appview_web/controllers/inbox_controller.ex apps/indexer/lib/opake_indexer_web/controllers/inbox_controller.ex
··· 1 - defmodule OpakeAppviewWeb.InboxController do 1 + defmodule OpakeIndexerWeb.InboxController do 2 2 @moduledoc """ 3 3 Returns incoming grants for the authenticated DID. 4 4 Supports cursor-based pagination with configurable limit (1-100, default 50). 5 5 """ 6 6 7 - use OpakeAppviewWeb, :controller 7 + use OpakeIndexerWeb, :controller 8 8 9 - alias OpakeAppview.Queries.GrantQueries 10 - import OpakeAppviewWeb.PaginationHelpers 9 + alias OpakeIndexer.Queries.GrantQueries 10 + import OpakeIndexerWeb.PaginationHelpers 11 11 12 12 def index(conn, params) do 13 13 did = conn.assigns.authenticated_did
+4 -4
apps/appview/lib/opake_appview_web/controllers/keyrings_controller.ex apps/indexer/lib/opake_indexer_web/controllers/keyrings_controller.ex
··· 1 - defmodule OpakeAppviewWeb.KeyringsController do 1 + defmodule OpakeIndexerWeb.KeyringsController do 2 2 @moduledoc """ 3 3 Returns full keyring records for keyrings where the authenticated DID is a 4 4 member. Joins `keyring_members` (membership + wrapped keys) with `keyrings` 5 5 (rotation, encrypted metadata) so clients get everything in one call. 6 6 """ 7 7 8 - use OpakeAppviewWeb, :controller 8 + use OpakeIndexerWeb, :controller 9 9 10 - alias OpakeAppview.Queries.KeyringQueries 11 - import OpakeAppviewWeb.PaginationHelpers 10 + alias OpakeIndexer.Queries.KeyringQueries 11 + import OpakeIndexerWeb.PaginationHelpers 12 12 13 13 @spec index(Plug.Conn.t(), map()) :: Plug.Conn.t() 14 14 def index(conn, params) do
+1 -1
apps/appview/lib/opake_appview_web/controllers/pagination_helpers.ex apps/indexer/lib/opake_indexer_web/controllers/pagination_helpers.ex
··· 1 - defmodule OpakeAppviewWeb.PaginationHelpers do 1 + defmodule OpakeIndexerWeb.PaginationHelpers do 2 2 @moduledoc """ 3 3 Shared parameter parsing for paginated API endpoints. 4 4 """
+1 -1
apps/appview/lib/opake_appview_web/controllers/tree_helpers.ex apps/indexer/lib/opake_indexer_web/controllers/tree_helpers.ex
··· 1 - defmodule OpakeAppviewWeb.TreeHelpers do 1 + defmodule OpakeIndexerWeb.TreeHelpers do 2 2 @moduledoc """ 3 3 Shared helpers for tree/sync responses across workspace and cabinet controllers. 4 4 Serializes directory and document schemas to snake_case JSON.
+5 -5
apps/appview/lib/opake_appview_web/controllers/workspace_controller.ex apps/indexer/lib/opake_indexer_web/controllers/workspace_controller.ex
··· 1 - defmodule OpakeAppviewWeb.WorkspaceController do 1 + defmodule OpakeIndexerWeb.WorkspaceController do 2 2 @moduledoc """ 3 3 Workspace-scoped endpoints. All actions require the caller to be a member 4 4 of the keyring identified by the `?keyring=` parameter. Returns documents, 5 5 directories, and delta-sync data for a workspace. 6 6 """ 7 7 8 - use OpakeAppviewWeb, :controller 8 + use OpakeIndexerWeb, :controller 9 9 10 - alias OpakeAppview.Queries.{ 10 + alias OpakeIndexer.Queries.{ 11 11 DirectoryQueries, 12 12 DocumentQueries, 13 13 DocumentUpdateQueries, 14 14 KeyringQueries 15 15 } 16 16 17 - import OpakeAppviewWeb.TreeHelpers 18 - import OpakeAppviewWeb.PaginationHelpers 17 + import OpakeIndexerWeb.TreeHelpers 18 + import OpakeIndexerWeb.PaginationHelpers 19 19 20 20 @spec snapshot(Plug.Conn.t(), map()) :: Plug.Conn.t() 21 21 def snapshot(conn, params) do
-20
apps/appview/lib/opake_appview_web/endpoint.ex
··· 1 - defmodule OpakeAppviewWeb.Endpoint do 2 - use Phoenix.Endpoint, otp_app: :opake_appview 3 - 4 - if code_reloading? do 5 - plug Phoenix.CodeReloader 6 - plug Phoenix.Ecto.CheckRepoStatus, otp_app: :opake_appview 7 - end 8 - 9 - plug Plug.RequestId 10 - plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 11 - 12 - plug OpakeAppviewWeb.Plugs.CORS 13 - 14 - plug Plug.Parsers, 15 - parsers: [:urlencoded, :json], 16 - pass: ["*/*"], 17 - json_decoder: Phoenix.json_library() 18 - 19 - plug OpakeAppviewWeb.Router 20 - end
+4 -4
apps/appview/lib/opake_appview_web/plugs/cors.ex apps/indexer/lib/opake_indexer_web/plugs/cors.ex
··· 1 - defmodule OpakeAppviewWeb.Plugs.CORS do 1 + defmodule OpakeIndexerWeb.Plugs.CORS do 2 2 @moduledoc """ 3 3 Minimal CORS plug. Allowed origin is configured per-environment: 4 4 5 - config :opake_appview, :cors_origin, "*" # dev 6 - config :opake_appview, :cors_origin, "https://opake.app" # prod 5 + config :opake_indexer, :cors_origin, "*" # dev 6 + config :opake_indexer, :cors_origin, "https://opake.app" # prod 7 7 8 8 Handles OPTIONS preflight and sets headers on all responses. 9 9 """ ··· 17 17 18 18 @impl true 19 19 def call(conn, _opts) do 20 - origin = Application.get_env(:opake_appview, :cors_origin, "*") 20 + origin = Application.get_env(:opake_indexer, :cors_origin, "*") 21 21 22 22 conn = 23 23 conn
+1 -1
apps/appview/lib/opake_appview_web/plugs/rate_limit.ex apps/indexer/lib/opake_indexer_web/plugs/rate_limit.ex
··· 1 - defmodule OpakeAppviewWeb.Plugs.RateLimit do 1 + defmodule OpakeIndexerWeb.Plugs.RateLimit do 2 2 @moduledoc """ 3 3 Per-IP rate limiting via Hammer (ETS backend). Allows 30 requests per second 4 4 burst. Respects `X-Forwarded-For` and `X-Real-IP` headers for clients behind
+6 -6
apps/appview/lib/opake_appview_web/router.ex apps/indexer/lib/opake_indexer_web/router.ex
··· 1 - defmodule OpakeAppviewWeb.Router do 1 + defmodule OpakeIndexerWeb.Router do 2 2 @moduledoc """ 3 3 API router. Health is public; all other routes require Opake-Ed25519 auth. 4 4 All routes are rate-limited per IP. ··· 10 10 documentation drift. 11 11 """ 12 12 13 - use OpakeAppviewWeb, :router 13 + use OpakeIndexerWeb, :router 14 14 15 15 pipeline :api do 16 16 plug :accepts, ["json"] 17 - plug OpakeAppviewWeb.Plugs.RateLimit 17 + plug OpakeIndexerWeb.Plugs.RateLimit 18 18 end 19 19 20 20 pipeline :authenticated do 21 - plug OpakeAppview.Auth.Plug 21 + plug OpakeIndexer.Auth.Plug 22 22 end 23 23 24 24 # SSE stream — token-authenticated, outside the :authenticated pipeline. 25 25 # Rate limiting excluded (long-lived connection, not a burst endpoint). 26 - scope "/api", OpakeAppviewWeb do 26 + scope "/api", OpakeIndexerWeb do 27 27 get "/events", EventsController, :stream 28 28 end 29 29 30 - scope "/api", OpakeAppviewWeb do 30 + scope "/api", OpakeIndexerWeb do 31 31 pipe_through :api 32 32 33 33 get "/health", HealthController, :index
+3 -3
apps/appview/mix.exs apps/indexer/mix.exs
··· 1 - defmodule OpakeAppview.MixProject do 1 + defmodule OpakeIndexer.MixProject do 2 2 use Mix.Project 3 3 4 4 def project do 5 5 [ 6 - app: :opake_appview, 6 + app: :opake_indexer, 7 7 version: "0.1.0", 8 8 elixir: "~> 1.15", 9 9 elixirc_paths: elixirc_paths(Mix.env()), ··· 16 16 17 17 def application do 18 18 [ 19 - mod: {OpakeAppview.Application, []}, 19 + mod: {OpakeIndexer.Application, []}, 20 20 extra_applications: [:logger, :runtime_tools, :crypto] 21 21 ] 22 22 end
apps/appview/mix.lock apps/indexer/mix.lock
apps/appview/priv/jetstream_zstd_dict apps/indexer/priv/jetstream_zstd_dict
apps/appview/priv/repo/migrations/.formatter.exs apps/indexer/priv/repo/migrations/.formatter.exs
+1 -1
apps/appview/priv/repo/migrations/20260310000001_create_cursor.exs apps/indexer/priv/repo/migrations/20260310000001_create_cursor.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateCursor do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateCursor do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260310000002_create_grants.exs apps/indexer/priv/repo/migrations/20260310000002_create_grants.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateGrants do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateGrants do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260310000003_create_keyring_members.exs apps/indexer/priv/repo/migrations/20260310000003_create_keyring_members.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateKeyringMembers do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateKeyringMembers do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260311000001_add_composite_pagination_indexes.exs apps/indexer/priv/repo/migrations/20260311000001_add_composite_pagination_indexes.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.AddCompositePaginationIndexes do 1 + defmodule OpakeIndexer.Repo.Migrations.AddCompositePaginationIndexes do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260321000001_add_role_to_keyring_members.exs apps/indexer/priv/repo/migrations/20260321000001_add_role_to_keyring_members.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.AddRoleToKeyringMembers do 1 + defmodule OpakeIndexer.Repo.Migrations.AddRoleToKeyringMembers do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260321000002_create_workspace_documents.exs apps/indexer/priv/repo/migrations/20260321000002_create_workspace_documents.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateWorkspaceDocuments do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateWorkspaceDocuments do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260321000003_create_document_updates.exs apps/indexer/priv/repo/migrations/20260321000003_create_document_updates.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateDocumentUpdates do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateDocumentUpdates do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260321000004_create_workspace_directories.exs apps/indexer/priv/repo/migrations/20260321000004_create_workspace_directories.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateWorkspaceDirectories do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateWorkspaceDirectories do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260321000005_create_directory_updates.exs apps/indexer/priv/repo/migrations/20260321000005_create_directory_updates.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateDirectoryUpdates do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateDirectoryUpdates do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260323000001_expand_directory_and_document_tables.exs apps/indexer/priv/repo/migrations/20260323000001_expand_directory_and_document_tables.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.ExpandDirectoryAndDocumentTables do 1 + defmodule OpakeIndexer.Repo.Migrations.ExpandDirectoryAndDocumentTables do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260324000001_create_keyrings.exs apps/indexer/priv/repo/migrations/20260324000001_create_keyrings.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateKeyrings do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateKeyrings do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260324000002_move_members_to_keyring_members.exs apps/indexer/priv/repo/migrations/20260324000002_move_members_to_keyring_members.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.MoveMembersToKeyringMembers do 1 + defmodule OpakeIndexer.Repo.Migrations.MoveMembersToKeyringMembers do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260324000003_enrich_directory_updates.exs apps/indexer/priv/repo/migrations/20260324000003_enrich_directory_updates.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.EnrichDirectoryUpdates do 1 + defmodule OpakeIndexer.Repo.Migrations.EnrichDirectoryUpdates do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/migrations/20260325000001_create_keyring_updates.exs apps/indexer/priv/repo/migrations/20260325000001_create_keyring_updates.exs
··· 1 - defmodule OpakeAppview.Repo.Migrations.CreateKeyringUpdates do 1 + defmodule OpakeIndexer.Repo.Migrations.CreateKeyringUpdates do 2 2 use Ecto.Migration 3 3 4 4 def change do
+1 -1
apps/appview/priv/repo/seeds.exs apps/indexer/priv/repo/seeds.exs
··· 5 5 # Inside the script, you can read and write to any of your 6 6 # repositories directly: 7 7 # 8 - # OpakeAppview.Repo.insert!(%OpakeAppview.SomeSchema{}) 8 + # OpakeIndexer.Repo.insert!(%OpakeIndexer.SomeSchema{}) 9 9 # 10 10 # We recommend using the bang functions (`insert!`, `update!` 11 11 # and so on) as they will fail if something goes wrong.
-11
apps/appview/rel/entrypoint.sh
··· 1 - #!/bin/sh 2 - set -e 3 - 4 - echo "Creating database (if needed)..." 5 - bin/opake_appview eval "OpakeAppview.Release.create_db()" 6 - 7 - echo "Running migrations..." 8 - bin/opake_appview eval "OpakeAppview.Release.migrate()" 9 - 10 - echo "Starting appview..." 11 - exec bin/opake_appview start
+2 -2
apps/appview/test/opake_appview/auth/plug_test.exs apps/indexer/test/opake_indexer/auth/plug_test.exs
··· 1 - defmodule OpakeAppview.Auth.PlugTest do 2 - use OpakeAppviewWeb.ConnCase, async: false 1 + defmodule OpakeIndexer.Auth.PlugTest do 2 + use OpakeIndexerWeb.ConnCase, async: false 3 3 import Mox 4 4 5 5 setup :set_mox_global
+4 -4
apps/appview/test/opake_appview/backfill_test.exs apps/indexer/test/opake_indexer/backfill_test.exs
··· 1 - defmodule OpakeAppview.BackfillTest do 2 - use OpakeAppview.DataCase, async: true 1 + defmodule OpakeIndexer.BackfillTest do 2 + use OpakeIndexer.DataCase, async: true 3 3 4 - alias OpakeAppview.Backfill 5 - alias OpakeAppview.Queries.{DirectoryQueries, GrantQueries, KeyringQueries} 4 + alias OpakeIndexer.Backfill 5 + alias OpakeIndexer.Queries.{DirectoryQueries, GrantQueries, KeyringQueries} 6 6 7 7 @did "did:plc:owner" 8 8
+2 -2
apps/appview/test/opake_appview/indexer/heartbeat_test.exs apps/indexer/test/opake_indexer/firehose/heartbeat_test.exs
··· 1 - defmodule OpakeAppview.Indexer.HeartbeatTest do 1 + defmodule OpakeIndexer.Firehose.HeartbeatTest do 2 2 @moduledoc """ 3 3 Tests for the heartbeat log line formatter. The GenServer itself is 4 4 trivial (timer + State.snapshot/0 + Logger.info), so we focus on ··· 7 7 8 8 use ExUnit.Case, async: true 9 9 10 - alias OpakeAppview.Indexer.Heartbeat 10 + alias OpakeIndexer.Firehose.Heartbeat 11 11 12 12 defp base_snapshot(overrides \\ %{}) do 13 13 Map.merge(
+2 -2
apps/appview/test/opake_appview/indexer/state_test.exs apps/indexer/test/opake_indexer/firehose/state_test.exs
··· 1 - defmodule OpakeAppview.Indexer.StateTest do 1 + defmodule OpakeIndexer.Firehose.StateTest do 2 2 @moduledoc """ 3 3 Unit tests for the indexer state ETS table. The table is initialized 4 4 once by the application supervisor and shared across all tests, so ··· 8 8 9 9 use ExUnit.Case, async: true 10 10 11 - alias OpakeAppview.Indexer.State 11 + alias OpakeIndexer.Firehose.State 12 12 13 13 test "bump_total/0 returns the new value" do 14 14 before = State.counter(:counter_total)
+32 -32
apps/appview/test/opake_appview/indexer_test.exs apps/indexer/test/opake_indexer/firehose_test.exs
··· 1 - defmodule OpakeAppview.IndexerTest do 2 - use OpakeAppview.DataCase, async: true 1 + defmodule OpakeIndexer.FirehoseTest do 2 + use OpakeIndexer.DataCase, async: true 3 3 4 - alias OpakeAppview.Indexer 5 - alias OpakeAppview.Indexer.State 6 - alias OpakeAppview.Queries.{CursorQueries, GrantQueries, KeyringQueries, DirectoryQueries} 4 + alias OpakeIndexer.Firehose 5 + alias OpakeIndexer.Firehose.State 6 + alias OpakeIndexer.Queries.{CursorQueries, GrantQueries, KeyringQueries, DirectoryQueries} 7 7 8 8 # Grant helpers 9 9 ··· 190 190 # Grant pipeline tests 191 191 192 192 test "indexes grant create" do 193 - Indexer.process_message(grant_create_json(), 0) 193 + Firehose.process_message(grant_create_json(), 0) 194 194 195 195 {grants, _} = GrantQueries.list_inbox("did:plc:recipient") 196 196 assert length(grants) == 1 ··· 198 198 end 199 199 200 200 test "indexes grant delete" do 201 - Indexer.process_message(grant_create_json(), 0) 202 - Indexer.process_message(grant_delete_json(), 1) 201 + Firehose.process_message(grant_create_json(), 0) 202 + Firehose.process_message(grant_delete_json(), 1) 203 203 204 204 {grants, _} = GrantQueries.list_inbox("did:plc:recipient") 205 205 assert grants == [] ··· 208 208 # Keyring pipeline tests 209 209 210 210 test "indexes keyring create" do 211 - Indexer.process_message(keyring_create_json(["did:plc:alice", "did:plc:bob"]), 0) 211 + Firehose.process_message(keyring_create_json(["did:plc:alice", "did:plc:bob"]), 0) 212 212 213 213 {alice, _} = KeyringQueries.list_keyrings_for_member("did:plc:alice") 214 214 assert length(alice) == 1 ··· 218 218 end 219 219 220 220 test "indexes keyring update replaces members" do 221 - Indexer.process_message(keyring_create_json(["did:plc:alice", "did:plc:bob"]), 0) 222 - Indexer.process_message(keyring_update_json(["did:plc:alice", "did:plc:charlie"]), 1) 221 + Firehose.process_message(keyring_create_json(["did:plc:alice", "did:plc:bob"]), 0) 222 + Firehose.process_message(keyring_update_json(["did:plc:alice", "did:plc:charlie"]), 1) 223 223 224 224 {bob, _} = KeyringQueries.list_keyrings_for_member("did:plc:bob") 225 225 assert bob == [] ··· 229 229 end 230 230 231 231 test "indexes keyring delete" do 232 - Indexer.process_message(keyring_create_json(["did:plc:alice"]), 0) 233 - Indexer.process_message(keyring_delete_json(), 1) 232 + Firehose.process_message(keyring_create_json(["did:plc:alice"]), 0) 233 + Firehose.process_message(keyring_delete_json(), 1) 234 234 235 235 {alice, _} = KeyringQueries.list_keyrings_for_member("did:plc:alice") 236 236 assert alice == [] ··· 244 244 "wrappedKey" => %{"$bytes" => "DDDD"} 245 245 } 246 246 247 - Indexer.process_message(directory_create_json(key_wrapping), 0) 247 + Firehose.process_message(directory_create_json(key_wrapping), 0) 248 248 249 249 {dirs, _docs} = DirectoryQueries.cabinet_tree("did:plc:owner") 250 250 assert length(dirs) == 1 ··· 267 267 "wrappedKey" => %{"$bytes" => "AAAA"} 268 268 } 269 269 270 - Indexer.process_message(directory_create_json(key_wrapping), 0) 270 + Firehose.process_message(directory_create_json(key_wrapping), 0) 271 271 272 272 {dirs, _docs} = DirectoryQueries.workspace_tree("at://did:plc:owner/app.opake.keyring/3def") 273 273 assert length(dirs) == 1 ··· 283 283 "wrappedKey" => %{"$bytes" => "DDDD"} 284 284 } 285 285 286 - Indexer.process_message(directory_create_json(key_wrapping), 0) 287 - Indexer.process_message(directory_delete_json(), 1) 286 + Firehose.process_message(directory_create_json(key_wrapping), 0) 287 + Firehose.process_message(directory_delete_json(), 1) 288 288 289 289 # Should not appear in cabinet tree 290 290 {dirs, _docs} = DirectoryQueries.cabinet_tree("did:plc:owner") ··· 306 306 "nonce" => "FFFF" 307 307 } 308 308 309 - Indexer.process_message(document_create_json(encryption), 0) 309 + Firehose.process_message(document_create_json(encryption), 0) 310 310 311 311 {_dirs, docs} = DirectoryQueries.cabinet_tree("did:plc:owner") 312 312 assert length(docs) == 1 ··· 330 330 "nonce" => "AABBCC" 331 331 } 332 332 333 - Indexer.process_message(document_create_json(encryption), 0) 333 + Firehose.process_message(document_create_json(encryption), 0) 334 334 335 335 {_dirs, docs} = DirectoryQueries.workspace_tree("at://did:plc:owner/app.opake.keyring/3def") 336 336 assert length(docs) == 1 ··· 348 348 "nonce" => "FFFF" 349 349 } 350 350 351 - Indexer.process_message(document_create_json(encryption), 0) 352 - Indexer.process_message(document_delete_json(), 1) 351 + Firehose.process_message(document_create_json(encryption), 0) 352 + Firehose.process_message(document_delete_json(), 1) 353 353 354 354 # Should not appear in cabinet tree 355 355 {_dirs, docs} = DirectoryQueries.cabinet_tree("did:plc:owner") ··· 374 374 # Pipeline tests run with cursor_save_interval_ms = 0 so every event 375 375 # is eligible to save. Set it explicitly and reset the State counters 376 376 # so this test group is deterministic regardless of test order. 377 - prev_interval = Application.get_env(:opake_appview, :cursor_save_interval_ms) 378 - Application.put_env(:opake_appview, :cursor_save_interval_ms, 0) 377 + prev_interval = Application.get_env(:opake_indexer, :cursor_save_interval_ms) 378 + Application.put_env(:opake_indexer, :cursor_save_interval_ms, 0) 379 379 380 380 # Clear any cursor state leaked from a prior test. 381 381 :ets.insert(:indexer_state, {:cursor_time_us, nil}) ··· 385 385 386 386 on_exit(fn -> 387 387 if prev_interval do 388 - Application.put_env(:opake_appview, :cursor_save_interval_ms, prev_interval) 388 + Application.put_env(:opake_indexer, :cursor_save_interval_ms, prev_interval) 389 389 else 390 - Application.delete_env(:opake_appview, :cursor_save_interval_ms) 390 + Application.delete_env(:opake_indexer, :cursor_save_interval_ms) 391 391 end 392 392 end) 393 393 ··· 414 414 end 415 415 416 416 test "advances the cursor for strictly-increasing time_us" do 417 - Indexer.process_message(grant_json_with_time(1_000_000), 0) 417 + Firehose.process_message(grant_json_with_time(1_000_000), 0) 418 418 assert State.last_cursor_time_us() == 1_000_000 419 419 420 - Indexer.process_message(grant_json_with_time(2_000_000), 1) 420 + Firehose.process_message(grant_json_with_time(2_000_000), 1) 421 421 assert State.last_cursor_time_us() == 2_000_000 422 422 423 - Indexer.process_message(grant_json_with_time(5_000_000), 2) 423 + Firehose.process_message(grant_json_with_time(5_000_000), 2) 424 424 assert State.last_cursor_time_us() == 5_000_000 425 425 end 426 426 427 427 test "ignores an older time_us after a newer one (no rollback)" do 428 - Indexer.process_message(grant_json_with_time(5_000_000), 0) 428 + Firehose.process_message(grant_json_with_time(5_000_000), 0) 429 429 assert State.last_cursor_time_us() == 5_000_000 430 430 431 431 # Older event arrives — cursor must stay at the high-water mark. 432 - Indexer.process_message(grant_json_with_time(3_000_000), 1) 432 + Firehose.process_message(grant_json_with_time(3_000_000), 1) 433 433 assert State.last_cursor_time_us() == 5_000_000 434 434 435 435 # And the persisted cursor in PG agrees. ··· 437 437 end 438 438 439 439 test "ignores an equal time_us (strictly monotonic)" do 440 - Indexer.process_message(grant_json_with_time(5_000_000), 0) 440 + Firehose.process_message(grant_json_with_time(5_000_000), 0) 441 441 assert State.last_cursor_time_us() == 5_000_000 442 442 443 - Indexer.process_message(grant_json_with_time(5_000_000), 1) 443 + Firehose.process_message(grant_json_with_time(5_000_000), 1) 444 444 assert State.last_cursor_time_us() == 5_000_000 445 445 end 446 446 end
+3 -3
apps/appview/test/opake_appview/jetstream/compression_test.exs apps/indexer/test/opake_indexer/jetstream/compression_test.exs
··· 1 - defmodule OpakeAppview.Jetstream.CompressionTest do 1 + defmodule OpakeIndexer.Jetstream.CompressionTest do 2 2 @moduledoc """ 3 3 Round-trip + dict-ID-validation tests for the zstd decompression module. 4 4 ··· 11 11 12 12 use ExUnit.Case, async: true 13 13 14 - alias OpakeAppview.Jetstream.Compression 14 + alias OpakeIndexer.Jetstream.Compression 15 15 16 16 setup_all do 17 17 # init/0 is idempotent — safe to call from multiple test files. ··· 41 41 end 42 42 43 43 defp dict_bytes do 44 - File.read!(Application.app_dir(:opake_appview, "priv/jetstream_zstd_dict")) 44 + File.read!(Application.app_dir(:opake_indexer, "priv/jetstream_zstd_dict")) 45 45 end 46 46 47 47 defp compress_with_dict(payload) do
+2 -2
apps/appview/test/opake_appview/jetstream/event_property_test.exs apps/indexer/test/opake_indexer/jetstream/event_property_test.exs
··· 1 - defmodule OpakeAppview.Jetstream.EventPropertyTest do 1 + defmodule OpakeIndexer.Jetstream.EventPropertyTest do 2 2 @moduledoc """ 3 3 Property-based tests for the Jetstream event parser. 4 4 Ensures parse/1 never crashes regardless of input shape. ··· 7 7 use ExUnit.Case, async: true 8 8 use ExUnitProperties 9 9 10 - alias OpakeAppview.Jetstream.Event 10 + alias OpakeIndexer.Jetstream.Event 11 11 12 12 @valid_event_tags [ 13 13 :upsert_grant,
+2 -2
apps/appview/test/opake_appview/jetstream/event_test.exs apps/indexer/test/opake_indexer/jetstream/event_test.exs
··· 1 - defmodule OpakeAppview.Jetstream.EventTest do 1 + defmodule OpakeIndexer.Jetstream.EventTest do 2 2 use ExUnit.Case, async: true 3 3 4 - alias OpakeAppview.Jetstream.Event 4 + alias OpakeIndexer.Jetstream.Event 5 5 6 6 defp grant_event_json(operation) do 7 7 Jason.encode!(%{
+26 -26
apps/appview/test/opake_appview/pipeline_test.exs apps/indexer/test/opake_indexer/pipeline_test.exs
··· 1 - defmodule OpakeAppview.PipelineTest do 1 + defmodule OpakeIndexer.PipelineTest do 2 2 @moduledoc """ 3 - End-to-end pipeline tests: raw Jetstream JSON → Indexer.process_message → DB → query results. 3 + End-to-end pipeline tests: raw Jetstream JSON → Firehose.process_message → DB → query results. 4 4 """ 5 5 6 - use OpakeAppview.DataCase, async: true 6 + use OpakeIndexer.DataCase, async: true 7 7 8 8 import Ecto.Query 9 9 10 - alias OpakeAppview.Indexer 10 + alias OpakeIndexer.Firehose 11 11 12 - alias OpakeAppview.Queries.{ 12 + alias OpakeIndexer.Queries.{ 13 13 DirectoryQueries, 14 14 DocumentQueries, 15 15 DocumentUpdateQueries, ··· 119 119 "keyringRef" => %{"keyring" => keyring_uri, "rotation" => 0} 120 120 }) 121 121 122 - Indexer.process_message(json, 0) 122 + Firehose.process_message(json, 0) 123 123 124 124 docs = DocumentQueries.list_documents(keyring_uri) 125 125 assert length(docs) == 1 ··· 137 137 "envelope" => %{"algo" => "aes-256-gcm"} 138 138 }) 139 139 140 - Indexer.process_message(json, 0) 140 + Firehose.process_message(json, 0) 141 141 142 142 # Should be in the cabinet tree (no keyring) 143 143 {dirs, docs} = DirectoryQueries.cabinet_tree("did:plc:alice") ··· 149 149 document_uri = "at://did:plc:owner/app.opake.document/3xyz" 150 150 151 151 json = document_update_create_json("did:plc:editor", "3upd", document_uri) 152 - Indexer.process_message(json, 0) 152 + Firehose.process_message(json, 0) 153 153 154 154 {updates, _cursor} = DocumentUpdateQueries.list_document_updates(document_uri) 155 155 assert length(updates) == 1 ··· 176 176 indexed_at: DateTime.utc_now() 177 177 }) 178 178 179 - Phoenix.PubSub.subscribe(OpakeAppview.PubSub, OpakeAppview.SSE.Topics.workspace(keyring_uri)) 179 + Phoenix.PubSub.subscribe(OpakeIndexer.PubSub, OpakeIndexer.SSE.Topics.workspace(keyring_uri)) 180 180 181 181 json = document_update_create_json("did:plc:docupdate_editor", "3upd_routed", document_uri) 182 - Indexer.process_message(json, 0) 182 + Firehose.process_message(json, 0) 183 183 184 184 assert_receive {:sse_event, "document_update:upsert", payload} 185 185 assert payload.document_uri == document_uri ··· 199 199 200 200 # Subscribe to both topics that could plausibly carry the event. We 201 201 # expect NEITHER to fire. 202 - Phoenix.PubSub.subscribe(OpakeAppview.PubSub, OpakeAppview.SSE.Topics.personal(author_did)) 202 + Phoenix.PubSub.subscribe(OpakeIndexer.PubSub, OpakeIndexer.SSE.Topics.personal(author_did)) 203 203 204 204 Phoenix.PubSub.subscribe( 205 - OpakeAppview.PubSub, 206 - OpakeAppview.SSE.Topics.workspace("at://did:plc:docupdate_unknown_owner/app.opake.keyring/3kr") 205 + OpakeIndexer.PubSub, 206 + OpakeIndexer.SSE.Topics.workspace("at://did:plc:docupdate_unknown_owner/app.opake.keyring/3kr") 207 207 ) 208 208 209 209 json = document_update_create_json(author_did, "3upd_unknown", document_uri) 210 - Indexer.process_message(json, 0) 210 + Firehose.process_message(json, 0) 211 211 212 212 refute_receive {:sse_event, "document_update:upsert", _}, 50 213 213 ··· 228 228 assert KeyringQueries.is_member?(keyring_uri, "did:plc:bob") 229 229 230 230 json = keyring_leave_json("did:plc:bob", keyring_uri) 231 - Indexer.process_message(json, 0) 231 + Firehose.process_message(json, 0) 232 232 233 233 refute KeyringQueries.is_member?(keyring_uri, "did:plc:bob") 234 234 assert KeyringQueries.is_member?(keyring_uri, "did:plc:alice") ··· 246 246 {"did:plc:charlie", "viewer"} 247 247 ]) 248 248 249 - Indexer.process_message(json, 0) 249 + Firehose.process_message(json, 0) 250 250 251 251 assert KeyringQueries.is_member?(keyring_uri, "did:plc:alice") 252 252 assert KeyringQueries.is_member?(keyring_uri, "did:plc:bob") ··· 254 254 255 255 members = 256 256 Repo.all( 257 - from(km in OpakeAppview.Schemas.KeyringMember, 257 + from(km in OpakeIndexer.Schemas.KeyringMember, 258 258 where: km.keyring_uri == ^keyring_uri, 259 259 order_by: km.member_did 260 260 ) ··· 287 287 created_at: "2026-03-10T08:00:00Z" 288 288 ) 289 289 290 - Indexer.process_message(json, 0) 290 + Firehose.process_message(json, 0) 291 291 292 - keyring = Repo.get(OpakeAppview.Schemas.Keyring, keyring_uri) 292 + keyring = Repo.get(OpakeIndexer.Schemas.Keyring, keyring_uri) 293 293 assert keyring != nil 294 294 assert keyring.owner_did == owner 295 295 assert keyring.rotation == 3 ··· 298 298 299 299 # Members stored in keyring_members with wrapped keys 300 300 members = 301 - from(km in OpakeAppview.Schemas.KeyringMember, where: km.keyring_uri == ^keyring_uri) 301 + from(km in OpakeIndexer.Schemas.KeyringMember, where: km.keyring_uri == ^keyring_uri) 302 302 |> Repo.all() 303 303 304 304 assert length(members) == 2 ··· 313 313 create_json = 314 314 keyring_create_json_with_roles(owner, rkey, [{"did:plc:alice", "manager"}]) 315 315 316 - Indexer.process_message(create_json, 0) 316 + Firehose.process_message(create_json, 0) 317 317 318 318 assert KeyringQueries.is_member?(keyring_uri, "did:plc:alice") 319 - assert Repo.get(OpakeAppview.Schemas.Keyring, keyring_uri) != nil 319 + assert Repo.get(OpakeIndexer.Schemas.Keyring, keyring_uri) != nil 320 320 321 321 delete_json = 322 322 Jason.encode!(%{ ··· 331 331 } 332 332 }) 333 333 334 - Indexer.process_message(delete_json, 1) 334 + Firehose.process_message(delete_json, 1) 335 335 336 336 refute KeyringQueries.is_member?(keyring_uri, "did:plc:alice") 337 - assert Repo.get(OpakeAppview.Schemas.Keyring, keyring_uri) == nil 337 + assert Repo.get(OpakeIndexer.Schemas.Keyring, keyring_uri) == nil 338 338 end 339 339 340 340 test "directory with keyringKeyWrapping indexes with keyring_uri" do ··· 360 360 } 361 361 }) 362 362 363 - Indexer.process_message(json, 0) 363 + Firehose.process_message(json, 0) 364 364 365 365 {dirs, _docs} = DirectoryQueries.workspace_tree(keyring_uri) 366 366 assert length(dirs) == 1 ··· 392 392 } 393 393 }) 394 394 395 - Indexer.process_message(json, 0) 395 + Firehose.process_message(json, 0) 396 396 397 397 {dirs, _docs} = DirectoryQueries.cabinet_tree("did:plc:alice") 398 398 assert length(dirs) == 1
+3 -3
apps/appview/test/opake_appview/queries_test.exs apps/indexer/test/opake_indexer/queries_test.exs
··· 1 - defmodule OpakeAppview.QueriesTest do 2 - use OpakeAppview.DataCase, async: true 1 + defmodule OpakeIndexer.QueriesTest do 2 + use OpakeIndexer.DataCase, async: true 3 3 4 - alias OpakeAppview.Queries.{ 4 + alias OpakeIndexer.Queries.{ 5 5 CursorQueries, 6 6 GrantQueries, 7 7 KeyringQueries,
+13 -13
apps/appview/test/opake_appview/sse/broadcaster_test.exs apps/indexer/test/opake_indexer/sse/broadcaster_test.exs
··· 1 - defmodule OpakeAppview.SSE.BroadcasterTest do 1 + defmodule OpakeIndexer.SSE.BroadcasterTest do 2 2 use ExUnit.Case, async: true 3 3 4 - alias OpakeAppview.SSE.Broadcaster 4 + alias OpakeIndexer.SSE.Broadcaster 5 5 6 - @pubsub OpakeAppview.PubSub 6 + @pubsub OpakeIndexer.PubSub 7 7 8 8 setup do 9 9 # Ensure PubSub is available (started by Application) ··· 12 12 13 13 describe "broadcast_directory/2" do 14 14 test "routes cabinet directory to did: topic" do 15 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.personal("did:plc:owner")) 15 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.personal("did:plc:owner")) 16 16 17 17 Broadcaster.broadcast_directory( 18 18 %{ ··· 31 31 32 32 test "routes workspace directory to keyring: topic" do 33 33 keyring = "at://did:plc:owner/app.opake.keyring/3kr" 34 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.workspace(keyring)) 34 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.workspace(keyring)) 35 35 36 36 Broadcaster.broadcast_directory( 37 37 %{ ··· 49 49 end 50 50 51 51 test "delete broadcasts directory_uri" do 52 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.personal("did:plc:owner")) 52 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.personal("did:plc:owner")) 53 53 54 54 Broadcaster.broadcast_directory( 55 55 %{ ··· 67 67 describe "broadcast_keyring/2" do 68 68 test "broadcasts to both keyring: and did: topics" do 69 69 uri = "at://did:plc:owner/app.opake.keyring/3kr" 70 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.workspace(uri)) 71 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.personal("did:plc:owner")) 70 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.workspace(uri)) 71 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.personal("did:plc:owner")) 72 72 73 73 Broadcaster.broadcast_keyring( 74 74 %{ ··· 89 89 90 90 describe "broadcast_grant/2" do 91 91 test "broadcasts to owner and recipient" do 92 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.personal("did:plc:owner")) 93 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.personal("did:plc:recipient")) 92 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.personal("did:plc:owner")) 93 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.personal("did:plc:recipient")) 94 94 95 95 Broadcaster.broadcast_grant( 96 96 %{ ··· 111 111 describe "broadcast_document_update/2" do 112 112 test "routes upsert to workspace topic when keyring_uri is present" do 113 113 keyring = "at://did:plc:bctest_doccast_routed/app.opake.keyring/3kr" 114 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.workspace(keyring)) 114 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.workspace(keyring)) 115 115 116 116 Broadcaster.broadcast_document_update( 117 117 %{ ··· 130 130 131 131 test "drops upsert silently when keyring_uri is absent" do 132 132 author = "did:plc:bctest_doccast_dropped" 133 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.personal(author)) 133 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.personal(author)) 134 134 135 135 Broadcaster.broadcast_document_update( 136 136 %{ ··· 151 151 152 152 test "drops delete silently when keyring_uri is absent" do 153 153 author = "did:plc:bctest_doccast_delete" 154 - Phoenix.PubSub.subscribe(@pubsub, OpakeAppview.SSE.Topics.personal(author)) 154 + Phoenix.PubSub.subscribe(@pubsub, OpakeIndexer.SSE.Topics.personal(author)) 155 155 156 156 Broadcaster.broadcast_document_update( 157 157 %{
+2 -2
apps/appview/test/opake_appview/sse/token_store_test.exs apps/indexer/test/opake_indexer/sse/token_store_test.exs
··· 1 - defmodule OpakeAppview.SSE.TokenStoreTest do 1 + defmodule OpakeIndexer.SSE.TokenStoreTest do 2 2 use ExUnit.Case, async: true 3 3 4 - alias OpakeAppview.SSE.TokenStore 4 + alias OpakeIndexer.SSE.TokenStore 5 5 6 6 describe "create_token/1 and consume_token/1" do 7 7 test "creates a token and consumes it" do
+3 -3
apps/appview/test/opake_appview_web/controllers/cabinet_controller_test.exs apps/indexer/test/opake_indexer_web/controllers/cabinet_controller_test.exs
··· 1 - defmodule OpakeAppviewWeb.CabinetControllerTest do 2 - use OpakeAppviewWeb.ConnCase, async: false 1 + defmodule OpakeIndexerWeb.CabinetControllerTest do 2 + use OpakeIndexerWeb.ConnCase, async: false 3 3 import Mox 4 4 5 - alias OpakeAppview.Queries.{DirectoryQueries, DocumentQueries} 5 + alias OpakeIndexer.Queries.{DirectoryQueries, DocumentQueries} 6 6 7 7 setup :set_mox_global 8 8 setup :verify_on_exit!
+4 -4
apps/appview/test/opake_appview_web/controllers/events_controller_test.exs apps/indexer/test/opake_indexer_web/controllers/events_controller_test.exs
··· 1 - defmodule OpakeAppviewWeb.EventsControllerTest do 2 - use OpakeAppviewWeb.ConnCase, async: false 1 + defmodule OpakeIndexerWeb.EventsControllerTest do 2 + use OpakeIndexerWeb.ConnCase, async: false 3 3 4 4 import Mox 5 5 setup :set_mox_global 6 6 setup :verify_on_exit! 7 7 8 - alias OpakeAppview.SSE.TokenStore 8 + alias OpakeIndexer.SSE.TokenStore 9 9 10 10 @did "did:plc:test123" 11 11 ··· 56 56 defp authed_conn_post(conn, did, path) do 57 57 {pubkey, privkey} = :crypto.generate_key(:eddsa, :ed25519) 58 58 59 - Mox.expect(OpakeAppview.Auth.KeyFetcherMock, :fetch_signing_key, fn ^did -> {:ok, pubkey} end) 59 + Mox.expect(OpakeIndexer.Auth.KeyFetcherMock, :fetch_signing_key, fn ^did -> {:ok, pubkey} end) 60 60 61 61 timestamp = System.system_time(:second) 62 62 message = "POST:#{path}:#{timestamp}:#{did}"
+3 -3
apps/appview/test/opake_appview_web/controllers/health_controller_test.exs apps/indexer/test/opake_indexer_web/controllers/health_controller_test.exs
··· 1 - defmodule OpakeAppviewWeb.HealthControllerTest do 2 - use OpakeAppviewWeb.ConnCase, async: true 1 + defmodule OpakeIndexerWeb.HealthControllerTest do 2 + use OpakeIndexerWeb.ConnCase, async: true 3 3 4 - alias OpakeAppview.Queries.GrantQueries 4 + alias OpakeIndexer.Queries.GrantQueries 5 5 6 6 test "returns health status", %{conn: conn} do 7 7 conn = get(conn, "/api/health")
+3 -3
apps/appview/test/opake_appview_web/controllers/inbox_controller_test.exs apps/indexer/test/opake_indexer_web/controllers/inbox_controller_test.exs
··· 1 - defmodule OpakeAppviewWeb.InboxControllerTest do 2 - use OpakeAppviewWeb.ConnCase, async: false 1 + defmodule OpakeIndexerWeb.InboxControllerTest do 2 + use OpakeIndexerWeb.ConnCase, async: false 3 3 import Mox 4 4 5 - alias OpakeAppview.Queries.GrantQueries 5 + alias OpakeIndexer.Queries.GrantQueries 6 6 7 7 setup :set_mox_global 8 8 setup :verify_on_exit!
+3 -3
apps/appview/test/opake_appview_web/controllers/keyrings_controller_test.exs apps/indexer/test/opake_indexer_web/controllers/keyrings_controller_test.exs
··· 1 - defmodule OpakeAppviewWeb.KeyringsControllerTest do 2 - use OpakeAppviewWeb.ConnCase, async: false 1 + defmodule OpakeIndexerWeb.KeyringsControllerTest do 2 + use OpakeIndexerWeb.ConnCase, async: false 3 3 import Mox 4 4 5 - alias OpakeAppview.Queries.KeyringQueries 5 + alias OpakeIndexer.Queries.KeyringQueries 6 6 7 7 setup :set_mox_global 8 8 setup :verify_on_exit!
+3 -3
apps/appview/test/opake_appview_web/controllers/workspace_controller_test.exs apps/indexer/test/opake_indexer_web/controllers/workspace_controller_test.exs
··· 1 - defmodule OpakeAppviewWeb.WorkspaceControllerTest do 2 - use OpakeAppviewWeb.ConnCase, async: false 1 + defmodule OpakeIndexerWeb.WorkspaceControllerTest do 2 + use OpakeIndexerWeb.ConnCase, async: false 3 3 import Mox 4 4 5 - alias OpakeAppview.Queries.{KeyringQueries, DirectoryQueries, DocumentQueries} 5 + alias OpakeIndexer.Queries.{KeyringQueries, DirectoryQueries, DocumentQueries} 6 6 7 7 setup :set_mox_global 8 8 setup :verify_on_exit!
+5 -5
apps/appview/test/support/conn_case.ex apps/indexer/test/support/conn_case.ex
··· 1 - defmodule OpakeAppviewWeb.ConnCase do 1 + defmodule OpakeIndexerWeb.ConnCase do 2 2 use ExUnit.CaseTemplate 3 3 4 4 using do 5 5 quote do 6 - @endpoint OpakeAppviewWeb.Endpoint 6 + @endpoint OpakeIndexerWeb.Endpoint 7 7 8 8 import Plug.Conn 9 9 import Phoenix.ConnTest 10 - import OpakeAppviewWeb.ConnCase 10 + import OpakeIndexerWeb.ConnCase 11 11 end 12 12 end 13 13 14 14 setup tags do 15 - OpakeAppview.DataCase.setup_sandbox(tags) 15 + OpakeIndexer.DataCase.setup_sandbox(tags) 16 16 {:ok, conn: Phoenix.ConnTest.build_conn()} 17 17 end 18 18 ··· 23 23 def authed_conn(conn, did, path) do 24 24 {pubkey, privkey} = :crypto.generate_key(:eddsa, :ed25519) 25 25 26 - Mox.expect(OpakeAppview.Auth.KeyFetcherMock, :fetch_signing_key, fn ^did -> {:ok, pubkey} end) 26 + Mox.expect(OpakeIndexer.Auth.KeyFetcherMock, :fetch_signing_key, fn ^did -> {:ok, pubkey} end) 27 27 28 28 timestamp = System.system_time(:second) 29 29 message = "GET:#{path}:#{timestamp}:#{did}"
+6 -6
apps/appview/test/support/data_case.ex apps/indexer/test/support/data_case.ex
··· 1 - defmodule OpakeAppview.DataCase do 1 + defmodule OpakeIndexer.DataCase do 2 2 @moduledoc """ 3 3 This module defines the setup for tests requiring 4 4 access to the application's data layer. ··· 10 10 we enable the SQL sandbox, so changes done to the database 11 11 are reverted at the end of every test. If you are using 12 12 PostgreSQL, you can even run database tests asynchronously 13 - by setting `use OpakeAppview.DataCase, async: true`, although 13 + by setting `use OpakeIndexer.DataCase, async: true`, although 14 14 this option is not recommended for other databases. 15 15 """ 16 16 ··· 18 18 19 19 using do 20 20 quote do 21 - alias OpakeAppview.Repo 21 + alias OpakeIndexer.Repo 22 22 23 23 import Ecto 24 24 import Ecto.Changeset 25 25 import Ecto.Query 26 - import OpakeAppview.DataCase 26 + import OpakeIndexer.DataCase 27 27 end 28 28 end 29 29 30 30 setup tags do 31 - OpakeAppview.DataCase.setup_sandbox(tags) 31 + OpakeIndexer.DataCase.setup_sandbox(tags) 32 32 :ok 33 33 end 34 34 ··· 36 36 Sets up the sandbox based on the test tags. 37 37 """ 38 38 def setup_sandbox(tags) do 39 - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(OpakeAppview.Repo, shared: not tags[:async]) 39 + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(OpakeIndexer.Repo, shared: not tags[:async]) 40 40 on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 41 end 42 42
-5
apps/appview/test/test_helper.exs
··· 1 - Mox.defmock(OpakeAppview.Auth.KeyFetcherMock, for: OpakeAppview.Auth.KeyFetcherBehaviour) 2 - Application.put_env(:opake_appview, :key_fetcher, OpakeAppview.Auth.KeyFetcherMock) 3 - 4 - ExUnit.start() 5 - Ecto.Adapters.SQL.Sandbox.mode(OpakeAppview.Repo, :manual)
+7 -7
apps/cli/src/commands/config.rs
··· 15 15 Examples: 16 16 opake config 17 17 opake config set telemetry-enabled true 18 - opake config set appview-url https://appview.opake.app")] 18 + opake config set indexer-url https://indexer.opake.app")] 19 19 pub struct ConfigCommand { 20 20 #[command(subcommand)] 21 21 action: Option<ConfigAction>, ··· 35 35 value: String, 36 36 } 37 37 38 - const VALID_KEYS: &[&str] = &["appview-url", "telemetry-enabled"]; 38 + const VALID_KEYS: &[&str] = &["indexer-url", "telemetry-enabled"]; 39 39 40 40 fn parse_bool(value: &str) -> Result<bool> { 41 41 match value { ··· 62 62 .unwrap_or_else(|| AccountConfigRecord::new(&now)); 63 63 64 64 match args.key.as_str() { 65 - "appview-url" => { 65 + "indexer-url" => { 66 66 let url = args.value.trim().to_string(); 67 - config.appview_url = if url.is_empty() { None } else { Some(url) }; 67 + config.indexer_url = if url.is_empty() { None } else { Some(url) }; 68 68 } 69 69 "telemetry-enabled" => { 70 70 config.telemetry_enabled = parse_bool(&args.value)?; ··· 97 97 }; 98 98 println!("telemetry {telemetry}"); 99 99 println!( 100 - "appview {}", 101 - config.appview_url.as_deref().unwrap_or("(not set)") 100 + "indexer {}", 101 + config.indexer_url.as_deref().unwrap_or("(not set)") 102 102 ); 103 103 println!("modified {}", config.modified_at); 104 104 } 105 105 None => { 106 106 println!("telemetry disabled (default)"); 107 - println!("appview (not set)"); 107 + println!("indexer (not set)"); 108 108 println!("modified never"); 109 109 } 110 110 }
+6 -6
apps/cli/src/commands/daemon/mod.rs
··· 92 92 93 93 // Spawn one long-lived SSE consumer per configured account. 94 94 // Each consumer does an initial catch-up sync on connect, 95 - // then streams events from the appview and applies proposals 95 + // then streams events from the indexer and applies proposals 96 96 // as they arrive. Record events (DirectoryUpsert, 97 97 // DocumentUpsert, etc.) are dropped — the CLI has no 98 98 // TreeKeeper or UI that needs live tree state. ··· 263 263 } 264 264 }; 265 265 266 - let appview_url = match opake.resolve_appview_url(None) { 266 + let indexer_url = match opake.resolve_indexer_url(None) { 267 267 Ok(url) => url, 268 268 Err(e) => { 269 - warn!("sync: no appview URL for {did}: {e}"); 269 + warn!("sync: no indexer URL for {did}: {e}"); 270 270 return; 271 271 } 272 272 }; ··· 303 303 }); 304 304 305 305 let transport = ReqwestSseTransport::with_default_client(); 306 - let mut consumer = SseConsumer::new(transport, appview_url, token_fetcher, sleep_fn, jitter_fn); 306 + let mut consumer = SseConsumer::new(transport, indexer_url, token_fetcher, sleep_fn, jitter_fn); 307 307 308 308 info!("sync: consumer started for {did}"); 309 309 ··· 408 408 ) 409 409 .await?; 410 410 411 - if let Ok(url) = std::env::var("OPAKE_APPVIEW_URL") { 412 - opake.set_appview_url(url); 411 + if let Ok(url) = std::env::var("OPAKE_INDEXER_URL") { 412 + opake.set_indexer_url(url); 413 413 } 414 414 415 415 Ok(opake)
+6 -6
apps/cli/src/commands/inbox.rs
··· 6 6 use crate::session::CommandContext; 7 7 8 8 #[derive(Args)] 9 - /// List grants shared with you (via appview) 9 + /// List grants shared with you (via indexer) 10 10 pub struct InboxCommand { 11 11 /// Show long format with document URIs and notes 12 12 #[arg(short, long)] 13 13 long: bool, 14 14 15 - /// AppView URL (overrides OPAKE_APPVIEW_URL and config) 15 + /// Indexer URL (overrides OPAKE_INDEXER_URL and config) 16 16 #[arg(long, value_name = "URL")] 17 - appview: Option<String>, 17 + indexer: Option<String>, 18 18 } 19 19 20 20 fn format_short(grants: &[InboxGrant]) -> String { ··· 43 43 let mut opake = ctx.opake().await?; 44 44 45 45 // CLI: flag → env var → account config (core handles the last one). 46 - let env_url = std::env::var("OPAKE_APPVIEW_URL").ok(); 47 - let appview_url = self.appview.as_deref().or(env_url.as_deref()); 46 + let env_url = std::env::var("OPAKE_INDEXER_URL").ok(); 47 + let indexer_url = self.indexer.as_deref().or(env_url.as_deref()); 48 48 49 - let grants = opake.list_inbox(appview_url).await?; 49 + let grants = opake.list_inbox(indexer_url).await?; 50 50 51 51 if grants.is_empty() { 52 52 println!("no incoming grants");
+1 -1
apps/cli/src/commands/share_group.rs
··· 27 27 New(share::NewShareCommand), 28 28 /// List grants you've shared with others 29 29 List(shared::SharedCommand), 30 - /// List grants shared with you (via appview) 30 + /// List grants shared with you (via indexer) 31 31 Inbox(inbox::InboxCommand), 32 32 /// Revoke a share grant 33 33 ///
+3 -3
apps/cli/src/commands/workspace.rs
··· 113 113 async fn ls(ctx: &CommandContext, args: LsArgs) -> Result<Option<Session>> { 114 114 let mut opake = ctx.opake().await?; 115 115 116 - // Single source: the appview indexes every keyring the caller is a 116 + // Single source: the indexer sees every keyring the caller is a 117 117 // member of — owned workspaces included, since the owner is always a 118 118 // member of their own. Staleness window exists after `workspace 119 - // create` until Jetstream delivers the commit to the appview indexer. 119 + // create` until Jetstream delivers the commit to the indexer's firehose consumer. 120 120 let keyrings = opake.discover_member_keyrings(None).await?; 121 121 122 122 if keyrings.is_empty() { ··· 128 128 let did = opake.did(); 129 129 130 130 for kr in &keyrings { 131 - let name = keyrings::decrypt_appview_keyring_name(kr, did, &private_key) 131 + let name = keyrings::decrypt_indexer_keyring_name(kr, did, &private_key) 132 132 .unwrap_or_else(|| "<encrypted>".into()); 133 133 let role_tag = if kr.owner_did == did { 134 134 ""
+2 -2
apps/cli/src/session.rs
··· 43 43 .await?; 44 44 45 45 // Runtime env override (dev/CI convenience — skip recompile). 46 - if let Ok(url) = std::env::var("OPAKE_APPVIEW_URL") { 47 - opake.set_appview_url(url); 46 + if let Ok(url) = std::env::var("OPAKE_INDEXER_URL") { 47 + opake.set_indexer_url(url); 48 48 } 49 49 50 50 Ok(opake)
+25
apps/indexer/config/test.exs
··· 1 + import Config 2 + 3 + config :opake_indexer, OpakeIndexer.Repo, 4 + username: "postgres", 5 + password: "postgres", 6 + hostname: "localhost", 7 + database: "opake_indexer_test#{System.get_env("MIX_TEST_PARTITION")}", 8 + pool: Ecto.Adapters.SQL.Sandbox, 9 + pool_size: System.schedulers_online() * 2 10 + 11 + config :opake_indexer, OpakeIndexerWeb.Endpoint, 12 + http: [ip: {127, 0, 0, 1}, port: 4002], 13 + secret_key_base: "qLFGZ/oib3gMumgPqHDETV3VM0klUHbVSFijSctQvjmTDRoshDjL+HyCbHS8IS3k", 14 + server: false 15 + 16 + config :opake_indexer, :indexer_enabled, false 17 + 18 + # Tests don't talk to Jetstream — they call Firehose.process_message 19 + # directly. Make cursor saves immediate so the existing pipeline tests 20 + # behave identically to before the time-based throttling. 21 + config :opake_indexer, :cursor_save_interval_ms, 0 22 + config :opake_indexer, :firehose_mode, {:custom, []} 23 + 24 + config :logger, level: :warning 25 + config :phoenix, :plug_init_mode, :runtime
+53
apps/indexer/lib/opake_indexer/application.ex
··· 1 + defmodule OpakeIndexer.Application do 2 + @moduledoc """ 3 + OTP application for the Opake Indexer — a read-only Jetstream indexer and 4 + query API for encrypted sharing metadata on the AT Protocol. 5 + 6 + Supervision tree: 7 + - Repo (Ecto/Postgres connection pool) 8 + - Auth.KeyCache (GenServer + ETS for Ed25519 public key caching) 9 + - Endpoint (Phoenix/Bandit HTTP server — always started, `server: false` skips binding) 10 + - Jetstream.Consumer (WebSockex — conditional on `:indexer_enabled` config) 11 + """ 12 + 13 + use Application 14 + 15 + @impl true 16 + def start(_type, _args) do 17 + OpakeIndexer.Firehose.init_state() 18 + OpakeIndexer.Jetstream.Compression.init() 19 + OpakeIndexer.SSE.TokenStore.init_table() 20 + OpakeIndexer.SSE.ConnectionTracker.init_table() 21 + 22 + children = 23 + [ 24 + OpakeIndexer.Repo, 25 + {Phoenix.PubSub, name: OpakeIndexer.PubSub}, 26 + OpakeIndexer.SSE.TokenStore, 27 + OpakeIndexer.Auth.KeyCache, 28 + OpakeIndexerWeb.Endpoint 29 + ] ++ 30 + maybe_indexer_children() 31 + 32 + opts = [strategy: :one_for_one, name: OpakeIndexer.Supervisor] 33 + Supervisor.start_link(children, opts) 34 + end 35 + 36 + defp maybe_indexer_children do 37 + if Application.get_env(:opake_indexer, :indexer_enabled, true) do 38 + [ 39 + OpakeIndexer.TombstoneCleanup, 40 + OpakeIndexer.Jetstream.Consumer, 41 + OpakeIndexer.Firehose.Heartbeat 42 + ] 43 + else 44 + [] 45 + end 46 + end 47 + 48 + @impl true 49 + def config_change(changed, _new, removed) do 50 + OpakeIndexerWeb.Endpoint.config_change(changed, removed) 51 + :ok 52 + end 53 + end
+5
apps/indexer/lib/opake_indexer/repo.ex
··· 1 + defmodule OpakeIndexer.Repo do 2 + use Ecto.Repo, 3 + otp_app: :opake_indexer, 4 + adapter: Ecto.Adapters.Postgres 5 + end
+20
apps/indexer/lib/opake_indexer_web/endpoint.ex
··· 1 + defmodule OpakeIndexerWeb.Endpoint do 2 + use Phoenix.Endpoint, otp_app: :opake_indexer 3 + 4 + if code_reloading? do 5 + plug Phoenix.CodeReloader 6 + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :opake_indexer 7 + end 8 + 9 + plug Plug.RequestId 10 + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 11 + 12 + plug OpakeIndexerWeb.Plugs.CORS 13 + 14 + plug Plug.Parsers, 15 + parsers: [:urlencoded, :json], 16 + pass: ["*/*"], 17 + json_decoder: Phoenix.json_library() 18 + 19 + plug OpakeIndexerWeb.Router 20 + end
+11
apps/indexer/rel/entrypoint.sh
··· 1 + #!/bin/sh 2 + set -e 3 + 4 + echo "Creating database (if needed)..." 5 + bin/opake_indexer eval "OpakeIndexer.Release.create_db()" 6 + 7 + echo "Running migrations..." 8 + bin/opake_indexer eval "OpakeIndexer.Release.migrate()" 9 + 10 + echo "Starting indexer..." 11 + exec bin/opake_indexer start
+5
apps/indexer/test/test_helper.exs
··· 1 + Mox.defmock(OpakeIndexer.Auth.KeyFetcherMock, for: OpakeIndexer.Auth.KeyFetcherBehaviour) 2 + Application.put_env(:opake_indexer, :key_fetcher, OpakeIndexer.Auth.KeyFetcherMock) 3 + 4 + ExUnit.start() 5 + Ecto.Adapters.SQL.Sandbox.mode(OpakeIndexer.Repo, :manual)
+1 -1
apps/web/.env
··· 1 - VITE_APPVIEW_URL=http://localhost:6100 1 + VITE_INDEXER_URL=http://localhost:6100
+3 -3
apps/web/src/content/docs/glossary.mdx
··· 10 10 11 11 The symmetric encryption algorithm we use for file contents. It's fast, secure, and industry-standard. 12 12 13 - ### AppView 13 + ### Indexer 14 14 15 - A specialized service that indexes the [Firehose](#firehose) and provides a fast way to discover which files have been shared with you. It never sees your plaintext data. 15 + A specialized service that indexes the [Firehose](#firehose) and provides a fast way to discover which files have been shared with you. It never sees your plaintext data. Fills the atproto "indexer" protocol role, but we call it the indexer because all payloads are ciphertext and it serves no rendered views. 16 16 17 17 ### Cabinet 18 18 ··· 28 28 29 29 ### Firehose 30 30 31 - The real-time stream of all public records being created on the AT Protocol. Our AppView "listens" to the firehose to find new Sharing Grants. 31 + The real-time stream of all public records being created on the AT Protocol. Our indexer "listens" to the firehose to find new Sharing Grants. 32 32 33 33 ### Grant 34 34
+2 -2
apps/web/src/content/troubleshooting.mdx
··· 58 58 59 59 ### "I don't see shared files in my inbox" 60 60 61 - Opake uses an **AppView** to index sharing grants. 61 + Opake uses an **indexer** to index sharing grants. 62 62 63 63 - **Index Lag:** Sometimes it takes a few moments for the firehose to catch up. 64 - - **AppView Status:** Check if the AppView service is healthy. If the indexer is down, your inbox will appear empty even if the files exist. 64 + - **Indexer Status:** Check if the indexer service is healthy. If the indexer is down, your inbox will appear empty even if the files exist. 65 65 66 66 --- 67 67
+2 -2
apps/web/src/lib/profileResolution.ts
··· 1 1 // Best-effort resolution of a DID → display profile (handle + avatar). 2 2 // 3 - // Uses Bluesky's public appview so we don't pay DPoP setup just to render 3 + // Uses Bluesky's public indexer so we don't pay DPoP setup just to render 4 4 // a member list. If the account isn't on bsky, or the fetch fails for 5 5 // any reason, we degrade gracefully to a null profile and callers fall 6 6 // back to the raw DID as display text. 7 7 // 8 8 // Results are memoized per DID for the lifetime of the page — the bsky 9 - // appview is already cached at the CDN, but coalescing local duplicates 9 + // indexer is already cached at the CDN, but coalescing local duplicates 10 10 // avoids N parallel fetches when a workspace has many members. 11 11 12 12 const PUBLIC_API = "https://public.api.bsky.app";
+2 -2
apps/web/src/routes/cabinet/route.lazy.tsx
··· 34 34 // `TreeKeeper::uninstall_all` runs and the previous user's 35 35 // `ContentKey`s / decrypted names don't linger across login. 36 36 // 37 - // The appview URL is resolved inside WASM from the stored config — 38 - // seeded at boot via `setDefaultAppviewUrl`. No env read here. 37 + // The indexer URL is resolved inside WASM from the stored config — 38 + // seeded at boot via `setDefaultIndexerUrl`. No env read here. 39 39 useEffect(() => { 40 40 const opake = getOpake(); 41 41 void opake.startSseConsumer().catch((err: unknown) => {
+21 -21
apps/web/src/routes/cabinet/settings.lazy.tsx
··· 14 14 const pdsUrl = session.status === "active" ? session.pdsUrl : null; 15 15 16 16 const [config, setConfig] = useState<import("@opake/sdk").AccountConfig | null>(null); 17 - const [appviewUrl, setAppviewUrl] = useState(""); 18 - const [savedAppviewUrl, setSavedAppviewUrl] = useState(""); 17 + const [indexerUrl, setIndexerUrl] = useState(""); 18 + const [savedIndexerUrl, setSavedIndexerUrl] = useState(""); 19 19 const [saving, setSaving] = useState(false); 20 20 21 21 // Load the persisted config once per session. The cancelled flag ··· 29 29 try { 30 30 const result = await getOpake().getAccountConfig(); 31 31 if (cancelled.current) return; 32 - const url = result?.appviewUrl ?? ""; 32 + const url = result?.indexerUrl ?? ""; 33 33 setConfig(result); 34 - setAppviewUrl(url); 35 - setSavedAppviewUrl(url); 34 + setIndexerUrl(url); 35 + setSavedIndexerUrl(url); 36 36 } catch (err) { 37 37 if (cancelled.current) return; 38 38 console.error("[settings] failed to load account config:", err); ··· 44 44 }; 45 45 }, [did]); 46 46 47 - const handleAppviewSave = useCallback(() => { 47 + const handleIndexerSave = useCallback(() => { 48 48 if (!did) return; 49 - const trimmed = appviewUrl.trim(); 49 + const trimmed = indexerUrl.trim(); 50 50 51 51 // Validate URL before saving — a malicious URL would receive Ed25519 52 52 // auth signatures that could be replayed within the 60s window. ··· 54 54 try { 55 55 const parsed = new URL(trimmed); 56 56 if (parsed.protocol !== "https:") { 57 - toastError("AppView URL must use HTTPS"); 57 + toastError("Indexer URL must use HTTPS"); 58 58 return; 59 59 } 60 60 } catch { ··· 70 70 // Empty field → explicit null (clear the stored override). 71 71 // Non-empty → set the new URL. Never undefined, which would 72 72 // leave the current value untouched instead of clearing it. 73 - appviewUrl: trimmed.length > 0 ? trimmed : null, 73 + indexerUrl: trimmed.length > 0 ? trimmed : null, 74 74 }; 75 75 const updated = await getOpake().updateAccountConfig(patch); 76 76 setConfig(updated); 77 - setSavedAppviewUrl(updated.appviewUrl ?? ""); 78 - toastSuccess("AppView URL saved"); 77 + setSavedIndexerUrl(updated.indexerUrl ?? ""); 78 + toastSuccess("Indexer URL saved"); 79 79 } catch (err) { 80 80 toastError(err instanceof Error ? err.message : "Failed to save"); 81 81 } finally { 82 82 setSaving(false); 83 83 } 84 84 })(); 85 - }, [appviewUrl, did]); 85 + }, [indexerUrl, did]); 86 86 87 - const appviewDirty = appviewUrl !== savedAppviewUrl; 87 + const indexerDirty = indexerUrl !== savedIndexerUrl; 88 88 89 89 const breadcrumbs = <span>Settings</span>; 90 90 ··· 124 124 </div> 125 125 </section> 126 126 127 - {/* AppView URL */} 127 + {/* Indexer URL */} 128 128 <section> 129 - <h2 className="text-base-content mb-3 text-sm font-semibold">AppView URL</h2> 129 + <h2 className="text-base-content mb-3 text-sm font-semibold">Indexer URL</h2> 130 130 <div className="space-y-3"> 131 131 <p className="text-base-content/60 text-sm"> 132 - The AppView indexes workspace membership and incoming shares. Leave blank to use the 132 + The Indexer indexes workspace membership and incoming shares. Leave blank to use the 133 133 default. 134 134 </p> 135 135 <div className="flex gap-2"> 136 136 <input 137 137 type="url" 138 138 className="input input-bordered input-sm flex-1" 139 - placeholder="https://appview.opake.app" 140 - value={appviewUrl} 141 - onChange={(e) => setAppviewUrl(e.target.value)} 139 + placeholder="https://indexer.opake.app" 140 + value={indexerUrl} 141 + onChange={(e) => setIndexerUrl(e.target.value)} 142 142 disabled={saving} 143 143 /> 144 144 <button 145 145 type="button" 146 146 className="btn btn-sm btn-primary gap-1.5" 147 - disabled={!appviewDirty || saving} 148 - onClick={handleAppviewSave} 147 + disabled={!indexerDirty || saving} 148 + onClick={handleIndexerSave} 149 149 > 150 150 <FloppyDiskIcon size={16} /> Save 151 151 </button>
+13 -13
apps/web/src/stores/auth.ts
··· 61 61 const loadStorage = () => import("@opake/sdk/storage/indexeddb"); 62 62 63 63 /** 64 - * Seed the appview URL into a freshly-initialized Opake instance. 64 + * Seed the indexer URL into a freshly-initialized Opake instance. 65 65 * 66 - * The WASM binary ships with a compile-time `DEFAULT_APPVIEW_URL` 67 - * baked in via `OPAKE_APPVIEW_URL`, but one binary serves multiple 66 + * The WASM binary ships with a compile-time `DEFAULT_INDEXER_URL` 67 + * baked in via `OPAKE_INDEXER_URL`, but one binary serves multiple 68 68 * web deployments — staging, prod, local dev — so the runtime 69 - * `VITE_APPVIEW_URL` has to win. Later writes to `accountConfig` on 69 + * `VITE_INDEXER_URL` has to win. Later writes to `accountConfig` on 70 70 * the PDS override this value via `set_account_config` inside core, 71 - * so a user-configured appview still beats the host default. 71 + * so a user-configured indexer still beats the host default. 72 72 */ 73 - async function seedAppviewUrl(opake: import("@opake/sdk").Opake): Promise<void> { 74 - const envUrl = import.meta.env.VITE_APPVIEW_URL as string | undefined; 73 + async function seedIndexerUrl(opake: import("@opake/sdk").Opake): Promise<void> { 74 + const envUrl = import.meta.env.VITE_INDEXER_URL as string | undefined; 75 75 if (!envUrl) return; 76 76 try { 77 - await opake.setAppviewUrl(envUrl); 77 + await opake.setIndexerUrl(envUrl); 78 78 } catch (err) { 79 - console.warn("[auth] setAppviewUrl failed:", err); 79 + console.warn("[auth] setIndexerUrl failed:", err); 80 80 } 81 81 } 82 82 ··· 218 218 await s.saveIdentity(did, identity); 219 219 220 220 const opake = await Opake.init({ storage: s, did }); 221 - await seedAppviewUrl(opake); 221 + await seedIndexerUrl(opake); 222 222 opakeInstance?.destroy(); 223 223 opakeInstance = opake; 224 224 } ··· 274 274 }); 275 275 return; 276 276 } 277 - await seedAppviewUrl(opake); 277 + await seedIndexerUrl(opake); 278 278 opakeInstance = opake; 279 279 280 280 // Probe: verify the session is actually usable. Opake.init() ··· 386 386 }); 387 387 388 388 const opake = await Opake.init({ storage: s }); 389 - await seedAppviewUrl(opake); 389 + await seedIndexerUrl(opake); 390 390 opakeInstance = opake; 391 391 392 392 set((draft) => { ··· 481 481 await s.saveIdentity(did, identity); 482 482 483 483 const opake = await Opake.init({ storage: s, did }); 484 - await seedAppviewUrl(opake); 484 + await seedIndexerUrl(opake); 485 485 opakeInstance?.destroy(); 486 486 opakeInstance = opake; 487 487
+3 -3
apps/web/src/stores/workspace.ts
··· 9 9 // There is no optimistic-update cooldown, no visibility-listener 10 10 // fallback, and no CustomEvent bridge. Those were workarounds for the 11 11 // old "re-fetch listWorkspaces on every SSE hint" pattern, which paid 12 - // 1–4s of appview cursor lag per update. 12 + // 1–4s of indexer cursor lag per update. 13 13 14 14 import { create } from "zustand"; 15 15 import { immer } from "zustand/middleware/immer"; ··· 101 101 // `listWorkspaces`, and the watcher above sees the resulting 102 102 // snapshot. Failures surface as store-level errors but don't 103 103 // tear down the watcher — subsequent SSE events can still 104 - // populate it. The appview URL is resolved inside WASM from the 105 - // stored config — seeded at boot via `setDefaultAppviewUrl`. 104 + // populate it. The indexer URL is resolved inside WASM from the 105 + // stored config — seeded at boot via `setDefaultIndexerUrl`. 106 106 if (bootstrapPromise) return; 107 107 const done = loading("workspaces-bootstrap"); 108 108 bootstrapPromise = (async () => {
+24 -24
crates/opake-core/src/account_config_tests.rs
··· 26 26 .to_string() 27 27 } 28 28 29 - fn account_config_json_ext(telemetry: bool, appview: Option<&str>, modified_at: &str) -> String { 29 + fn account_config_json_ext(telemetry: bool, indexer: Option<&str>, modified_at: &str) -> String { 30 30 let record = AccountConfigRecord { 31 31 telemetry_enabled: telemetry, 32 - appview_url: appview.map(str::to_string), 32 + indexer_url: indexer.map(str::to_string), 33 33 ..AccountConfigRecord::new(modified_at) 34 34 }; 35 35 serde_json::json!({ ··· 174 174 175 175 let updates: AccountConfigUpdates = serde_json::from_str("{}").unwrap(); 176 176 assert!(updates.telemetry_enabled.is_none()); 177 - assert!(updates.appview_url.is_none()); 177 + assert!(updates.indexer_url.is_none()); 178 178 } 179 179 180 180 #[test] 181 - fn updates_explicit_null_clears_appview_url() { 181 + fn updates_explicit_null_clears_indexer_url() { 182 182 use crate::records::AccountConfigUpdates; 183 183 184 - let updates: AccountConfigUpdates = serde_json::from_str(r#"{"appviewUrl": null}"#).unwrap(); 185 - assert_eq!(updates.appview_url, Some(None)); 184 + let updates: AccountConfigUpdates = serde_json::from_str(r#"{"indexerUrl": null}"#).unwrap(); 185 + assert_eq!(updates.indexer_url, Some(None)); 186 186 } 187 187 188 188 #[test] 189 - fn updates_value_sets_appview_url() { 189 + fn updates_value_sets_indexer_url() { 190 190 use crate::records::AccountConfigUpdates; 191 191 192 192 let updates: AccountConfigUpdates = 193 - serde_json::from_str(r#"{"appviewUrl": "https://appview.test"}"#).unwrap(); 193 + serde_json::from_str(r#"{"indexerUrl": "https://indexer.test"}"#).unwrap(); 194 194 assert_eq!( 195 - updates.appview_url, 196 - Some(Some("https://appview.test".into())) 195 + updates.indexer_url, 196 + Some(Some("https://indexer.test".into())) 197 197 ); 198 198 } 199 199 ··· 211 211 // --------------------------------------------------------------------------- 212 212 213 213 /// Updating one field must leave all other fields at their stored values. 214 - /// Specifically: omitting `appview_url` in the updates payload must NOT 214 + /// Specifically: omitting `indexer_url` in the updates payload must NOT 215 215 /// clear the existing URL on the PDS. 216 216 #[tokio::test] 217 217 async fn update_account_config_preserves_untouched_fields() { 218 218 use crate::records::AccountConfigUpdates; 219 219 220 220 let mock = MockTransport::new(); 221 - // Seeded record: telemetry off, custom appview URL 221 + // Seeded record: telemetry off, custom indexer URL 222 222 mock.enqueue(success(&account_config_json_ext( 223 223 false, 224 - Some("https://custom.appview/"), 224 + Some("https://custom.indexer/"), 225 225 "2025-01-01T00:00:00Z", 226 226 ))); 227 227 mock.enqueue(put_record_response()); ··· 229 229 let mut opake = make_test_opake(mock); 230 230 let updates = AccountConfigUpdates { 231 231 telemetry_enabled: Some(true), 232 - appview_url: None, // leave alone 232 + indexer_url: None, // leave alone 233 233 }; 234 234 let result = opake.update_account_config(updates).await.unwrap(); 235 235 ··· 238 238 "telemetry should be updated to true" 239 239 ); 240 240 assert_eq!( 241 - result.appview_url.as_deref(), 242 - Some("https://custom.appview/"), 243 - "appview_url must be preserved when absent from updates" 241 + result.indexer_url.as_deref(), 242 + Some("https://custom.indexer/"), 243 + "indexer_url must be preserved when absent from updates" 244 244 ); 245 245 assert_eq!( 246 246 result.modified_at, "2026-01-01T00:00:00Z", ··· 248 248 ); 249 249 } 250 250 251 - /// Passing `appview_url: Some(None)` in the updates (explicit JSON null) 251 + /// Passing `indexer_url: Some(None)` in the updates (explicit JSON null) 252 252 /// must overwrite the stored URL with `None`. 253 253 #[tokio::test] 254 - async fn update_account_config_explicit_null_clears_appview_url() { 254 + async fn update_account_config_explicit_null_clears_indexer_url() { 255 255 use crate::records::AccountConfigUpdates; 256 256 257 257 let mock = MockTransport::new(); 258 - // Seeded record: has a custom appview URL 258 + // Seeded record: has a custom indexer URL 259 259 mock.enqueue(success(&account_config_json_ext( 260 260 false, 261 - Some("https://custom.appview/"), 261 + Some("https://custom.indexer/"), 262 262 "2025-01-01T00:00:00Z", 263 263 ))); 264 264 mock.enqueue(put_record_response()); ··· 266 266 let mut opake = make_test_opake(mock); 267 267 let updates = AccountConfigUpdates { 268 268 telemetry_enabled: None, 269 - appview_url: Some(None), // explicit clear 269 + indexer_url: Some(None), // explicit clear 270 270 }; 271 271 let result = opake.update_account_config(updates).await.unwrap(); 272 272 273 273 assert!( 274 - result.appview_url.is_none(), 275 - "explicit null must clear the stored appview_url" 274 + result.indexer_url.is_none(), 275 + "explicit null must clear the stored indexer_url" 276 276 ); 277 277 }
+68 -68
crates/opake-core/src/client/appview.rs crates/opake-core/src/client/indexer.rs
··· 1 - // AppView client — fetches inbox grants from the appview JSON API. 1 + // Indexer client — fetches inbox grants from the indexer JSON API. 2 2 // 3 3 // Uses the Transport trait for WASM compatibility. Signs each request 4 - // with the caller's Ed25519 key via sign_appview_request. 4 + // with the caller's Ed25519 key via sign_indexer_request. 5 5 6 - use crate::client::appview_auth::sign_appview_request; 7 - use crate::client::appview_types::{ 6 + use crate::client::indexer_auth::sign_indexer_request; 7 + use crate::client::indexer_types::{ 8 8 InboxGrant, InboxResponse, KeyringsResponse, TreeDelta, WorkspaceDocument, WorkspaceResponse, 9 9 }; 10 10 use crate::client::transport::{HttpMethod, HttpRequest, Transport}; 11 11 use crate::error::Error; 12 12 13 - /// Check an appview JSON response for errors. 14 - fn check_appview_response(status: u16, body: &[u8]) -> Result<(), Error> { 13 + /// Check an indexer JSON response for errors. 14 + fn check_indexer_response(status: u16, body: &[u8]) -> Result<(), Error> { 15 15 if (200..300).contains(&status) { 16 16 return Ok(()); 17 17 } ··· 26 26 .and_then(|e| e.error) 27 27 .unwrap_or_else(|| format!("HTTP {status}")); 28 28 29 - Err(Error::Appview { status, message }) 29 + Err(Error::Indexer { status, message }) 30 30 } 31 31 32 - /// Fetch a single page of inbox grants from the appview. 32 + /// Fetch a single page of inbox grants from the indexer. 33 33 pub async fn fetch_inbox( 34 34 transport: &impl Transport, 35 - appview_url: &str, 35 + indexer_url: &str, 36 36 did: &str, 37 37 signing_key: &[u8; 32], 38 38 limit: Option<u32>, ··· 40 40 ) -> Result<InboxResponse, Error> { 41 41 let path = "/api/inbox"; 42 42 let timestamp = super::time::unix_now() as u64; 43 - let auth = sign_appview_request("GET", path, did, signing_key, timestamp); 43 + let auth = sign_indexer_request("GET", path, did, signing_key, timestamp); 44 44 45 45 let mut params = Vec::new(); 46 46 if let Some(l) = limit { ··· 51 51 } 52 52 53 53 let url = if params.is_empty() { 54 - format!("{appview_url}{path}") 54 + format!("{indexer_url}{path}") 55 55 } else { 56 - format!("{appview_url}{path}?{}", params.join("&")) 56 + format!("{indexer_url}{path}?{}", params.join("&")) 57 57 }; 58 58 59 59 let request = HttpRequest { ··· 64 64 }; 65 65 66 66 let response = transport.send(request).await?; 67 - check_appview_response(response.status, &response.body)?; 67 + check_indexer_response(response.status, &response.body)?; 68 68 69 - serde_json::from_slice(&response.body).map_err(|e| Error::Appview { 69 + serde_json::from_slice(&response.body).map_err(|e| Error::Indexer { 70 70 status: response.status, 71 71 message: format!("failed to parse inbox response: {e}"), 72 72 }) ··· 75 75 /// Fetch all inbox grants, paginating automatically until exhausted. 76 76 pub async fn fetch_inbox_all( 77 77 transport: &impl Transport, 78 - appview_url: &str, 78 + indexer_url: &str, 79 79 did: &str, 80 80 signing_key: &[u8; 32], 81 81 ) -> Result<Vec<InboxGrant>, Error> { ··· 85 85 loop { 86 86 let page = fetch_inbox( 87 87 transport, 88 - appview_url, 88 + indexer_url, 89 89 did, 90 90 signing_key, 91 91 Some(100), ··· 105 105 Ok(all_grants) 106 106 } 107 107 108 - /// Fetch all workspace documents from the AppView, paginated. 108 + /// Fetch all workspace documents from the Indexer, paginated. 109 109 pub async fn fetch_workspace_documents( 110 110 transport: &impl Transport, 111 - appview_url: &str, 111 + indexer_url: &str, 112 112 did: &str, 113 113 signing_key: &[u8; 32], 114 114 keyring_uri: &str, ··· 119 119 120 120 loop { 121 121 let timestamp = super::time::unix_now() as u64; 122 - let auth = sign_appview_request("GET", path, did, signing_key, timestamp); 122 + let auth = sign_indexer_request("GET", path, did, signing_key, timestamp); 123 123 124 124 let mut query = format!("keyringUri={keyring_uri}"); 125 125 if let Some(ref c) = cursor { ··· 129 129 let response = transport 130 130 .send(HttpRequest { 131 131 method: HttpMethod::Get, 132 - url: format!("{appview_url}{path}?{query}"), 132 + url: format!("{indexer_url}{path}?{query}"), 133 133 headers: vec![("Authorization".into(), auth)], 134 134 body: None, 135 135 }) 136 136 .await?; 137 137 138 - check_appview_response(response.status, &response.body)?; 138 + check_indexer_response(response.status, &response.body)?; 139 139 140 140 let page: WorkspaceResponse = 141 - serde_json::from_slice(&response.body).map_err(|e| Error::Appview { 141 + serde_json::from_slice(&response.body).map_err(|e| Error::Indexer { 142 142 status: response.status, 143 143 message: format!("failed to parse workspace response: {e}"), 144 144 })?; ··· 158 158 /// Fetch all keyrings the user is a member of, with full record data. 159 159 pub async fn fetch_member_keyrings( 160 160 transport: &impl Transport, 161 - appview_url: &str, 161 + indexer_url: &str, 162 162 did: &str, 163 163 signing_key: &[u8; 32], 164 - ) -> Result<Vec<super::AppviewKeyring>, Error> { 164 + ) -> Result<Vec<super::IndexerKeyring>, Error> { 165 165 let path = "/api/keyrings"; 166 166 let mut all = Vec::new(); 167 167 let mut cursor: Option<String> = None; 168 168 169 169 loop { 170 170 let timestamp = super::time::unix_now() as u64; 171 - let auth = sign_appview_request("GET", path, did, signing_key, timestamp); 171 + let auth = sign_indexer_request("GET", path, did, signing_key, timestamp); 172 172 173 173 let url = match &cursor { 174 - Some(c) => format!("{appview_url}{path}?cursor={c}"), 175 - None => format!("{appview_url}{path}"), 174 + Some(c) => format!("{indexer_url}{path}?cursor={c}"), 175 + None => format!("{indexer_url}{path}"), 176 176 }; 177 177 178 178 let response = transport ··· 184 184 }) 185 185 .await?; 186 186 187 - check_appview_response(response.status, &response.body)?; 187 + check_indexer_response(response.status, &response.body)?; 188 188 189 189 let page: KeyringsResponse = 190 - serde_json::from_slice(&response.body).map_err(|e| Error::Appview { 190 + serde_json::from_slice(&response.body).map_err(|e| Error::Indexer { 191 191 status: response.status, 192 192 message: format!("failed to parse keyrings response: {e}"), 193 193 })?; ··· 208 208 // Tree sync — delta broker endpoints 209 209 // --------------------------------------------------------------------------- 210 210 211 - /// Fetch an authenticated AppView JSON endpoint. 212 - async fn appview_get( 211 + /// Fetch an authenticated Indexer JSON endpoint. 212 + async fn indexer_get( 213 213 transport: &impl Transport, 214 - appview_url: &str, 214 + indexer_url: &str, 215 215 path: &str, 216 216 did: &str, 217 217 signing_key: &[u8; 32], 218 218 query: &str, 219 219 ) -> Result<Vec<u8>, Error> { 220 220 let timestamp = super::time::unix_now() as u64; 221 - let auth = sign_appview_request("GET", path, did, signing_key, timestamp); 221 + let auth = sign_indexer_request("GET", path, did, signing_key, timestamp); 222 222 let url = if query.is_empty() { 223 - format!("{appview_url}{path}") 223 + format!("{indexer_url}{path}") 224 224 } else { 225 - format!("{appview_url}{path}?{query}") 225 + format!("{indexer_url}{path}?{query}") 226 226 }; 227 227 let response = transport 228 228 .send(HttpRequest { ··· 232 232 body: None, 233 233 }) 234 234 .await?; 235 - check_appview_response(response.status, &response.body)?; 235 + check_indexer_response(response.status, &response.body)?; 236 236 Ok(response.body) 237 237 } 238 238 239 239 /// Fetch the full cabinet snapshot (all directories + documents for the caller's DID). 240 240 pub async fn fetch_cabinet_snapshot( 241 241 transport: &impl Transport, 242 - appview_url: &str, 242 + indexer_url: &str, 243 243 did: &str, 244 244 signing_key: &[u8; 32], 245 245 ) -> Result<TreeDelta, Error> { 246 - let body = appview_get( 246 + let body = indexer_get( 247 247 transport, 248 - appview_url, 248 + indexer_url, 249 249 "/api/cabinet/snapshot", 250 250 did, 251 251 signing_key, 252 252 "", 253 253 ) 254 254 .await?; 255 - serde_json::from_slice(&body).map_err(|e| Error::Appview { 255 + serde_json::from_slice(&body).map_err(|e| Error::Indexer { 256 256 status: 200, 257 257 message: format!("failed to parse cabinet snapshot response: {e}"), 258 258 }) ··· 261 261 /// Fetch cabinet changes since a given timestamp. 262 262 pub async fn fetch_cabinet_sync( 263 263 transport: &impl Transport, 264 - appview_url: &str, 264 + indexer_url: &str, 265 265 did: &str, 266 266 signing_key: &[u8; 32], 267 267 since: &str, 268 268 ) -> Result<TreeDelta, Error> { 269 269 let query = format!("since={since}"); 270 - let body = appview_get( 270 + let body = indexer_get( 271 271 transport, 272 - appview_url, 272 + indexer_url, 273 273 "/api/cabinet/sync", 274 274 did, 275 275 signing_key, 276 276 &query, 277 277 ) 278 278 .await?; 279 - serde_json::from_slice(&body).map_err(|e| Error::Appview { 279 + serde_json::from_slice(&body).map_err(|e| Error::Indexer { 280 280 status: 200, 281 281 message: format!("failed to parse cabinet sync response: {e}"), 282 282 }) ··· 285 285 /// Fetch the full workspace snapshot. 286 286 pub async fn fetch_workspace_snapshot( 287 287 transport: &impl Transport, 288 - appview_url: &str, 288 + indexer_url: &str, 289 289 did: &str, 290 290 signing_key: &[u8; 32], 291 291 keyring_uri: &str, 292 292 ) -> Result<TreeDelta, Error> { 293 293 let query = format!("keyring={keyring_uri}"); 294 - let body = appview_get( 294 + let body = indexer_get( 295 295 transport, 296 - appview_url, 296 + indexer_url, 297 297 "/api/workspace/snapshot", 298 298 did, 299 299 signing_key, 300 300 &query, 301 301 ) 302 302 .await?; 303 - serde_json::from_slice(&body).map_err(|e| Error::Appview { 303 + serde_json::from_slice(&body).map_err(|e| Error::Indexer { 304 304 status: 200, 305 305 message: format!("failed to parse workspace snapshot response: {e}"), 306 306 }) ··· 309 309 /// Fetch workspace changes since a given timestamp. 310 310 pub async fn fetch_workspace_sync( 311 311 transport: &impl Transport, 312 - appview_url: &str, 312 + indexer_url: &str, 313 313 did: &str, 314 314 signing_key: &[u8; 32], 315 315 keyring_uri: &str, 316 316 since: &str, 317 317 ) -> Result<TreeDelta, Error> { 318 318 let query = format!("keyring={keyring_uri}&since={since}"); 319 - let body = appview_get( 319 + let body = indexer_get( 320 320 transport, 321 - appview_url, 321 + indexer_url, 322 322 "/api/workspace/sync", 323 323 did, 324 324 signing_key, 325 325 &query, 326 326 ) 327 327 .await?; 328 - serde_json::from_slice(&body).map_err(|e| Error::Appview { 328 + serde_json::from_slice(&body).map_err(|e| Error::Indexer { 329 329 status: 200, 330 330 message: format!("failed to parse workspace sync response: {e}"), 331 331 }) 332 332 } 333 333 334 - /// Request a short-lived SSE token from the appview. 334 + /// Request a short-lived SSE token from the indexer. 335 335 /// 336 336 /// The token authenticates the EventSource connection (which cannot carry 337 337 /// custom headers). Valid for ~60 seconds, single-use. 338 338 pub async fn request_sse_token( 339 339 transport: &impl Transport, 340 - appview_url: &str, 340 + indexer_url: &str, 341 341 did: &str, 342 342 signing_key: &[u8; 32], 343 343 ) -> Result<String, Error> { 344 344 let path = "/api/events/token"; 345 345 let timestamp = super::time::unix_now() as u64; 346 - let auth = sign_appview_request("POST", path, did, signing_key, timestamp); 346 + let auth = sign_indexer_request("POST", path, did, signing_key, timestamp); 347 347 348 348 let request = HttpRequest { 349 349 method: HttpMethod::Post, 350 - url: format!("{appview_url}{path}"), 350 + url: format!("{indexer_url}{path}"), 351 351 headers: vec![("Authorization".into(), auth)], 352 352 body: None, 353 353 }; 354 354 355 355 let response = transport.send(request).await?; 356 - check_appview_response(response.status, &response.body)?; 356 + check_indexer_response(response.status, &response.body)?; 357 357 358 358 #[derive(serde::Deserialize)] 359 359 struct TokenResponse { ··· 361 361 } 362 362 363 363 let parsed: TokenResponse = 364 - serde_json::from_slice(&response.body).map_err(|e| Error::Appview { 364 + serde_json::from_slice(&response.body).map_err(|e| Error::Indexer { 365 365 status: response.status, 366 366 message: format!("failed to parse SSE token response: {e}"), 367 367 })?; ··· 405 405 406 406 let resp = fetch_inbox( 407 407 &mock, 408 - "https://appview.test", 408 + "https://indexer.test", 409 409 "did:plc:me", 410 410 &dummy_key(), 411 411 None, ··· 418 418 assert!(resp.cursor.is_none()); 419 419 420 420 let req = &mock.requests()[0]; 421 - assert_eq!(req.url, "https://appview.test/api/inbox"); 421 + assert_eq!(req.url, "https://indexer.test/api/inbox"); 422 422 assert!(req 423 423 .headers 424 424 .iter() ··· 439 439 body: inbox_json(&[&grant_json("g2")], None), 440 440 }); 441 441 442 - let grants = fetch_inbox_all(&mock, "https://appview.test", "did:plc:me", &dummy_key()) 442 + let grants = fetch_inbox_all(&mock, "https://indexer.test", "did:plc:me", &dummy_key()) 443 443 .await 444 444 .unwrap(); 445 445 ··· 459 459 460 460 let resp = fetch_inbox( 461 461 &mock, 462 - "https://appview.test", 462 + "https://indexer.test", 463 463 "did:plc:me", 464 464 &dummy_key(), 465 465 None, ··· 482 482 483 483 let err = fetch_inbox( 484 484 &mock, 485 - "https://appview.test", 485 + "https://indexer.test", 486 486 "did:plc:me", 487 487 &dummy_key(), 488 488 None, ··· 492 492 .unwrap_err(); 493 493 494 494 match err { 495 - Error::Appview { status, message } => { 495 + Error::Indexer { status, message } => { 496 496 assert_eq!(status, 401); 497 497 assert!(message.contains("signature verification failed")); 498 498 } 499 - other => panic!("expected Appview error, got: {other:?}"), 499 + other => panic!("expected Indexer error, got: {other:?}"), 500 500 } 501 501 } 502 502 ··· 511 511 512 512 let err = fetch_inbox( 513 513 &mock, 514 - "https://appview.test", 514 + "https://indexer.test", 515 515 "did:plc:me", 516 516 &dummy_key(), 517 517 None, ··· 521 521 .unwrap_err(); 522 522 523 523 match err { 524 - Error::Appview { status, .. } => assert_eq!(status, 500), 525 - other => panic!("expected Appview error, got: {other:?}"), 524 + Error::Indexer { status, .. } => assert_eq!(status, 500), 525 + other => panic!("expected Indexer error, got: {other:?}"), 526 526 } 527 527 } 528 528 }
+10 -10
crates/opake-core/src/client/appview_auth.rs crates/opake-core/src/client/indexer_auth.rs
··· 1 - // Auth signing for appview requests. 1 + // Auth signing for indexer requests. 2 2 // 3 3 // Pure function — no I/O, no clock. The caller provides the timestamp. 4 4 // Produces the `Authorization: Opake-Ed25519 <did>:<ts>:<sig>` header value 5 - // that the appview's auth middleware expects. 5 + // that the indexer's auth middleware expects. 6 6 7 7 use base64::engine::general_purpose::STANDARD as BASE64; 8 8 use base64::Engine; 9 9 use ed25519_dalek::{Signer, SigningKey}; 10 10 11 - /// Build the signed Authorization header value for an appview request. 11 + /// Build the signed Authorization header value for an indexer request. 12 12 /// 13 13 /// Signature covers: `<method>:<path>:<timestamp>:<did>` 14 14 /// Returns: `Opake-Ed25519 <did>:<timestamp>:<base64(signature)>` 15 - pub fn sign_appview_request( 15 + pub fn sign_indexer_request( 16 16 method: &str, 17 17 path: &str, 18 18 did: &str, ··· 42 42 #[test] 43 43 fn roundtrip_signature_verifies() { 44 44 let (secret, verifying) = test_keypair(); 45 - let header = sign_appview_request("GET", "/api/inbox", "did:plc:abc", &secret, 1709330400); 45 + let header = sign_indexer_request("GET", "/api/inbox", "did:plc:abc", &secret, 1709330400); 46 46 47 47 let payload = header.strip_prefix("Opake-Ed25519 ").unwrap(); 48 48 // Parse from right: sig, then timestamp, then did ··· 62 62 #[test] 63 63 fn header_format_is_correct() { 64 64 let secret = [1u8; 32]; 65 - let header = sign_appview_request("GET", "/api/inbox", "did:plc:test", &secret, 12345); 65 + let header = sign_indexer_request("GET", "/api/inbox", "did:plc:test", &secret, 12345); 66 66 assert!(header.starts_with("Opake-Ed25519 did:plc:test:12345:")); 67 67 } 68 68 69 69 #[test] 70 70 fn different_inputs_produce_different_signatures() { 71 71 let secret = [7u8; 32]; 72 - let sig_a = sign_appview_request("GET", "/api/inbox", "did:plc:a", &secret, 100); 73 - let sig_b = sign_appview_request("GET", "/api/inbox", "did:plc:b", &secret, 100); 74 - let sig_c = sign_appview_request("POST", "/api/inbox", "did:plc:a", &secret, 100); 75 - let sig_d = sign_appview_request("GET", "/api/inbox", "did:plc:a", &secret, 200); 72 + let sig_a = sign_indexer_request("GET", "/api/inbox", "did:plc:a", &secret, 100); 73 + let sig_b = sign_indexer_request("GET", "/api/inbox", "did:plc:b", &secret, 100); 74 + let sig_c = sign_indexer_request("POST", "/api/inbox", "did:plc:a", &secret, 100); 75 + let sig_d = sign_indexer_request("GET", "/api/inbox", "did:plc:a", &secret, 200); 76 76 77 77 assert_ne!(sig_a, sig_b); 78 78 assert_ne!(sig_a, sig_c);
+10 -10
crates/opake-core/src/client/appview_types.rs crates/opake-core/src/client/indexer_types.rs
··· 1 - // Client-side response types for the appview JSON API. 1 + // Client-side response types for the indexer JSON API. 2 2 // 3 - // These mirror the appview's server-side types but only carry Deserialize — 3 + // These mirror the indexer's server-side types but only carry Deserialize — 4 4 // this crate doesn't need to serialize them. 5 5 6 6 use serde::{Deserialize, Serialize}; ··· 37 37 38 38 /// A keyring the user is a member of, from /api/keyrings. 39 39 /// 40 - /// The AppView indexes full keyring data from the firehose so clients 40 + /// The Indexer indexes full keyring data from the firehose so clients 41 41 /// don't need raw XRPC listRecords calls. 42 42 #[derive(Debug, Clone, Deserialize, Serialize)] 43 - pub struct AppviewKeyring { 43 + pub struct IndexerKeyring { 44 44 pub uri: String, 45 45 pub owner_did: String, 46 46 pub rotation: u64, ··· 53 53 54 54 #[derive(Debug, Clone, Deserialize)] 55 55 pub struct KeyringsResponse { 56 - pub keyrings: Vec<AppviewKeyring>, 56 + pub keyrings: Vec<IndexerKeyring>, 57 57 pub cursor: Option<String>, 58 58 } 59 59 ··· 61 61 // Tree sync types — delta broker responses 62 62 // --------------------------------------------------------------------------- 63 63 64 - /// A directory record from the AppView tree/sync endpoints. 64 + /// A directory record from the Indexer tree/sync endpoints. 65 65 #[derive(Debug, Clone, Deserialize, Serialize)] 66 66 pub struct TreeDirectory { 67 67 pub directory_uri: String, ··· 74 74 pub indexed_at: String, 75 75 } 76 76 77 - /// A document record from the AppView tree/sync endpoints. 77 + /// A document record from the Indexer tree/sync endpoints. 78 78 #[derive(Debug, Clone, Deserialize, Serialize)] 79 79 pub struct TreeDocument { 80 80 pub document_uri: String, ··· 115 115 } 116 116 117 117 /// A pending document update proposal from a workspace member. 118 - /// The AppView stores only metadata — the full record (blob ref, encrypted 118 + /// The Indexer stores only metadata — the full record (blob ref, encrypted 119 119 /// metadata) lives on the proposer's PDS and must be fetched for processing. 120 120 #[derive(Debug, Clone, Deserialize, Serialize)] 121 121 pub struct DocumentProposal { ··· 147 147 // --------------------------------------------------------------------------- 148 148 149 149 impl TreeDirectory { 150 - /// Convert an AppView directory into a `CachedRecord` for local storage. 150 + /// Convert an Indexer directory into a `CachedRecord` for local storage. 151 151 /// 152 152 /// Reconstructs a PDS-compatible record JSON so `DirectoryTree::from_cached_records` 153 153 /// can deserialize it as a `Directory`. ··· 168 168 } 169 169 170 170 impl TreeDocument { 171 - /// Convert an AppView document into a `CachedRecord` for local storage. 171 + /// Convert an Indexer document into a `CachedRecord` for local storage. 172 172 pub fn to_cached_record(&self) -> crate::storage::CachedRecord { 173 173 let value = serde_json::json!({ 174 174 "opakeVersion": 1,
+6 -6
crates/opake-core/src/client/mod.rs
··· 1 - mod appview; 2 - mod appview_auth; 3 - mod appview_types; 4 1 mod did; 5 2 #[cfg(feature = "dns")] 6 3 mod dns; 7 4 pub mod dpop; 5 + mod indexer; 6 + mod indexer_auth; 7 + mod indexer_types; 8 8 mod list; 9 9 pub mod oauth_discovery; 10 10 pub mod oauth_token; ··· 17 17 mod wasm_transport; 18 18 mod xrpc; 19 19 20 - pub use appview::*; 21 - pub use appview_auth::*; 22 - pub use appview_types::*; 23 20 pub use did::*; 24 21 #[cfg(feature = "dns")] 25 22 pub use dns::resolve_handle_dns; 23 + pub use indexer::*; 24 + pub use indexer_auth::*; 25 + pub use indexer_types::*; 26 26 pub use list::*; 27 27 #[cfg(feature = "reqwest-transport")] 28 28 pub use reqwest_transport::ReqwestTransport;
+2 -2
crates/opake-core/src/directories/tree.rs
··· 197 197 198 198 /// Load the directory hierarchy from the PDS (test use only). 199 199 /// 200 - /// Production code uses AppView snapshots via `from_cached_records()`. 200 + /// Production code uses Indexer snapshots via `from_cached_records()`. 201 201 #[cfg(test)] 202 202 pub(crate) async fn load( 203 203 client: &mut crate::client::XrpcClient<impl crate::client::Transport>, ··· 906 906 } 907 907 } 908 908 909 - /// Apply a delta from the AppView to cached records, returning a new set. 909 + /// Apply a delta from the Indexer to cached records, returning a new set. 910 910 /// 911 911 /// Deleted directories are filtered out. New/updated directories replace 912 912 /// existing records by URI. Pure function — no mutation.
+1 -1
crates/opake-core/src/directories/tree_tests.rs
··· 600 600 use crate::sse::events::SseDirectoryRecord; 601 601 602 602 /// Convert a test `Directory` into the SSE payload shape. Mirrors how the 603 - /// appview broadcaster formats directory records — `key_wrapping` and 603 + /// indexer broadcaster formats directory records — `key_wrapping` and 604 604 /// `encrypted_metadata` go through as opaque JSON values. 605 605 fn sse_record(uri: &str, dir: &Directory, keyring_uri: Option<&str>) -> SseDirectoryRecord { 606 606 SseDirectoryRecord {
+1 -1
crates/opake-core/src/documents/mod.rs
··· 1 1 // Document operations: list, upload+encrypt, download+decrypt, delete. 2 2 // 3 3 // These are the high-level building blocks that both the CLI and the future 4 - // web AppView use. They talk to the PDS via XrpcClient and handle record 4 + // web Indexer use. They talk to the PDS via XrpcClient and handle record 5 5 // parsing, schema version checks, and crypto — but never touch the filesystem, 6 6 // config, or user prompts. 7 7
+2 -2
crates/opake-core/src/error.rs
··· 17 17 #[error("XRPC error ({status}): {message}")] 18 18 Xrpc { status: u16, message: String }, 19 19 20 - #[error("appview error ({status}): {message}")] 21 - Appview { status: u16, message: String }, 20 + #[error("indexer error ({status}): {message}")] 21 + Indexer { status: u16, message: String }, 22 22 23 23 #[error("record not found: {0}")] 24 24 NotFound(String),
+5 -5
crates/opake-core/src/inbox_keeper/mod.rs
··· 16 16 //! ## Why no crypto 17 17 //! 18 18 //! Unlike the workspace keeper, inbox entries are already-resolved 19 - //! appview records — a `grant:upsert` event carries the URI, owner, 19 + //! indexer records — a `grant:upsert` event carries the URI, owner, 20 20 //! and document URI in plaintext. Metadata decryption still requires a 21 21 //! cross-PDS fetch via [`Opake::resolve_grant_metadata`], but that's 22 22 //! the consumer's job, not the keeper's. ··· 37 37 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 38 38 pub struct InboxWatcherHandle(u64); 39 39 40 - /// An incoming grant as seen by the recipient — mirrors the appview's 40 + /// An incoming grant as seen by the recipient — mirrors the indexer's 41 41 /// `InboxGrant` DTO but lives in this crate so the keeper stays 42 42 /// self-contained. 43 43 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] ··· 119 119 // -- Mutation API -- 120 120 121 121 /// Replace the entire entry set. Called after a full-list fetch 122 - /// from the appview. 122 + /// from the indexer. 123 123 pub fn bootstrap(&mut self, entries: Vec<InboxEntry>) { 124 124 self.entries = entries.into_iter().map(|e| (e.uri.clone(), e)).collect(); 125 125 self.loaded = true; ··· 220 220 }) 221 221 } 222 222 223 - /// Convenience wrapper: build an entry from an appview [`InboxGrant`]. 223 + /// Convenience wrapper: build an entry from an indexer [`InboxGrant`]. 224 224 /// 225 225 /// [`InboxGrant`]: crate::client::InboxGrant 226 - pub fn entry_from_appview_grant(grant: &crate::client::InboxGrant) -> InboxEntry { 226 + pub fn entry_from_indexer_grant(grant: &crate::client::InboxGrant) -> InboxEntry { 227 227 InboxEntry { 228 228 uri: grant.uri.clone(), 229 229 owner_did: grant.owner_did.clone(),
+17 -17
crates/opake-core/src/keyrings/mod.rs
··· 29 29 Some(metadata.name) 30 30 } 31 31 32 - /// Decrypt an appview-sourced keyring's name. 32 + /// Decrypt an indexer-sourced keyring's name. 33 33 /// 34 - /// Mirrors [`decrypt_keyring_name`] for the [`AppviewKeyring`] shape, so 34 + /// Mirrors [`decrypt_keyring_name`] for the [`IndexerKeyring`] shape, so 35 35 /// `workspace ls` can show workspaces the user is a *member* of (where the 36 36 /// keyring record lives on another user's PDS and the caller's own 37 37 /// `listRecords` call doesn't see it). ··· 42 42 /// 43 43 /// # Trust model 44 44 /// 45 - /// The returned name is only as trustworthy as the appview that served the 45 + /// The returned name is only as trustworthy as the indexer that served the 46 46 /// record. Workspace membership is publicly forgeable (anyone can wrap a 47 47 /// group key to the caller's published X25519 pubkey), so a compromised 48 - /// appview that also controls a keyring the caller has been silently 48 + /// indexer that also controls a keyring the caller has been silently 49 49 /// added to can spoof the name to steer name-based resolution at a later 50 50 /// `resolve_workspace("family-photos")` call. The write would then land as 51 51 /// a proposal encrypted under the attacker-controlled group key. ··· 54 54 /// logic in [`crate::Opake::resolve_workspace`] catches the collision and 55 55 /// raises `AmbiguousName` — the user must disambiguate by URI. For 56 56 /// foreign-only names there is currently **no cryptographic anchor** on 57 - /// the name ↔ URI binding; the reader trusts the appview for that map. 57 + /// the name ↔ URI binding; the reader trusts the indexer for that map. 58 58 /// A future end-to-end signature layer (owner signs the keyring record 59 59 /// with their DID's signing key, client verifies via DID doc) would close 60 60 /// this gap without any API change here. 61 61 /// 62 62 /// Returns `None` if the DID isn't a member, deserialization fails, 63 63 /// unwrapping fails, or metadata decryption fails. 64 - pub fn decrypt_appview_keyring_name( 65 - keyring: &crate::client::AppviewKeyring, 64 + pub fn decrypt_indexer_keyring_name( 65 + keyring: &crate::client::IndexerKeyring, 66 66 did: &str, 67 67 private_key: &X25519PrivateKey, 68 68 ) -> Option<String> { ··· 85 85 // --------------------------------------------------------------------------- 86 86 87 87 #[cfg(test)] 88 - mod appview_keyring_tests { 88 + mod indexer_keyring_tests { 89 89 use super::*; 90 - use crate::client::AppviewKeyring; 90 + use crate::client::IndexerKeyring; 91 91 use crate::crypto::{ 92 92 generate_content_key, wrap_key, OsRng, X25519DalekPublicKey, X25519DalekStaticSecret, 93 93 X25519PrivateKey, X25519PublicKey, ··· 100 100 (public.to_bytes(), secret.to_bytes()) 101 101 } 102 102 103 - /// Build an `AppviewKeyring` with real crypto: a group key wrapped to 103 + /// Build an `IndexerKeyring` with real crypto: a group key wrapped to 104 104 /// `member_did`'s public key and a `KeyringMetadata { name }` encrypted 105 105 /// under the group key. Returns the keyring plus the caller's private 106 106 /// key so tests can attempt decryption. ··· 109 109 owner_did: &str, 110 110 member_did: &str, 111 111 uri: &str, 112 - ) -> (AppviewKeyring, X25519PrivateKey) { 112 + ) -> (IndexerKeyring, X25519PrivateKey) { 113 113 let (member_pubkey, member_privkey) = test_keypair(); 114 114 let group_key = generate_content_key(&mut OsRng); 115 115 let wrapped = ··· 131 131 role: crate::records::Role::Editor, 132 132 }; 133 133 134 - let keyring = AppviewKeyring { 134 + let keyring = IndexerKeyring { 135 135 uri: uri.into(), 136 136 owner_did: owner_did.into(), 137 137 rotation: 0, ··· 154 154 "at://did:plc:owner/app.opake.keyring/abc", 155 155 ); 156 156 157 - let name = decrypt_appview_keyring_name(&keyring, member_did, &privkey); 157 + let name = decrypt_indexer_keyring_name(&keyring, member_did, &privkey); 158 158 assert_eq!(name.as_deref(), Some("family-photos")); 159 159 } 160 160 ··· 171 171 // A DID not present in the members list — we use an unrelated keypair 172 172 // so that even if the DID matched, unwrap_key would fail. 173 173 let (_, stranger_privkey) = test_keypair(); 174 - let name = decrypt_appview_keyring_name(&keyring, "did:plc:stranger", &stranger_privkey); 174 + let name = decrypt_indexer_keyring_name(&keyring, "did:plc:stranger", &stranger_privkey); 175 175 assert!(name.is_none()); 176 176 } 177 177 ··· 188 188 // DID matches a member entry, but we unwrap with the wrong private key 189 189 // — simulates an identity mismatch or corrupted local storage. 190 190 let (_, wrong_privkey) = test_keypair(); 191 - let name = decrypt_appview_keyring_name(&keyring, member_did, &wrong_privkey); 191 + let name = decrypt_indexer_keyring_name(&keyring, member_did, &wrong_privkey); 192 192 assert!(name.is_none()); 193 193 } 194 194 ··· 203 203 ); 204 204 keyring.encrypted_metadata = None; 205 205 206 - let name = decrypt_appview_keyring_name(&keyring, member_did, &privkey); 206 + let name = decrypt_indexer_keyring_name(&keyring, member_did, &privkey); 207 207 assert!(name.is_none()); 208 208 } 209 209 ··· 221 221 // it silently and no member matches the DID. 222 222 keyring.members = vec![serde_json::json!({"garbage": true})]; 223 223 224 - let name = decrypt_appview_keyring_name(&keyring, member_did, &privkey); 224 + let name = decrypt_indexer_keyring_name(&keyring, member_did, &privkey); 225 225 assert!(name.is_none()); 226 226 } 227 227 }
+1 -1
crates/opake-core/src/manager/mod.rs
··· 58 58 59 59 /// Pending directory update proposals from the last tree sync. 60 60 /// 61 - /// Only populated for workspace contexts after `load_tree()`. The AppView 61 + /// Only populated for workspace contexts after `load_tree()`. The Indexer 62 62 /// verifies that each proposal's author is a current workspace member. 63 63 pub fn proposals(&self) -> &[TreeProposal] { 64 64 &self.last_proposals
+20 -20
crates/opake-core/src/manager/tree.rs
··· 51 51 /// 52 52 /// Called after mutations that modify directory records on the PDS. 53 53 /// The cache records stay (for offline use) but `fetched_at` is cleared, 54 - /// forcing a full AppView re-sync on next load. 54 + /// forcing a full Indexer re-sync on next load. 55 55 pub(crate) async fn invalidate_directory_cache(&self) { 56 56 let scope = dir_scope_key(self.context); 57 57 let _ = self ··· 64 64 /// Load and decrypt the directory tree for the current context. 65 65 /// 66 66 /// Cache-first: reads from local Storage cache if available, syncs 67 - /// deltas from the AppView to keep it fresh. Falls back to PDS 67 + /// deltas from the Indexer to keep it fresh. Falls back to PDS 68 68 /// `listRecords` for bootstrap (first load with no cache). 69 69 pub async fn load_tree(&mut self) -> Result<DirectoryTree, Error> { 70 70 let scope = dir_scope_key(self.context); ··· 84 84 cached_coll.fetched_at, 85 85 ); 86 86 87 - // Sync deltas from AppView if available 87 + // Sync deltas from Indexer if available 88 88 let (records, proposals) = self.try_sync_deltas(cached_coll).await?; 89 89 trace!("sync returned {} proposals", proposals.len()); 90 90 self.last_proposals = proposals; ··· 103 103 /// Apply pending directory proposals from workspace members. 104 104 /// 105 105 /// Pre-filters proposals against the loaded tree to skip already-applied 106 - /// ones (the AppView keeps serving proposals until the editor deletes 106 + /// ones (the Indexer keeps serving proposals until the editor deletes 107 107 /// them). Groups remaining proposals by target directory, fetches each 108 108 /// once, batch-applies adds/removes, and submits atomically via 109 109 /// `applyWrites`. Consumes `last_proposals` — subsequent calls return 0. ··· 306 306 /// 307 307 /// After a tree sync, proposals authored by the caller whose effects are 308 308 /// already in the tree are stale. Deleting them from the PDS propagates 309 - /// via the firehose so the AppView drops them from its index too. 309 + /// via the firehose so the Indexer drops them from its index too. 310 310 /// 311 311 /// Must be called with proposals still in `last_proposals` (before 312 312 /// `apply_pending_proposals` consumes them). ··· 385 385 } 386 386 } 387 387 388 - /// Try to sync deltas from the AppView. If AppView is unavailable, 388 + /// Try to sync deltas from the Indexer. If Indexer is unavailable, 389 389 /// return the cached records as-is (offline-capable). 390 390 async fn try_sync_deltas( 391 391 &self, 392 392 cached: CachedCollection, 393 393 ) -> Result<(Vec<CachedRecord>, Vec<crate::client::TreeProposal>), Error> { 394 - let appview_url = match &self.opake.appview_url { 394 + let indexer_url = match &self.opake.indexer_url { 395 395 Some(url) => url.clone(), 396 396 None => return Ok((cached.records, Vec::new())), 397 397 }; ··· 415 415 Some(s) => { 416 416 crate::client::fetch_cabinet_sync( 417 417 self.opake.client.transport(), 418 - &appview_url, 418 + &indexer_url, 419 419 &self.opake.did, 420 420 &signing_key, 421 421 s, ··· 425 425 None => { 426 426 crate::client::fetch_cabinet_snapshot( 427 427 self.opake.client.transport(), 428 - &appview_url, 428 + &indexer_url, 429 429 &self.opake.did, 430 430 &signing_key, 431 431 ) ··· 436 436 Some(s) => { 437 437 crate::client::fetch_workspace_sync( 438 438 self.opake.client.transport(), 439 - &appview_url, 439 + &indexer_url, 440 440 &self.opake.did, 441 441 &signing_key, 442 442 &ws.uri, ··· 447 447 None => { 448 448 crate::client::fetch_workspace_snapshot( 449 449 self.opake.client.transport(), 450 - &appview_url, 450 + &indexer_url, 451 451 &self.opake.did, 452 452 &signing_key, 453 453 &ws.uri, ··· 464 464 Ok((updated, proposals)) 465 465 } 466 466 Err(e) => { 467 - warn!("AppView sync failed, using cached tree: {e}"); 467 + warn!("Indexer sync failed, using cached tree: {e}"); 468 468 Ok((cached.records, Vec::new())) 469 469 } 470 470 } ··· 537 537 .collect() 538 538 } 539 539 540 - /// Bootstrap the tree when no cache exists. Requires AppView. 540 + /// Bootstrap the tree when no cache exists. Requires Indexer. 541 541 /// 542 - /// Fetches a full snapshot from the AppView, caches it locally, 542 + /// Fetches a full snapshot from the Indexer, caches it locally, 543 543 /// and returns a tree built from the cached records. 544 544 async fn bootstrap_tree(&mut self) -> Result<DirectoryTree, Error> { 545 - let appview_url = self 545 + let indexer_url = self 546 546 .opake 547 - .appview_url 547 + .indexer_url 548 548 .as_ref() 549 549 .ok_or_else(|| { 550 - Error::Storage("AppView URL required — configure one or self-host".into()) 550 + Error::Storage("Indexer URL required — configure one or self-host".into()) 551 551 })? 552 552 .clone(); 553 553 554 554 let identity = self.opake.require_identity()?; 555 555 let signing_key = identity 556 556 .signing_key_bytes()? 557 - .ok_or_else(|| Error::Auth("signing key required for AppView sync".into()))?; 557 + .ok_or_else(|| Error::Auth("signing key required for Indexer sync".into()))?; 558 558 559 559 let snapshot = match self.context { 560 560 FileContext::Cabinet(_) => { 561 561 crate::client::fetch_cabinet_snapshot( 562 562 self.opake.client.transport(), 563 - &appview_url, 563 + &indexer_url, 564 564 &self.opake.did, 565 565 &signing_key, 566 566 ) ··· 569 569 FileContext::Workspace(ws) => { 570 570 crate::client::fetch_workspace_snapshot( 571 571 self.opake.client.transport(), 572 - &appview_url, 572 + &indexer_url, 573 573 &self.opake.did, 574 574 &signing_key, 575 575 &ws.uri,
+59 -59
crates/opake-core/src/opake.rs
··· 39 39 pub(crate) storage: S, 40 40 pub(crate) now_fn: fn() -> String, 41 41 pub(crate) now_micros_fn: fn() -> u64, 42 - /// Cached appview URL from account config (fetched once at construction). 43 - pub(crate) appview_url: Option<String>, 42 + /// Cached indexer URL from account config (fetched once at construction). 43 + pub(crate) indexer_url: Option<String>, 44 44 } 45 45 46 - /// AppView URL baked into the binary at compile time. 46 + /// Indexer URL baked into the binary at compile time. 47 47 /// 48 - /// Set via `OPAKE_APPVIEW_URL` env var at build time. Falls back to 48 + /// Set via `OPAKE_INDEXER_URL` env var at build time. Falls back to 49 49 /// the production URL if unset. Dev builds pick it up from `.envrc`. 50 - pub const DEFAULT_APPVIEW_URL: &str = match option_env!("OPAKE_APPVIEW_URL") { 50 + pub const DEFAULT_INDEXER_URL: &str = match option_env!("OPAKE_INDEXER_URL") { 51 51 Some(url) => url, 52 - None => "https://appview.opake.app", 52 + None => "https://indexer.opake.app", 53 53 }; 54 54 55 55 impl<T: Transport, R: CryptoRng + RngCore, S: Storage> Opake<T, R, S> { ··· 70 70 storage, 71 71 now_fn: now, 72 72 now_micros_fn: now_micros, 73 - appview_url: None, 73 + indexer_url: None, 74 74 } 75 75 } 76 76 ··· 82 82 /// and identity (encryption keys, if present). If the identity exists 83 83 /// but lacks signing keys, migrates it automatically. 84 84 /// 85 - /// AppView URL resolution (highest priority wins): 86 - /// 1. Runtime env override — CLI checks `OPAKE_APPVIEW_URL` after construction 85 + /// Indexer URL resolution (highest priority wins): 86 + /// 1. Runtime env override — CLI checks `OPAKE_INDEXER_URL` after construction 87 87 /// 2. Account config on PDS — fetched best-effort, user-configurable in settings 88 - /// 3. `DEFAULT_APPVIEW_URL` — baked in at compile time from `OPAKE_APPVIEW_URL` env var 88 + /// 3. `DEFAULT_INDEXER_URL` — baked in at compile time from `OPAKE_INDEXER_URL` env var 89 89 /// 90 90 /// Pass `None` for the default account, or `Some(did)` for a specific one. 91 91 pub async fn for_account( ··· 124 124 125 125 let client = XrpcClient::with_session(transport, account.pds_url.clone(), session); 126 126 let mut opake = Self::new(client, target_did, identity, rng, storage, now, now_micros); 127 - opake.appview_url = Some(DEFAULT_APPVIEW_URL.to_string()); 127 + opake.indexer_url = Some(DEFAULT_INDEXER_URL.to_string()); 128 128 Ok(opake) 129 129 } 130 130 ··· 203 203 204 204 // -- Workspace resolution -- 205 205 206 - /// Resolve a workspace by name via the appview member-keyrings index. 206 + /// Resolve a workspace by name via the indexer member-keyrings index. 207 207 /// 208 - /// The appview indexes every keyring from the firehose and serves them 208 + /// The indexer indexes every keyring from the firehose and serves them 209 209 /// via `/api/keyrings` filtered to ones where the caller is a member — 210 210 /// which includes workspaces the caller owns (they're always a member 211 211 /// of their own). That makes it the single source of truth for ··· 213 213 /// 214 214 /// Once a unique URI is picked, [`resolve_workspace_by_uri`] fetches 215 215 /// the canonical record from the owner's PDS and unwraps the group 216 - /// key there — so the appview is only trusted for the name → URI map, 216 + /// key there — so the indexer is only trusted for the name → URI map, 217 217 /// not for the group key material. 218 218 /// 219 219 /// Limitation: workspace creation writes to the caller's PDS, which 220 - /// the appview indexes with some lag (seconds) through the firehose. 220 + /// the indexer indexes with some lag (seconds) through the firehose. 221 221 /// A `workspace ls` immediately after `workspace create` may miss the 222 222 /// new entry until Jetstream delivers the commit. 223 223 pub async fn resolve_workspace(&mut self, name: &str) -> Result<Workspace, Error> { ··· 228 228 let matches: Vec<String> = keyrings 229 229 .iter() 230 230 .filter(|kr| { 231 - keyrings::decrypt_appview_keyring_name(kr, &self.did, &private_key).as_deref() 231 + keyrings::decrypt_indexer_keyring_name(kr, &self.did, &private_key).as_deref() 232 232 == Some(name) 233 233 }) 234 234 .map(|kr| kr.uri.clone()) ··· 445 445 446 446 /// Sync all workspaces: apply proposals (owned) + cleanup stale records (member). 447 447 /// 448 - /// Discovers all workspaces via the AppView (includes both owned and member 448 + /// Discovers all workspaces via the Indexer (includes both owned and member 449 449 /// workspaces). For each: 450 - /// - Syncs the tree from AppView 450 + /// - Syncs the tree from Indexer 451 451 /// - Cleans up the caller's own applied proposal records from their PDS 452 452 /// - Applies pending proposals if the caller is the owner 453 453 /// ··· 459 459 460 460 /// Sync all workspaces with per-workspace result visibility. 461 461 /// 462 - /// Discovers workspaces via AppView, syncs each one, returns a result per 462 + /// Discovers workspaces via Indexer, syncs each one, returns a result per 463 463 /// workspace. Per-workspace errors are captured (not propagated) so one 464 464 /// failing workspace doesn't block the rest. 465 465 pub async fn sync_owned_workspaces_detailed( 466 466 &mut self, 467 467 ) -> Result<Vec<crate::daemon::WorkspaceSyncResult>, Error> { 468 468 log::trace!("sync: discovering workspaces for {}", self.did); 469 - let appview_keyrings = self.discover_member_keyrings(None).await?; 470 - log::trace!("sync: found {} workspaces", appview_keyrings.len()); 469 + let indexer_keyrings = self.discover_member_keyrings(None).await?; 470 + log::trace!("sync: found {} workspaces", indexer_keyrings.len()); 471 471 let identity = self.require_identity()?; 472 472 let private_key = identity.private_key_bytes()?; 473 473 474 - let mut results = Vec::with_capacity(appview_keyrings.len()); 475 - for kr in &appview_keyrings { 474 + let mut results = Vec::with_capacity(indexer_keyrings.len()); 475 + for kr in &indexer_keyrings { 476 476 results.push(self.sync_single_workspace(kr, &private_key).await); 477 477 } 478 478 ··· 482 482 483 483 /// Sync a single workspace identified by its keyring URI. 484 484 /// 485 - /// Fetches all member keyrings from the appview (same as the full sync), 485 + /// Fetches all member keyrings from the indexer (same as the full sync), 486 486 /// finds the target, and syncs only that one. Returns `None` if the 487 487 /// keyring URI wasn't found in the member list. 488 488 pub async fn sync_workspace_by_uri( 489 489 &mut self, 490 490 keyring_uri: &str, 491 491 ) -> Result<Option<crate::daemon::WorkspaceSyncResult>, Error> { 492 - let appview_keyrings = self.discover_member_keyrings(None).await?; 493 - let target = appview_keyrings.iter().find(|kr| kr.uri == keyring_uri); 492 + let indexer_keyrings = self.discover_member_keyrings(None).await?; 493 + let target = indexer_keyrings.iter().find(|kr| kr.uri == keyring_uri); 494 494 let Some(kr) = target else { return Ok(None) }; 495 495 496 496 let identity = self.require_identity()?; ··· 506 506 /// propagating — the caller can continue with remaining workspaces. 507 507 async fn sync_single_workspace( 508 508 &mut self, 509 - kr: &crate::client::AppviewKeyring, 509 + kr: &crate::client::IndexerKeyring, 510 510 private_key: &crate::crypto::X25519PrivateKey, 511 511 ) -> crate::daemon::WorkspaceSyncResult { 512 512 use crate::daemon::WorkspaceSyncResult; ··· 938 938 /// Runs for all members (not just the owner). Compares each of the caller's 939 939 /// own proposals against the current keyring member list. If the proposal's 940 940 /// effect is reflected in the keyring state, deletes the proposal record 941 - /// from the caller's PDS so the AppView drops it from its index. 941 + /// from the caller's PDS so the Indexer drops it from its index. 942 942 /// 943 943 /// Metadata proposals (rename, updateDescription) are skipped — verifying 944 944 /// encrypted content isn't practical here. They're idempotent, so the owner ··· 1068 1068 1069 1069 /// Leave a workspace. Writes a keyringUpdate with actionType "leave". 1070 1070 /// 1071 - /// The AppView handles visibility immediately (stops listing the workspace). 1071 + /// The Indexer handles visibility immediately (stops listing the workspace). 1072 1072 /// The owner's daemon processes key rotation asynchronously. 1073 1073 pub async fn leave_workspace(&mut self, keyring_uri: &str) -> Result<String, Error> { 1074 1074 let now = self.now(); ··· 1392 1392 crate::crypto::unwrap_key(&member.wrapped_key, private_key) 1393 1393 } 1394 1394 1395 - // -- AppView helpers -- 1395 + // -- Indexer helpers -- 1396 1396 1397 - /// Override the AppView URL unconditionally (runtime env var override). 1398 - pub fn set_appview_url(&mut self, url: String) { 1399 - self.appview_url = Some(url); 1397 + /// Override the Indexer URL unconditionally (runtime env var override). 1398 + pub fn set_indexer_url(&mut self, url: String) { 1399 + self.indexer_url = Some(url); 1400 1400 } 1401 1401 1402 - /// Resolve the appview URL: cached config → caller default → error. 1403 - /// Resolve the appview URL to use for a request. 1402 + /// Resolve the indexer URL: cached config → caller default → error. 1403 + /// Resolve the indexer URL to use for a request. 1404 1404 /// 1405 1405 /// Returns the URL stored on this Opake instance (loaded from config 1406 1406 /// during `init`), falling back to the provided `default` if no URL 1407 1407 /// is stored. Returns `NotFound` if neither source has a URL. 1408 1408 /// 1409 - /// This is the shared helper behind every appview-touching method 1409 + /// This is the shared helper behind every indexer-touching method 1410 1410 /// (`request_sse_token`, `list_inbox`, `discover_member_keyrings`, 1411 1411 /// etc.) and also used by WASM bindings that need to auto-resolve 1412 1412 /// the URL before starting long-lived tasks like the SSE consumer. 1413 - pub fn resolve_appview_url(&self, default: Option<&str>) -> Result<String, Error> { 1414 - self.appview_url 1413 + pub fn resolve_indexer_url(&self, default: Option<&str>) -> Result<String, Error> { 1414 + self.indexer_url 1415 1415 .clone() 1416 1416 .or_else(|| default.map(|s| s.to_string())) 1417 1417 .ok_or_else(|| { 1418 1418 Error::NotFound( 1419 - "no appview URL — set VITE_APPVIEW_URL or configure in settings".into(), 1419 + "no indexer URL — set VITE_INDEXER_URL or configure in settings".into(), 1420 1420 ) 1421 1421 }) 1422 1422 } 1423 1423 1424 1424 // -- SSE token (for EventSource auth) -- 1425 1425 1426 - /// Request a short-lived SSE token from the AppView. 1426 + /// Request a short-lived SSE token from the Indexer. 1427 1427 /// 1428 1428 /// The token is passed as a query parameter to the SSE endpoint, 1429 1429 /// sidestepping EventSource's inability to send custom headers. 1430 1430 pub async fn request_sse_token( 1431 1431 &mut self, 1432 - default_appview_url: Option<&str>, 1432 + default_indexer_url: Option<&str>, 1433 1433 ) -> Result<String, Error> { 1434 1434 let identity = self.require_identity()?; 1435 1435 let signing_key = identity 1436 1436 .signing_key_bytes()? 1437 1437 .ok_or_else(|| Error::Auth("no signing key for SSE token request".into()))?; 1438 1438 1439 - let url = self.resolve_appview_url(default_appview_url)?; 1439 + let url = self.resolve_indexer_url(default_indexer_url)?; 1440 1440 crate::client::request_sse_token(self.client.transport(), &url, &self.did, &signing_key) 1441 1441 .await 1442 1442 } 1443 1443 1444 - // -- Inbox (incoming grants via AppView) -- 1444 + // -- Inbox (incoming grants via Indexer) -- 1445 1445 1446 - /// Fetch all incoming grants from the AppView. 1446 + /// Fetch all incoming grants from the Indexer. 1447 1447 /// 1448 - /// Returns an empty list if no appview URL is configured or no signing key exists. 1448 + /// Returns an empty list if no indexer URL is configured or no signing key exists. 1449 1449 pub async fn list_inbox( 1450 1450 &mut self, 1451 - default_appview_url: Option<&str>, 1451 + default_indexer_url: Option<&str>, 1452 1452 ) -> Result<Vec<crate::client::InboxGrant>, Error> { 1453 1453 let identity = match self.identity.as_ref() { 1454 1454 Some(id) => id, ··· 1459 1459 None => return Ok(vec![]), 1460 1460 }; 1461 1461 1462 - let url = self.resolve_appview_url(default_appview_url)?; 1462 + let url = self.resolve_indexer_url(default_indexer_url)?; 1463 1463 1464 1464 crate::client::fetch_inbox_all(self.client.transport(), &url, &self.did, &signing_key).await 1465 1465 } 1466 1466 1467 - /// Fetch workspace documents from the AppView. 1467 + /// Fetch workspace documents from the Indexer. 1468 1468 /// 1469 - /// Returns an empty list if no appview URL is configured or no signing key exists. 1469 + /// Returns an empty list if no indexer URL is configured or no signing key exists. 1470 1470 pub async fn list_workspace_documents( 1471 1471 &mut self, 1472 1472 keyring_uri: &str, 1473 - default_appview_url: Option<&str>, 1473 + default_indexer_url: Option<&str>, 1474 1474 ) -> Result<Vec<crate::client::WorkspaceDocument>, Error> { 1475 1475 let identity = match self.identity.as_ref() { 1476 1476 Some(id) => id, ··· 1481 1481 None => return Ok(vec![]), 1482 1482 }; 1483 1483 1484 - let url = self.resolve_appview_url(default_appview_url)?; 1484 + let url = self.resolve_indexer_url(default_indexer_url)?; 1485 1485 1486 1486 crate::client::fetch_workspace_documents( 1487 1487 self.client.transport(), ··· 1495 1495 1496 1496 /// Fetch all keyrings the user is a member of, with full record data. 1497 1497 /// 1498 - /// Returns an empty list if no appview URL is configured or no signing key exists. 1498 + /// Returns an empty list if no indexer URL is configured or no signing key exists. 1499 1499 pub async fn discover_member_keyrings( 1500 1500 &mut self, 1501 - default_appview_url: Option<&str>, 1502 - ) -> Result<Vec<crate::client::AppviewKeyring>, Error> { 1501 + default_indexer_url: Option<&str>, 1502 + ) -> Result<Vec<crate::client::IndexerKeyring>, Error> { 1503 1503 let identity = match self.identity.as_ref() { 1504 1504 Some(id) => id, 1505 1505 None => return Ok(vec![]), ··· 1509 1509 None => return Ok(vec![]), 1510 1510 }; 1511 1511 1512 - let url = self.resolve_appview_url(default_appview_url)?; 1512 + let url = self.resolve_indexer_url(default_indexer_url)?; 1513 1513 1514 1514 crate::client::fetch_member_keyrings(self.client.transport(), &url, &self.did, &signing_key) 1515 1515 .await ··· 1577 1577 ) -> Result<String, Error> { 1578 1578 // Propagate whatever the user last committed to their PDS — including 1579 1579 // explicit clears (None). The compile-time default is seeded separately 1580 - // via `set_appview_url` and lives below this in priority order. 1581 - self.appview_url = config.appview_url.clone(); 1580 + // via `set_indexer_url` and lives below this in priority order. 1581 + self.indexer_url = config.indexer_url.clone(); 1582 1582 let result = crate::account_config::publish_account_config(&mut self.client, config).await; 1583 1583 self.signoff(result).await 1584 1584 } ··· 1604 1604 if let Some(v) = updates.telemetry_enabled { 1605 1605 next.telemetry_enabled = v; 1606 1606 } 1607 - if let Some(v) = updates.appview_url { 1608 - next.appview_url = v; 1607 + if let Some(v) = updates.indexer_url { 1608 + next.indexer_url = v; 1609 1609 } 1610 1610 next.modified_at = now; 1611 1611
+5 -5
crates/opake-core/src/records/account_config.rs
··· 14 14 pub opake_version: u32, 15 15 pub telemetry_enabled: bool, 16 16 #[serde(skip_serializing_if = "Option::is_none")] 17 - pub appview_url: Option<String>, 17 + pub indexer_url: Option<String>, 18 18 pub modified_at: String, 19 19 } 20 20 21 21 impl AccountConfigRecord { 22 - /// Default preferences: telemetry disabled, no appview URL. 22 + /// Default preferences: telemetry disabled, no indexer URL. 23 23 pub fn new(modified_at: &str) -> Self { 24 24 Self { 25 25 opake_version: SCHEMA_VERSION, 26 26 telemetry_enabled: false, 27 - appview_url: None, 27 + indexer_url: None, 28 28 modified_at: modified_at.into(), 29 29 } 30 30 } ··· 33 33 /// Partial update payload for `AccountConfigRecord`. 34 34 /// 35 35 /// Field semantics: `Some(v)` replaces the current value, `None` leaves it 36 - /// untouched. `appview_url` uses a nested `Option` so callers can clear it 36 + /// untouched. `indexer_url` uses a nested `Option` so callers can clear it 37 37 /// by passing `Some(None)` — serialized as an explicit JSON `null`, which 38 38 /// is distinct from an absent/`undefined` field (the latter leaves the 39 39 /// current value intact). ··· 47 47 skip_serializing_if = "Option::is_none", 48 48 with = "double_option" 49 49 )] 50 - pub appview_url: Option<Option<String>>, 50 + pub indexer_url: Option<Option<String>>, 51 51 } 52 52 53 53 /// Distinguish absent (`None`) from explicit null (`Some(None)`) for
+1 -1
crates/opake-core/src/records/defs.rs
··· 6 6 7 7 /// A member's role in a workspace (keyring). 8 8 /// 9 - /// Plaintext on the record because the AppView needs it for authorization. 9 + /// Plaintext on the record because the Indexer needs it for authorization. 10 10 /// Grants ignore this field — it's only meaningful in keyring membership context. 11 11 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 12 12 #[serde(rename_all = "lowercase")]
+1 -1
crates/opake-core/src/records/directory_update.rs
··· 4 4 5 5 pub const DIRECTORY_UPDATE_COLLECTION: &str = "app.opake.directoryUpdate"; 6 6 7 - /// Action type strings for matching AppView proposal responses. 7 + /// Action type strings for matching Indexer proposal responses. 8 8 pub const ACTION_ADD_ENTRY: &str = "addEntry"; 9 9 pub const ACTION_REMOVE_ENTRY: &str = "removeEntry"; 10 10 pub const ACTION_MOVE_ENTRY: &str = "moveEntry";
+1 -1
crates/opake-core/src/records/document_update.rs
··· 4 4 5 5 pub const DOCUMENT_UPDATE_COLLECTION: &str = "app.opake.documentUpdate"; 6 6 7 - /// Action type strings for matching AppView proposal responses. 7 + /// Action type strings for matching Indexer proposal responses. 8 8 #[allow(dead_code)] 9 9 pub const ACTION_UPDATE_CONTENT: &str = "updateContent"; 10 10 #[allow(dead_code)]
+3 -3
crates/opake-core/src/records/keyring_update.rs
··· 4 4 5 5 pub const KEYRING_UPDATE_COLLECTION: &str = "app.opake.keyringUpdate"; 6 6 7 - /// Action type strings for matching AppView proposal responses. 7 + /// Action type strings for matching Indexer proposal responses. 8 8 pub const ACTION_RENAME: &str = "rename"; 9 9 pub const ACTION_UPDATE_DESCRIPTION: &str = "updateDescription"; 10 10 pub const ACTION_ADD_MEMBER: &str = "addMember"; ··· 15 15 /// A proposed change to a workspace keyring, with schema version envelope. 16 16 /// 17 17 /// Written by a member to their own PDS. The owner's daemon picks up 18 - /// pending updates via the AppView and applies them. `#[serde(flatten)]` 18 + /// pending updates via the Indexer and applies them. `#[serde(flatten)]` 19 19 /// inlines the variant fields alongside `opakeVersion` on the wire. 20 20 #[derive(Debug, Clone, Serialize, Deserialize)] 21 21 #[serde(rename_all = "camelCase")] ··· 70 70 role: String, 71 71 created_at: String, 72 72 }, 73 - /// Leave the workspace. AppView handles visibility immediately; 73 + /// Leave the workspace. Indexer handles visibility immediately; 74 74 /// the owner's daemon processes key rotation asynchronously. 75 75 #[serde(rename = "leave")] 76 76 Leave { keyring: String, created_at: String },
+5 -5
crates/opake-core/src/sse/consumer.rs
··· 25 25 26 26 /// A future-returning token fetcher. Called before every connect attempt. 27 27 /// Own-your-captures: the closure holds clones of whatever state it needs 28 - /// (transport, DID, signing key, appview URL). 28 + /// (transport, DID, signing key, indexer URL). 29 29 pub type TokenFetcher = Box<dyn FnMut() -> Pin<Box<dyn Future<Output = Result<String, Error>>>>>; 30 30 31 31 /// A future-returning sleep function. Abstracts over tokio::time::sleep ··· 40 40 /// Configuration and state for the outer reconnect loop. 41 41 pub struct SseConsumer<T: SseTransport> { 42 42 transport: T, 43 - appview_url: String, 43 + indexer_url: String, 44 44 fetch_token: TokenFetcher, 45 45 sleep: SleepFn, 46 46 jitter: JitterRng, ··· 62 62 impl<T: SseTransport> SseConsumer<T> { 63 63 pub fn new( 64 64 transport: T, 65 - appview_url: impl Into<String>, 65 + indexer_url: impl Into<String>, 66 66 fetch_token: TokenFetcher, 67 67 sleep: SleepFn, 68 68 jitter: JitterRng, 69 69 ) -> Self { 70 70 Self { 71 71 transport, 72 - appview_url: appview_url.into(), 72 + indexer_url: indexer_url.into(), 73 73 fetch_token, 74 74 sleep, 75 75 jitter, ··· 143 143 } 144 144 }; 145 145 146 - match self.transport.connect(&self.appview_url, token).await { 146 + match self.transport.connect(&self.indexer_url, token).await { 147 147 Ok(conn) => { 148 148 self.connection = Some(conn); 149 149 // If we were previously delivering events, queue a
+3 -3
crates/opake-core/src/sse/events.rs
··· 1 1 // SSE event types. Mirrors the payload shapes emitted by 2 - // `apps/appview/lib/opake_appview/sse/broadcaster.ex` and validated by the 2 + // `apps/indexer/lib/opake_indexer/sse/broadcaster.ex` and validated by the 3 3 // Zod schemas in the shipped SDK's `packages/opake-sdk/src/event-stream.ts`. 4 4 // 5 5 // Fields are serde-lenient — every field that the broadcaster marks with ··· 155 155 /// A document update proposal. 156 156 /// 157 157 /// Note: the `app.opake.documentUpdate` lexicon itself has no 158 - /// `keyring` field — the appview's indexer injects `keyring_uri` at 158 + /// `keyring` field — the indexer's firehose consumer injects `keyring_uri` at 159 159 /// dispatch time by joining through the documents table. When the 160 160 /// join succeeds, the broadcaster routes on the workspace topic 161 161 /// (where owners subscribe); when it fails (cabinet documents or a ··· 282 282 Self::DocumentUpdateDelete(decode(data, "document_update:delete")?) 283 283 } 284 284 other => { 285 - // Silent drop — the appview may add event types we don't 285 + // Silent drop — the indexer may add event types we don't 286 286 // understand yet, and forward-compat beats hard-failure. 287 287 log::debug!("[sse] ignoring unknown event type: {other}"); 288 288 let _ = parse; // satisfy unused-binding when all variants
+1 -1
crates/opake-core/src/sse/mock.rs
··· 93 93 impl SseTransport for MockSseTransport { 94 94 type Connection = MockSseConnection; 95 95 96 - async fn connect(&self, _appview_url: &str, _token: String) -> Result<Self::Connection, Error> { 96 + async fn connect(&self, _indexer_url: &str, _token: String) -> Result<Self::Connection, Error> { 97 97 if let Some(err) = self.connect_error.borrow_mut().take() { 98 98 return Err(err); 99 99 }
+1 -1
crates/opake-core/src/sse/mod.rs
··· 1 1 //! Server-Sent Events consumer infrastructure. 2 2 //! 3 - //! This module provides the building blocks for consuming the appview's 3 + //! This module provides the building blocks for consuming the indexer's 4 4 //! `/api/events` SSE stream from both WASM (browser `EventSource`) and 5 5 //! native (tokio + `reqwest::Response::bytes_stream()`) targets. The 6 6 //! design mirrors the existing [`crate::client::Transport`] trait style:
+2 -2
crates/opake-core/src/sse/reqwest_connection.rs
··· 52 52 impl SseTransport for ReqwestSseTransport { 53 53 type Connection = ReqwestSseConnection; 54 54 55 - async fn connect(&self, appview_url: &str, token: String) -> Result<Self::Connection, Error> { 55 + async fn connect(&self, indexer_url: &str, token: String) -> Result<Self::Connection, Error> { 56 56 let url = format!( 57 57 "{}/api/events?token={}", 58 - appview_url.trim_end_matches('/'), 58 + indexer_url.trim_end_matches('/'), 59 59 urlencoding::encode(&token) 60 60 ); 61 61
+3 -3
crates/opake-core/src/sse/transport.rs
··· 14 14 use crate::sse::events::SseEvent; 15 15 use std::future::Future; 16 16 17 - /// Opens SSE connections against the appview's `/api/events` endpoint. 17 + /// Opens SSE connections against the indexer's `/api/events` endpoint. 18 18 /// 19 19 /// Each call to [`connect`](Self::connect) establishes a fresh connection 20 20 /// using a one-shot token from `request_sse_token`. Reconnection is the ··· 24 24 25 25 /// Open a new SSE connection. The token is passed as a query parameter 26 26 /// (EventSource can't carry custom headers) and is single-use on the 27 - /// appview side, so every call must use a fresh token. 27 + /// indexer side, so every call must use a fresh token. 28 28 fn connect( 29 29 &self, 30 - appview_url: &str, 30 + indexer_url: &str, 31 31 token: String, 32 32 ) -> impl Future<Output = Result<Self::Connection, Error>>; 33 33 }
+3 -3
crates/opake-core/src/sse/wasm_connection.rs
··· 62 62 impl SseTransport for WasmSseTransport { 63 63 type Connection = WasmSseConnection; 64 64 65 - async fn connect(&self, appview_url: &str, token: String) -> Result<Self::Connection, Error> { 65 + async fn connect(&self, indexer_url: &str, token: String) -> Result<Self::Connection, Error> { 66 66 let url = format!( 67 67 "{}/api/events?token={}", 68 - appview_url.trim_end_matches('/'), 68 + indexer_url.trim_end_matches('/'), 69 69 urlencoding::encode(&token) 70 70 ); 71 71 ··· 130 130 } 131 131 } 132 132 133 - /// A live SSE connection to the appview. 133 + /// A live SSE connection to the indexer. 134 134 /// 135 135 /// The underlying `EventSource` is closed when this value is dropped. 136 136 pub struct WasmSseConnection {
+2 -2
crates/opake-core/src/storage.rs
··· 48 48 49 49 /// Persistent CLI configuration — tracks all logged-in accounts. 50 50 /// 51 - /// Device-local only. Cross-device preferences (appview URL, telemetry) 51 + /// Device-local only. Cross-device preferences (indexer URL, telemetry) 52 52 /// live in `AccountConfigRecord` on the PDS. 53 53 #[derive(Debug, Serialize, Deserialize)] 54 54 pub struct Config { ··· 172 172 /// Construct from raw key bytes (e.g. from WASM where keys arrive as Uint8Array). 173 173 /// 174 174 /// Creates an identity without signing keys — those are only needed for 175 - /// AppView auth, not file operations. 175 + /// Indexer auth, not file operations. 176 176 pub fn from_raw_keys(did: &str, public_key: &[u8; 32], private_key: &[u8; 32]) -> Self { 177 177 Self { 178 178 did: did.to_string(),
+1 -1
crates/opake-core/src/tree_keeper/mod.rs
··· 17 17 //! [`install_workspace_tree`] during the normal "load this view" flow 18 18 //! (typically after the existing `FileManager::load_tree` call). The 19 19 //! reconnect contract then covers any gap: on reconnect, all installed 20 - //! contexts should be full-synced from the appview, and the SSE stream 20 + //! contexts should be full-synced from the indexer, and the SSE stream 21 21 //! resumes from current state. 22 22 //! 23 23 //! [`install_cabinet_tree`]: TreeKeeper::install_cabinet_tree
+7 -7
crates/opake-core/src/workspace_keeper/mod.rs
··· 7 7 //! 8 8 //! This replaces the older "re-fetch `list_workspaces` on every SSE 9 9 //! keyring event" pattern, which relied on a round-trip through the 10 - //! appview and paid 1–4s of cursor-lag latency per update. With the 11 - //! keeper, SSE events patch the list directly — the appview is a 10 + //! indexer and paid 1–4s of cursor-lag latency per update. With the 11 + //! keeper, SSE events patch the list directly — the indexer is a 12 12 //! cold-start bootstrap path only. 13 13 //! 14 14 //! ## Cold-start ··· 150 150 // -- Mutation API -- 151 151 152 152 /// Replace the entire entry set. Called after a full-list fetch 153 - /// from the appview. 153 + /// from the indexer. 154 154 pub fn bootstrap(&mut self, entries: Vec<WorkspaceEntry>) { 155 155 self.entries = entries.into_iter().map(|e| (e.uri.clone(), e)).collect(); 156 156 self.loaded = true; ··· 333 333 }) 334 334 } 335 335 336 - /// Convenience wrapper: build an entry from an [`AppviewKeyring`]. 336 + /// Convenience wrapper: build an entry from an [`IndexerKeyring`]. 337 337 /// 338 - /// [`AppviewKeyring`]: crate::client::AppviewKeyring 339 - pub fn try_build_entry_from_appview_keyring( 340 - keyring: &crate::client::AppviewKeyring, 338 + /// [`IndexerKeyring`]: crate::client::IndexerKeyring 339 + pub fn try_build_entry_from_indexer_keyring( 340 + keyring: &crate::client::IndexerKeyring, 341 341 my_did: &str, 342 342 private_key: &X25519PrivateKey, 343 343 ) -> Option<WorkspaceEntry> {
+5 -5
crates/opake-wasm/src/lib.rs
··· 431 431 } 432 432 433 433 // --------------------------------------------------------------------------- 434 - // AppView auth signing 434 + // Indexer auth signing 435 435 // --------------------------------------------------------------------------- 436 436 437 - /// Sign an appview request and return the full Authorization header value. 437 + /// Sign an indexer request and return the full Authorization header value. 438 438 /// 439 439 /// Returns: `Opake-Ed25519 <did>:<timestamp>:<base64(signature)>` 440 - #[wasm_bindgen(js_name = signAppviewRequest)] 441 - pub fn sign_appview_request_js( 440 + #[wasm_bindgen(js_name = signIndexerRequest)] 441 + pub fn sign_indexer_request_js( 442 442 method: &str, 443 443 path: &str, 444 444 did: &str, ··· 448 448 let key: [u8; 32] = signing_key 449 449 .try_into() 450 450 .map_err(|_| JsError::new("signing key must be exactly 32 bytes"))?; 451 - Ok(opake_core::client::sign_appview_request( 451 + Ok(opake_core::client::sign_indexer_request( 452 452 method, 453 453 path, 454 454 did,
+22 -22
crates/opake-wasm/src/opake_wasm.rs
··· 227 227 #[wasm_bindgen(js_name = listWorkspaces)] 228 228 pub async fn list_workspaces( 229 229 &self, 230 - default_appview_url: Option<String>, 230 + default_indexer_url: Option<String>, 231 231 ) -> Result<JsValue, JsError> { 232 232 let mut opake = self.opake().await?; 233 233 let identity = opake.require_identity().map_err(wasm_err)?; ··· 235 235 let did = opake.did().to_string(); 236 236 237 237 let keyrings = opake 238 - .discover_member_keyrings(default_appview_url.as_deref()) 238 + .discover_member_keyrings(default_indexer_url.as_deref()) 239 239 .await 240 240 .map_err(wasm_err)?; 241 241 drop(opake); ··· 247 247 let entries: Vec<opake_core::workspace_keeper::WorkspaceEntry> = keyrings 248 248 .iter() 249 249 .filter_map(|kr| { 250 - opake_core::workspace_keeper::try_build_entry_from_appview_keyring( 250 + opake_core::workspace_keeper::try_build_entry_from_indexer_keyring( 251 251 kr, 252 252 &did, 253 253 &private_key, ··· 614 614 })) 615 615 } 616 616 617 - /// Override the cached appview URL at runtime. 617 + /// Override the cached indexer URL at runtime. 618 618 /// 619 - /// Overrides the compile-time `DEFAULT_APPVIEW_URL` seeded during 619 + /// Overrides the compile-time `DEFAULT_INDEXER_URL` seeded during 620 620 /// `for_account`. Callers use this at boot to inject a host-specific 621 - /// runtime default (e.g. web's `VITE_APPVIEW_URL`, which can't be 621 + /// runtime default (e.g. web's `VITE_INDEXER_URL`, which can't be 622 622 /// baked in because one WASM binary serves multiple deployments). 623 623 /// 624 624 /// Subsequent writes to `accountConfig` on the PDS still override 625 625 /// this value via `set_account_config` — so a user-configured 626 - /// appview (written via settings) wins over the host default. 627 - #[wasm_bindgen(js_name = setAppviewUrl)] 628 - pub async fn set_appview_url(&self, url: String) -> Result<(), JsError> { 626 + /// indexer (written via settings) wins over the host default. 627 + #[wasm_bindgen(js_name = setIndexerUrl)] 628 + pub async fn set_indexer_url(&self, url: String) -> Result<(), JsError> { 629 629 let mut guard = self.inner.lock().await; 630 630 let opake = guard 631 631 .as_mut() 632 632 .ok_or_else(|| JsError::new("Opake context already consumed"))?; 633 - opake.set_appview_url(url); 633 + opake.set_indexer_url(url); 634 634 Ok(()) 635 635 } 636 636 ··· 691 691 opake.publish_public_key().await.map_err(wasm_err) 692 692 } 693 693 694 - /// Fetch workspace documents from the AppView. 694 + /// Fetch workspace documents from the Indexer. 695 695 #[wasm_bindgen(js_name = listWorkspaceDocuments)] 696 696 pub async fn list_workspace_documents( 697 697 &self, 698 698 keyring_uri: &str, 699 - default_appview_url: Option<String>, 699 + default_indexer_url: Option<String>, 700 700 ) -> Result<JsValue, JsError> { 701 701 let mut opake = self.opake().await?; 702 702 let docs = opake 703 - .list_workspace_documents(keyring_uri, default_appview_url.as_deref()) 703 + .list_workspace_documents(keyring_uri, default_indexer_url.as_deref()) 704 704 .await 705 705 .map_err(wasm_err)?; 706 706 to_js(&docs) ··· 710 710 #[wasm_bindgen(js_name = discoverMemberKeyrings)] 711 711 pub async fn discover_member_keyrings( 712 712 &self, 713 - default_appview_url: Option<String>, 713 + default_indexer_url: Option<String>, 714 714 ) -> Result<JsValue, JsError> { 715 715 let mut opake = self.opake().await?; 716 716 let keyrings = opake 717 - .discover_member_keyrings(default_appview_url.as_deref()) 717 + .discover_member_keyrings(default_indexer_url.as_deref()) 718 718 .await 719 719 .map_err(wasm_err)?; 720 720 to_js(&keyrings) 721 721 } 722 722 723 - /// Request a short-lived SSE token from the AppView. 723 + /// Request a short-lived SSE token from the Indexer. 724 724 #[wasm_bindgen(js_name = requestSseToken)] 725 - pub async fn request_sse_token(&self, appview_url: Option<String>) -> Result<String, JsError> { 725 + pub async fn request_sse_token(&self, indexer_url: Option<String>) -> Result<String, JsError> { 726 726 let mut opake = self.opake().await?; 727 727 opake 728 - .request_sse_token(appview_url.as_deref()) 728 + .request_sse_token(indexer_url.as_deref()) 729 729 .await 730 730 .map_err(wasm_err) 731 731 } 732 732 733 - /// Fetch all incoming grants from the AppView. 733 + /// Fetch all incoming grants from the Indexer. 734 734 /// 735 735 /// Side effect: bootstraps the shared `InboxKeeper` with the result. 736 736 /// Any `watchInbox` callers (current or future) receive a fresh ··· 739 739 /// events keep the keeper in sync without further `listInbox` 740 740 /// round-trips. 741 741 #[wasm_bindgen(js_name = listInbox)] 742 - pub async fn list_inbox(&self, appview_url: Option<String>) -> Result<JsValue, JsError> { 742 + pub async fn list_inbox(&self, indexer_url: Option<String>) -> Result<JsValue, JsError> { 743 743 let mut opake = self.opake().await?; 744 744 let grants = opake 745 - .list_inbox(appview_url.as_deref()) 745 + .list_inbox(indexer_url.as_deref()) 746 746 .await 747 747 .map_err(wasm_err)?; 748 748 drop(opake); 749 749 750 750 let entries: Vec<opake_core::inbox_keeper::InboxEntry> = 751 - grants.iter().map(ik::entry_from_appview_grant).collect(); 751 + grants.iter().map(ik::entry_from_indexer_grant).collect(); 752 752 753 753 { 754 754 let mut keeper = self.inbox_keeper.lock().await;
+12 -12
crates/opake-wasm/src/sse_wasm.rs
··· 2 2 // watchers. 3 3 // 4 4 // Exposes: 5 - // - WasmOpakeHandle::startSseConsumer(appviewUrl) 5 + // - WasmOpakeHandle::startSseConsumer(indexerUrl) 6 6 // - WasmOpakeHandle::stopSseConsumer() 7 7 // - WasmOpakeHandle::watchWorkspaces(callback) 8 8 // - WasmFileManagerHandle::watchDirectory(uri, callback) ··· 313 313 #[wasm_bindgen(js_class = OpakeContext)] 314 314 impl WasmOpakeHandle { 315 315 /// Start the SSE event consumer. Spawns a background task that 316 - /// connects to the appview's `/api/events` endpoint, pulls events, 316 + /// connects to the indexer's `/api/events` endpoint, pulls events, 317 317 /// and dispatches them to the shared TreeKeeper. 318 318 /// 319 - /// `appview_url` is optional: if omitted, the URL is resolved from 319 + /// `indexer_url` is optional: if omitted, the URL is resolved from 320 320 /// the Opake instance's stored config (loaded during `init`). Pass 321 321 /// an explicit value as a fallback for Opake instances whose config 322 - /// doesn't include an appview URL. 322 + /// doesn't include an indexer URL. 323 323 /// 324 324 /// Idempotent: subsequent calls are no-ops while an existing 325 325 /// consumer is running. React StrictMode's double-mount is thus 326 326 /// harmless — only one consumer task exists per OpakeContext. 327 327 #[wasm_bindgen(js_name = startSseConsumer)] 328 - pub async fn start_sse_consumer(&self, appview_url: Option<String>) -> Result<(), JsError> { 328 + pub async fn start_sse_consumer(&self, indexer_url: Option<String>) -> Result<(), JsError> { 329 329 // Resolve the URL BEFORE flipping the started flag — if no URL 330 330 // is available anywhere, we want to fail loudly without leaving 331 331 // the flag in a broken state. ··· 335 335 .as_ref() 336 336 .ok_or_else(|| JsError::new("Opake context already consumed"))?; 337 337 opake 338 - .resolve_appview_url(appview_url.as_deref()) 338 + .resolve_indexer_url(indexer_url.as_deref()) 339 339 .map_err(wasm_err)? 340 340 }; 341 341 ··· 388 388 } else { 389 389 // Unroutable proposal — in practice a 390 390 // `documentUpdate` (the lexicon has no 391 - // `keyring` field). The appview routes it 391 + // `keyring` field). The indexer routes it 392 392 // to the author's personal topic, so the 393 393 // workspace owner never sees it and the web 394 394 // client has no polling fallback to fill ··· 406 406 } 407 407 408 408 // Workspace list updates: apply directly to the keeper 409 - // so subscribers see changes without an appview round- 409 + // so subscribers see changes without an indexer round- 410 410 // trip. Idempotent upserts (same rotation + same data) 411 411 // don't re-fire watchers — see `WorkspaceKeeper::upsert`. 412 412 apply_keyring_to_workspace_keeper(&opake_rc, &workspace_keeper_rc, &event).await; ··· 463 463 464 464 /// Build a token fetcher closure that uses the shared Opake to request 465 465 /// a fresh SSE token on every connect attempt. 466 - fn make_token_fetcher(opake_rc: Rc<Mutex<Option<WasmOpake>>>, appview_url: String) -> TokenFetcher { 466 + fn make_token_fetcher(opake_rc: Rc<Mutex<Option<WasmOpake>>>, indexer_url: String) -> TokenFetcher { 467 467 Box::new(move || { 468 468 let opake_rc = Rc::clone(&opake_rc); 469 - let appview_url = appview_url.clone(); 469 + let indexer_url = indexer_url.clone(); 470 470 Box::pin(async move { 471 471 let guard = opake_rc.lock().await; 472 472 let opake = guard ··· 476 476 let identity = opake 477 477 .identity() 478 478 .ok_or_else(|| opake_core::error::Error::Sse("no identity".into()))?; 479 - // Ed25519 signing key — used for appview auth signatures. 479 + // Ed25519 signing key — used for indexer auth signatures. 480 480 let signing_key = identity 481 481 .signing_key_bytes() 482 482 .map_err(|e| opake_core::error::Error::Sse(format!("{e}")))? ··· 484 484 opake_core::error::Error::Sse("identity has no signing key".into()) 485 485 })?; 486 486 let transport = opake_core::client::WasmTransport::new(); 487 - request_sse_token(&transport, &appview_url, &did, &signing_key).await 487 + request_sse_token(&transport, &indexer_url, &did, &signing_key).await 488 488 }) 489 489 }) 490 490 }
+4 -4
crates/opake-wasm/src/wasm_util.rs
··· 67 67 Error::KeyWrap(_) => "KeyWrap", 68 68 Error::Auth(_) => "Auth", 69 69 Error::Xrpc { .. } => "Xrpc", 70 - Error::Appview { .. } => "Appview", 70 + Error::Indexer { .. } => "Indexer", 71 71 Error::NotFound(_) => "NotFound", 72 72 Error::RecipientNotReady(_) => "RecipientNotReady", 73 73 Error::AmbiguousName { .. } => "AmbiguousName", ··· 105 105 106 106 /// Construct an Opake context from a JsStorageAdapter via `for_account`. 107 107 /// 108 - /// AppView URL is resolved by `for_account`: account config on PDS 109 - /// overrides the compile-time `DEFAULT_APPVIEW_URL` (set via 110 - /// `OPAKE_APPVIEW_URL` env var at build time). 108 + /// Indexer URL is resolved by `for_account`: account config on PDS 109 + /// overrides the compile-time `DEFAULT_INDEXER_URL` (set via 110 + /// `OPAKE_INDEXER_URL` env var at build time). 111 111 pub async fn make_opake_from_storage( 112 112 did: Option<&str>, 113 113 storage: crate::js_storage::JsStorageAdapter,
+4 -4
docker-compose.yml
··· 14 14 timeout: 5s 15 15 retries: 5 16 16 17 - appview: 17 + indexer: 18 18 build: 19 - context: apps/appview 20 - dockerfile: ../Containerfile.appview 19 + context: apps/indexer 20 + dockerfile: ../Containerfile.indexer 21 21 depends_on: 22 22 db: 23 23 condition: service_healthy 24 24 environment: 25 - DATABASE_URL: ecto://postgres:postgres@db/opake_appview_prod 25 + DATABASE_URL: ecto://postgres:postgres@db/opake_indexer_prod 26 26 SECRET_KEY_BASE: dev-only-please-change-in-production-64-chars-aaaaaaaaaaaaaaaa 27 27 PHX_HOST: localhost 28 28 PHX_SERVER: "true"
+12 -10
docs/ARCHITECTURE.md
··· 18 18 Crypto["Client-side crypto<br/>(AES-256-GCM, X25519)"] 19 19 end 20 20 21 - subgraph Server ["AppView (self-hosted)"] 22 - AppView["opake-appview<br/>(Elixir/Phoenix)"] 21 + subgraph Server ["Indexer (self-hosted)"] 22 + Indexer["opake-indexer<br/>(Elixir/Phoenix)"] 23 23 Postgres["PostgreSQL"] 24 24 end 25 25 ··· 36 36 Core -->|XRPC / HTTPS| OwnPDS 37 37 Core -->|unauthenticated| OtherPDS 38 38 Core -->|DID resolution| PLC 39 - CLI -->|inbox query| AppView 40 - Web -->|inbox query| AppView 39 + CLI -->|inbox query| Indexer 40 + Web -->|inbox query| Indexer 41 41 42 - AppView -->|subscribe| Jetstream 43 - AppView --> Postgres 42 + Indexer -->|subscribe| Jetstream 43 + Indexer --> Postgres 44 44 Jetstream -.->|events from| OwnPDS 45 45 Jetstream -.->|events from| OtherPDS 46 46 ··· 51 51 style Network fill:#16213e,color:#eee 52 52 ``` 53 53 54 - Both the CLI and the web frontend talk directly to PDS instances over XRPC. No PDS modifications needed. All encryption and decryption happens client-side — on your machine (CLI) or in the browser (Web via WASM). The AppView is an optional component that indexes grants and keyrings from the firehose for discovery. 54 + Both the CLI and the web frontend talk directly to PDS instances over XRPC. No PDS modifications needed. All encryption and decryption happens client-side — on your machine (CLI) or in the browser (Web via WASM). The Indexer is an optional component that indexes grants and keyrings from the firehose for discovery. 55 + 56 + The Indexer fills the atproto "appview" protocol role — it reads the firehose and serves indexed records through a REST API. We call it the indexer because all payloads are ciphertext; it serves no rendered views. 55 57 56 58 ## Encryption Model 57 59 ··· 90 92 AT Protocol DID documents only contain signing keys (secp256k1/P-256), not encryption keys. Opake publishes `app.opake.publicKey/self` singleton records on each user's PDS containing: 91 93 92 94 - **X25519 encryption public key** — used for key wrapping (sharing) 93 - - **Ed25519 signing public key** — used for AppView authentication 95 + - **Ed25519 signing public key** — used for Indexer authentication 94 96 95 97 Key discovery is an unauthenticated `getRecord` call — no auth needed to look up someone's public key. Both keys are published automatically on every `opake login` via an idempotent `putRecord`. 96 98 ··· 158 160 159 161 IDENTITY { 160 162 bytes x25519_private "decrypts wrapped keys" 161 - bytes ed25519_signing "AppView auth" 163 + bytes ed25519_signing "Indexer auth" 162 164 string seed_phrase "24-word BIP-39 (not stored)" 163 165 } 164 166 ··· 218 220 - **[CRATE_STRUCTURE.md](CRATE_STRUCTURE.md)** — Detailed file tree for all crates and the web frontend 219 221 - **[STORAGE.md](STORAGE.md)** — Storage abstraction, local record cache, file permissions 220 222 - **[AUTH.md](AUTH.md)** — OAuth/DPoP authentication, multi-account support, device pairing 221 - - **[appview.md](appview.md)** — AppView indexer: tables, endpoints, deployment 223 + - **[indexer.md](indexer.md)** — Indexer: tables, endpoints, deployment
+5 -5
docs/CRATE_STRUCTURE.md
··· 24 24 mod.rs WorkspaceKeeper — in-memory workspace list state. Bootstrapped by `listWorkspaces`, patched by `keyring:upsert` / `keyring:delete` SSE events. `watchWorkspaces` installs snapshot callbacks. Parallel design to TreeKeeper. 25 25 tests.rs Unit tests 26 26 inbox_keeper/ 27 - mod.rs InboxKeeper — in-memory incoming-share list state. Bootstrapped by `listInbox`, patched by `grant:upsert` / `grant:delete` SSE events (appview fans both to owner and recipient). `watchInbox` installs snapshot callbacks. Parallel design to WorkspaceKeeper; no crypto — entries are already-resolved appview records. 27 + mod.rs InboxKeeper — in-memory incoming-share list state. Bootstrapped by `listInbox`, patched by `grant:upsert` / `grant:delete` SSE events (indexer fans both to owner and recipient). `watchInbox` installs snapshot callbacks. Parallel design to WorkspaceKeeper; no crypto — entries are already-resolved indexer records. 28 28 tests.rs Unit tests 29 29 manager/ 30 30 mod.rs FileManager<'a, T, R, S> struct (borrows &mut Opake + &FileContext), create_record passthrough ··· 159 159 share_group.rs Share subcommand group (new, revoke, list, inbox) 160 160 revoke.rs Grant deletion 161 161 shared.rs List created grants 162 - inbox.rs List received grants (via AppView) 162 + inbox.rs List received grants (via Indexer) 163 163 workspace.rs Workspace CRUD (create, ls, add-member, leave). remove-member via WorkspaceAdmin. Renamed from keyring.rs 164 164 pair.rs Device pairing (request, approve) 165 165 accounts.rs List accounts ··· 216 216 identity.ts Keypairs, seed phrases, DPoP, DID resolution 217 217 workspace.ts Workspace operations 218 218 219 - appview/ Elixir/Phoenix indexer + REST API (replaces Rust appview) 219 + indexer/ Elixir/Phoenix indexer + REST API (replaces Rust indexer) 220 220 lib/ 221 - opake_appview/ 221 + opake_indexer/ 222 222 application.ex OTP supervision tree (Repo, KeyCache, Endpoint, Consumer) 223 223 indexer.ex Event dispatch, cursor saving, connection state (ETS) 224 224 release.ex Release tasks (create_db, migrate, rollback, status) ··· 244 244 keyring_member.ex Keyring member (composite PK) 245 245 workspace_document.ex Workspace document schema 246 246 document_update.ex Document update schema 247 - opake_appview_web/ 247 + opake_indexer_web/ 248 248 router.ex /api/health (public), /api/inbox + /api/keyrings + /api/workspace + /api/workspace/updates (auth'd) 249 249 endpoint.ex Bandit HTTP, API-only (no sessions/static) 250 250 plugs/rate_limit.ex Hammer ETS rate limiting per IP
+2 -2
docs/CRYPTO.md
··· 11 11 | AES-256-KW (RFC 3394) | Symmetric key wrapping (content key → group key) | `aes-kw` | 12 12 | HKDF-SHA256 | KDF for key wrapping + identity derivation | `hkdf` + `sha2` | 13 13 | PBKDF2-HMAC-SHA512 | Mnemonic → master seed | `pbkdf2` + `sha2` | 14 - | Ed25519 | AppView authentication signatures | `ed25519-dalek` | 14 + | Ed25519 | Indexer authentication signatures | `ed25519-dalek` | 15 15 | BIP-39 | 24-word mnemonic encoding (256-bit entropy) | `bip39` (embedded wordlist) | 16 16 17 17 `x25519-hkdf-a256kw` is intentionally distinct from JWE's `ECDH-ES+A256KW` — we use HKDF-SHA256, not JWE's Concat KDF. ··· 198 198 } 199 199 ``` 200 200 201 - Only used in keyring `members` and `keyHistory` arrays. Composes a `WrappedKey` with a workspace role. The role is plaintext because the AppView needs it for authorization — it's not a crypto concept. 201 + Only used in keyring `members` and `keyHistory` arrays. Composes a `WrappedKey` with a workspace role. The role is plaintext because the Indexer needs it for authorization — it's not a crypto concept. 202 202 203 203 ### Encryption union on documents 204 204
+5 -5
docs/FLOWS.md
··· 27 27 28 28 **Cold start (bootstrap)** 29 29 30 - 1. `listWorkspaces` calls `discover_member_keyrings` → fetches all keyrings from the appview. 30 + 1. `listWorkspaces` calls `discover_member_keyrings` → fetches all keyrings from the indexer. 31 31 2. Each keyring is run through `try_build_entry` (identity key material from `Identity::private_key_bytes`) to produce a `WorkspaceEntry` with decrypted name/description. 32 32 3. `WorkspaceKeeper::bootstrap` replaces the entry set and flips `loaded = true`. All registered `watchWorkspaces` callbacks receive an updated snapshot immediately. 33 33 ··· 45 45 46 46 **Optimistic insert** 47 47 48 - After `createWorkspace` succeeds, `opake_wasm.rs` synthesizes a `WorkspaceEntry` from the known-fresh data and calls `keeper.upsert` immediately. The sidebar reflects the new workspace within the current render cycle rather than waiting 1–4 s for the appview cursor lag. The later SSE echo is a no-op (dedup short-circuits). 48 + After `createWorkspace` succeeds, `opake_wasm.rs` synthesizes a `WorkspaceEntry` from the known-fresh data and calls `keeper.upsert` immediately. The sidebar reflects the new workspace within the current render cycle rather than waiting 1–4 s for the indexer cursor lag. The later SSE echo is a no-op (dedup short-circuits). 49 49 50 50 **Watcher teardown** 51 51 ··· 59 59 60 60 **Cold start (bootstrap)** 61 61 62 - 1. `listInbox` calls the appview's `/api/inbox` endpoint → returns paginated `InboxGrant` records for the authenticated DID. 62 + 1. `listInbox` calls the indexer's `/api/inbox` endpoint → returns paginated `InboxGrant` records for the authenticated DID. 63 63 2. `InboxKeeper::bootstrap` replaces the entry set and flips `loaded = true`. All registered `watchInbox` callbacks receive an updated snapshot immediately. 64 64 65 65 **Incremental updates (SSE)** 66 66 67 67 SSE `grant:upsert` events route to `apply_grant_to_inbox_keeper`: 68 68 69 - 1. The appview broadcasts `grant:upsert` to the **recipient's** personal topic (in addition to the owner's). 69 + 1. The indexer broadcasts `grant:upsert` to the **recipient's** personal topic (in addition to the owner's). 70 70 2. `InboxKeeper::upsert` adds or updates the entry. Deduplication: if the new entry equals the existing one, no callbacks fire. 71 71 72 72 SSE `grant:delete` events: 73 73 74 - 1. The appview fetches `owner_did` + `recipient_did` from the DB **before** deleting the row (the firehose delete payload carries only the URI). Both personal topics receive `grant:delete`. 74 + 1. The indexer fetches `owner_did` + `recipient_did` from the DB **before** deleting the row (the firehose delete payload carries only the URI). Both personal topics receive `grant:delete`. 75 75 2. `InboxKeeper::delete(uri)` removes the entry and fires callbacks. 76 76 77 77 **Watcher teardown**
+1 -1
docs/LICENSING.md
··· 41 41 42 42 ### What does NOT trigger copyleft 43 43 44 - - **Communicating with Opake over HTTP or XRPC.** The API boundary is not a linking boundary. A mobile app that talks to the Opake AppView over REST, or a script that calls PDS endpoints, is an independent work — license it however you want. 44 + - **Communicating with Opake over HTTP or XRPC.** The API boundary is not a linking boundary. A mobile app that talks to the Opake Indexer over REST, or a script that calls PDS endpoints, is an independent work — license it however you want. 45 45 - **Reading or writing `app.opake.*` records on a PDS.** Lexicon schemas are interface definitions. Implementing them independently doesn't create a derivative work. 46 46 - **Running Opake alongside your software** without linking (an "aggregate" in GPL terms). Shipping a Docker Compose stack that includes Opake as a separate container is fine. 47 47
+15 -15
docs/appview.md docs/indexer.md
··· 1 1 <!-- 2 2 NOTE TO EDITORS: 3 - Opake uses a dual-documentation system. If you modify the AppView service 3 + Opake uses a dual-documentation system. If you modify the Indexer service 4 4 details or indexing logic in this file, you MUST also update the 5 - corresponding MDX content in `web/src/content/` to prevent documentation drift. 5 + corresponding MDX content in `apps/web/src/content/` to prevent documentation drift. 6 6 --> 7 7 8 - # AppView: API & Deployment 8 + # Indexer: API & Deployment 9 9 10 - The AppView indexes five `app.opake.*` collections from the AT Protocol firehose — `grant`, `keyring`, `document` (keyring-encrypted only), `documentUpdate`, and `keyringLeave` — and serves them via a REST API. It enables the `inbox` command ("what's been shared with me?") and workspace queries without scanning every PDS in the network. 10 + The Indexer indexes five `app.opake.*` collections from the AT Protocol firehose — `grant`, `keyring`, `document` (keyring-encrypted only), `documentUpdate`, and `keyringLeave` — and serves them via a REST API. It enables the `inbox` command ("what's been shared with me?") and workspace queries without scanning every PDS in the network. 11 11 12 - Built with Elixir/Phoenix. Source lives in `appview/`. 12 + Built with Elixir/Phoenix. Source lives in `apps/indexer/`. 13 13 14 14 ## Running Modes 15 15 ··· 24 24 Status check via release eval: 25 25 26 26 ```bash 27 - bin/opake_appview eval "OpakeAppview.Release.status()" 27 + bin/opake_indexer eval "OpakeIndexer.Release.status()" 28 28 ``` 29 29 30 30 ## Development 31 31 32 32 ```bash 33 - cd appview 33 + cd apps/indexer 34 34 docker compose up -d # start postgres 35 35 mix setup # deps + create DB + migrate 36 36 mix phx.server # dev server on :6100 ··· 40 40 ## Production (Docker) 41 41 42 42 ```bash 43 - cd appview 43 + cd apps/indexer 44 44 docker compose --profile full up --build 45 45 ``` 46 46 47 - This starts postgres and the appview container. The entrypoint auto-creates the database and runs migrations. 47 + This starts postgres and the indexer container. The entrypoint auto-creates the database and runs migrations. 48 48 49 49 ## Database Schema 50 50 ··· 60 60 61 61 ### Development 62 62 63 - `config/dev.exs` — defaults to local postgres (`postgres:postgres@localhost/opake_appview_dev`) and the public Jetstream relay. 63 + `config/dev.exs` — defaults to local postgres (`postgres:postgres@localhost/opake_indexer_dev`) and the public Jetstream relay. 64 64 65 65 ### Production (environment variables) 66 66 ··· 235 235 236 236 ## Release Tasks 237 237 238 - Available via `bin/opake_appview eval`: 238 + Available via `bin/opake_indexer eval`: 239 239 240 240 ```bash 241 241 # Create the database 242 - bin/opake_appview eval "OpakeAppview.Release.create_db()" 242 + bin/opake_indexer eval "OpakeIndexer.Release.create_db()" 243 243 244 244 # Run pending migrations 245 - bin/opake_appview eval "OpakeAppview.Release.migrate()" 245 + bin/opake_indexer eval "OpakeIndexer.Release.migrate()" 246 246 247 247 # Print cursor position, lag, and indexed record counts 248 - bin/opake_appview eval "OpakeAppview.Release.status()" 248 + bin/opake_indexer eval "OpakeIndexer.Release.status()" 249 249 250 250 # Rollback to a specific migration version 251 - bin/opake_appview eval "OpakeAppview.Release.rollback(OpakeAppview.Repo, 20260310000001)" 251 + bin/opake_indexer eval "OpakeIndexer.Release.rollback(OpakeIndexer.Repo, 20260310000001)" 252 252 ```
+5 -5
docs/flows/directories.md
··· 153 153 sequenceDiagram 154 154 participant Member 155 155 participant MemberPDS as Member's PDS 156 - participant AppView 156 + participant Indexer 157 157 participant OwnerDaemon as Owner's Daemon 158 158 participant OwnerPDS as Owner's PDS 159 159 160 160 Member->>MemberPDS: createRecord(directoryUpdate, { actionType, keyring, ... }) 161 - MemberPDS->>AppView: firehose event 162 - AppView->>AppView: index in directory_updates 161 + MemberPDS->>Indexer: firehose event 162 + Indexer->>Indexer: index in directory_updates 163 163 164 - OwnerDaemon->>AppView: GET /api/workspace/directory-updates 165 - AppView-->>OwnerDaemon: pending directoryUpdate records 164 + OwnerDaemon->>Indexer: GET /api/workspace/directory-updates 165 + Indexer-->>OwnerDaemon: pending directoryUpdate records 166 166 OwnerDaemon->>OwnerPDS: apply changes (applyWrites for moves) 167 167 ``` 168 168
+1 -1
docs/flows/keyrings.md
··· 54 54 CLI->>User: family-photos → at://did/.../keyring-tid 55 55 ``` 56 56 57 - The group key is never stored in plaintext on the PDS — only the wrapped copies live in the keyring record. The `owner` field identifies the canonical keyring owner for AppView authorization. 57 + The group key is never stored in plaintext on the PDS — only the wrapped copies live in the keyring record. The `owner` field identifies the canonical keyring owner for Indexer authorization. 58 58 59 59 ## List Keyrings 60 60
+13 -13
docs/flows/revisions.md
··· 1 1 # Document Updates (Collaborative Editing) 2 2 3 - Collaborative editing via `app.opake.documentUpdate` records. Each editor uploads updates to their own PDS — data stays under their control, and the AppView surfaces pending updates to the document owner. Same pattern as Bluesky replies: your content lives on your PDS, the AppView presents the thread. 3 + Collaborative editing via `app.opake.documentUpdate` records. Each editor uploads updates to their own PDS — data stays under their control, and the Indexer surfaces pending updates to the document owner. Same pattern as Bluesky replies: your content lives on your PDS, the Indexer presents the thread. 4 4 5 5 ## Propose Update (Workspace Editor) 6 6 ··· 91 91 sequenceDiagram 92 92 participant Manager 93 93 participant CLI as Manager's CLI 94 - participant AppView 94 + participant Indexer 95 95 participant RemovedPDS as Removed Member's PDS 96 96 participant Crypto 97 97 participant ManagerPDS as Manager's PDS 98 98 99 - Manager->>AppView: GET /api/workspace?keyring={uri} 100 - AppView-->>Manager: documents including removed member's 99 + Manager->>Indexer: GET /api/workspace?keyring={uri} 100 + Indexer-->>Manager: documents including removed member's 101 101 102 102 loop For each orphaned document 103 103 Manager->>RemovedPDS: getRecord + getBlob ··· 120 120 121 121 Adoption must happen while the removed member's PDS is still serving data. The daemon should adopt eagerly on removal, not lazily. 122 122 123 - ## Discovery via AppView 123 + ## Discovery via Indexer 124 124 125 - The AppView watches firehose events for `documentUpdate` records and indexes them by target document. 125 + The Indexer watches firehose events for `documentUpdate` records and indexes them by target document. 126 126 127 127 ```mermaid 128 128 sequenceDiagram 129 - participant AppView 129 + participant Indexer 130 130 participant EditorPDS as Editor's PDS 131 131 participant OwnerPDS as Owner's PDS 132 132 133 - EditorPDS->>AppView: Firehose event: new documentUpdate record 134 - AppView->>AppView: Validate editor role, index by document URI 133 + EditorPDS->>Indexer: Firehose event: new documentUpdate record 134 + Indexer->>Indexer: Validate editor role, index by document URI 135 135 136 - Note over AppView: Later, owner queries pending updates 136 + Note over Indexer: Later, owner queries pending updates 137 137 138 - OwnerPDS->>AppView: GET /api/workspace/updates?document=at://owner/.../document/tid 139 - AppView-->>OwnerPDS: [{ update_uri, author_did, supersedes_uri, created_at }, ...] 138 + OwnerPDS->>Indexer: GET /api/workspace/updates?document=at://owner/.../document/tid 139 + Indexer-->>OwnerPDS: [{ update_uri, author_did, supersedes_uri, created_at }, ...] 140 140 ``` 141 141 142 - Without the AppView, discovery falls back to polling each workspace member's PDS for `app.opake.documentUpdate` records whose `document` field matches. Slow but functional. 142 + Without the Indexer, discovery falls back to polling each workspace member's PDS for `app.opake.documentUpdate` records whose `document` field matches. Slow but functional.
+20 -20
justfile
··· 100 100 cd apps/web && bun run tsc --noEmit 101 101 102 102 # --------------------------------------------------------------------------- 103 - # Elixir appview 103 + # Elixir indexer 104 104 # --------------------------------------------------------------------------- 105 105 106 - # Start appview dev server 107 - appview: 108 - cd apps/appview && mix phx.server 106 + # Start indexer dev server 107 + indexer: 108 + cd apps/indexer && mix phx.server 109 109 110 - # Run appview tests 111 - appview-test: 112 - cd apps/appview && mix test 110 + # Run indexer tests 111 + indexer-test: 112 + cd apps/indexer && mix test 113 113 114 - # Build appview release 115 - appview-release: 116 - cd apps/appview && MIX_ENV=prod mix release 114 + # Build indexer release 115 + indexer-release: 116 + cd apps/indexer && MIX_ENV=prod mix release 117 117 118 118 # --------------------------------------------------------------------------- 119 119 # E2E tests ··· 123 123 e2e-cli: 124 124 cd tests && bun test tests/cli/ 125 125 126 - # Run web e2e tests (requires running web + appview) 126 + # Run web e2e tests (requires running web + indexer) 127 127 e2e-web: 128 128 cd tests && bun test tests/web/ 129 129 ··· 135 135 # --------------------------------------------------------------------------- 136 136 137 137 # Run all checks (CI equivalent) 138 - validate: fmt clippy rust-test sdk web-lint web-typecheck web-build appview-test 138 + validate: fmt clippy rust-test sdk web-lint web-typecheck web-build indexer-test 139 139 140 - # Run all tests (Rust + SDK + web + appview) 141 - test: rust-test sdk-test web-test appview-test 140 + # Run all tests (Rust + SDK + web + indexer) 141 + test: rust-test sdk-test web-test indexer-test 142 142 143 143 # Run all lints (Rust + web) 144 144 lint: fmt clippy web-lint web-typecheck ··· 147 147 # Container images 148 148 # --------------------------------------------------------------------------- 149 149 150 - # Build container images (appview + web) 150 + # Build container images (indexer + web) 151 151 images: 152 - docker build -f Containerfile.appview \ 153 - -t {{ registry }}/opake/appview:{{ tag }} \ 154 - -t {{ registry }}/opake/appview:latest . 152 + docker build -f Containerfile.indexer \ 153 + -t {{ registry }}/opake/indexer:{{ tag }} \ 154 + -t {{ registry }}/opake/indexer:latest . 155 155 docker build -f Containerfile.web \ 156 156 -t {{ registry }}/opake/web:{{ tag }} \ 157 157 -t {{ registry }}/opake/web:latest . 158 158 159 159 # Push container images to registry 160 160 push-images: images 161 - docker push {{ registry }}/opake/appview:{{ tag }} 162 - docker push {{ registry }}/opake/appview:latest 161 + docker push {{ registry }}/opake/indexer:{{ tag }} 162 + docker push {{ registry }}/opake/indexer:latest 163 163 docker push {{ registry }}/opake/web:{{ tag }} 164 164 docker push {{ registry }}/opake/web:latest 165 165
+6 -6
lexicons/EXAMPLES.md
··· 108 108 109 109 ## 2b. Directory update (member proposing structural change) 110 110 111 - Non-owner workspace members can't directly modify the owner's directory records. Instead they write `directoryUpdate` proposals to their own PDS. The owner's daemon picks them up via the AppView and applies them. 111 + Non-owner workspace members can't directly modify the owner's directory records. Instead they write `directoryUpdate` proposals to their own PDS. The owner's daemon picks them up via the Indexer and applies them. 112 112 113 113 ```json 114 114 { ··· 185 185 ``` 186 186 187 187 **How Bob decrypts:** 188 - 1. His client/AppView discovers this grant (firehose, query, or notification) 188 + 1. His client/Indexer discovers this grant (firehose, query, or notification) 189 189 2. Fetches the document record via the `document` AT URI 190 190 3. Uses his private key to decrypt `wrappedKey.ciphertext` → gets AES-256 content key 191 191 4. Fetches the blob via `com.atproto.sync.getBlob` ··· 284 284 - Wrap GK to Dave's pubkey with `"role": "editor"` 285 285 - Update the keyring record to add Dave to `members` 286 286 - Dave can now decrypt *all* documents under this keyring. No per-document changes needed. 287 - - The AppView enforces Dave's role — he can propose edits via `documentUpdate` but can't add/remove members. 287 + - The Indexer enforces Dave's role — he can propose edits via `documentUpdate` but can't add/remove members. 288 288 289 289 **Removing a member:** 290 290 - Archive the current rotation's remaining member entries into `keyHistory` ··· 391 391 ``` 392 392 393 393 **How the owner applies it:** 394 - 1. AppView surfaces pending updates via `GET /api/workspace/updates` 394 + 1. Indexer surfaces pending updates via `GET /api/workspace/updates` 395 395 2. Owner's client fetches the update blob from the editor's PDS 396 396 3. Owner re-uploads the blob to their own PDS and updates their document record 397 397 4. Editor's client deletes the `documentUpdate` record after confirmation ··· 420 420 421 421 ## 10. Leaving a workspace 422 422 423 - A member opts out of a workspace by writing a `keyringLeave` record to their own PDS. The AppView stops listing them as a member. 423 + A member opts out of a workspace by writing a `keyringLeave` record to their own PDS. The Indexer stops listing them as a member. 424 424 425 425 ```json 426 426 { ··· 450 450 `keys` array), grants are separate records because: 451 451 - The document owner might not want to update the document record every time they share 452 452 - Grants can be deleted independently (for revocation) 453 - - An AppView can efficiently query "what's shared with me?" across all documents 453 + - An Indexer can efficiently query "what's shared with me?" across all documents 454 454 - It matches the atproto pattern of small, independent records 455 455 456 456 ### Why the two-layer key for keyrings?
+9 -9
lexicons/README.md
··· 109 109 sequenceDiagram 110 110 participant Editor 111 111 participant EditorPDS as Editor's PDS 112 - participant AppView 112 + participant Indexer 113 113 participant Owner 114 114 participant OwnerPDS as Owner's PDS 115 115 ··· 119 119 Editor->>EditorPDS: uploadBlob(new ciphertext) 120 120 Editor->>EditorPDS: createRecord(documentUpdate) 121 121 122 - EditorPDS->>AppView: firehose event 123 - AppView->>AppView: validate editor role, index update 122 + EditorPDS->>Indexer: firehose event 123 + Indexer->>Indexer: validate editor role, index update 124 124 125 125 Note over Owner,OwnerPDS: 2. Owner applies the update 126 - Owner->>AppView: GET /api/workspace/updates 127 - AppView-->>Owner: pending documentUpdate records 126 + Owner->>Indexer: GET /api/workspace/updates 127 + Indexer-->>Owner: pending documentUpdate records 128 128 Owner->>EditorPDS: getBlob(update cid) 129 129 Owner->>OwnerPDS: uploadBlob + putRecord(document) 130 130 ··· 140 140 sequenceDiagram 141 141 participant Member 142 142 participant MemberPDS as Member's PDS 143 - participant AppView 143 + participant Indexer 144 144 145 145 Member->>MemberPDS: createRecord(keyringLeave, { keyring }) 146 - MemberPDS->>AppView: firehose event 147 - AppView->>AppView: remove member from workspace index 148 - Note right of AppView: Workspace disappears from<br/>member's sidebar 146 + MemberPDS->>Indexer: firehose event 147 + Indexer->>Indexer: remove member from workspace index 148 + Note right of Indexer: Workspace disappears from<br/>member's sidebar 149 149 ``` 150 150 151 151 The member's wrapped key still exists on the keyring record — they *could* still decrypt. This is a visibility opt-out, not a key revocation. The owner can follow up with a proper removal (key rotation) if needed.
+2 -2
lexicons/app.opake.accountConfig.json
··· 19 19 "type": "boolean", 20 20 "description": "Whether usage analytics are enabled." 21 21 }, 22 - "appviewUrl": { 22 + "indexerUrl": { 23 23 "type": "string", 24 24 "format": "uri", 25 - "description": "AppView endpoint URL for indexing and search." 25 + "description": "Indexer endpoint URL for discovery, inbox, and live event streaming." 26 26 }, 27 27 "modifiedAt": { 28 28 "type": "string",
+2 -2
lexicons/app.opake.defs.json
··· 29 29 30 30 "keyringMember": { 31 31 "type": "object", 32 - "description": "A keyring member: a wrapped group key paired with the member's workspace role. Role is plaintext because the AppView needs it for authorization decisions.", 32 + "description": "A keyring member: a wrapped group key paired with the member's workspace role. Role is plaintext because the indexer needs it for authorization decisions.", 33 33 "required": ["wrappedKey", "role"], 34 34 "properties": { 35 35 "wrappedKey": { ··· 144 144 145 145 "visibility": { 146 146 "type": "string", 147 - "description": "Hint for AppViews and clients about intended visibility of the content.", 147 + "description": "Hint for indexers and clients about intended visibility of the content.", 148 148 "knownValues": [ 149 149 "private", 150 150 "shared",
+1 -1
lexicons/app.opake.directoryUpdate.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A proposed structural change to a workspace directory. Written by a member to their own PDS when they can't directly modify the owner's directory records. The owner's daemon picks up pending updates via the AppView and applies them.", 7 + "description": "A proposed structural change to a workspace directory. Written by a member to their own PDS when they can't directly modify the owner's directory records. The owner's daemon picks up pending updates via the indexer and applies them.", 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object",
+1 -1
lexicons/app.opake.documentUpdate.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A proposed update to a document owned by another workspace member. Written by an editor to their own PDS. The document owner's client picks up pending updates via the AppView, applies them, and the editor deletes the update record after confirmation.", 7 + "description": "A proposed update to a document owned by another workspace member. Written by an editor to their own PDS. The document owner's client picks up pending updates via the indexer, applies them, and the editor deletes the update record after confirmation.", 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object",
+1 -1
lexicons/app.opake.grant.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A share grant — gives a specific DID access to a specific document's content encryption key. Created by the document owner when sharing a file ad-hoc (outside of a keyring). The recipient discovers this via their AppView or a notification mechanism. To revoke: delete this record and optionally re-encrypt the document with a new key.", 7 + "description": "A share grant — gives a specific DID access to a specific document's content encryption key. Created by the document owner when sharing a file ad-hoc (outside of a keyring). The recipient discovers this via their indexer or a notification mechanism. To revoke: delete this record and optionally re-encrypt the document with a new key.", 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object",
+1 -1
lexicons/app.opake.keyringUpdate.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A proposed change to a workspace keyring. Written by a manager to their own PDS when they can't directly modify the owner's keyring record. The owner's daemon applies pending updates via the AppView.", 7 + "description": "A proposed change to a workspace keyring. Written by a manager to their own PDS when they can't directly modify the owner's keyring record. The owner's daemon applies pending updates via the indexer.", 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object",
+1 -1
lexicons/app.opake.publicKey.json
··· 28 28 "signingKey": { 29 29 "type": "bytes", 30 30 "maxLength": 32, 31 - "description": "Raw Ed25519 signing public key bytes. Used for DID-scoped authentication with the AppView." 31 + "description": "Raw Ed25519 signing public key bytes. Used for DID-scoped authentication with the indexer." 32 32 }, 33 33 "signingAlgo": { 34 34 "type": "string",
+1 -1
packages/opake-daemon/src/scheduler.ts
··· 30 30 * background tasks. Schedules all tasks from the core registry at their 31 31 * configured intervals. 32 32 * 33 - * For live tree updates, pair this with `opake.startSseConsumer(appviewUrl)` 33 + * For live tree updates, pair this with `opake.startSseConsumer(indexerUrl)` 34 34 * and `fileManager.watchDirectory(uri, handler)` — the WASM-side consumer 35 35 * handles SSE events directly and patches trees in place. 36 36 *
+1 -1
packages/opake-daemon/src/tasks.ts
··· 8 8 // drive proposal application via SSE events (see the WASM consumer in 9 9 // `crates/opake-wasm/src/sse_wasm.rs`), and the native CLI daemon runs 10 10 // its own native SSE consumer. There's no fallback polling in either 11 - // track — the proposal store in the appview DB is the backstop if a 11 + // track — the proposal store in the indexer DB is the backstop if a 12 12 // real-time event is missed. 13 13 14 14 import { OpakeError } from "@opake/sdk";
+1 -1
packages/opake-react/src/__tests__/mock-opake.ts
··· 12 12 export interface MockOpake { 13 13 cabinet: Mock<() => Promise<MockFileManager>>; 14 14 workspace: Mock<(keyringUri: string) => Promise<MockFileManager>>; 15 - startSseConsumer: Mock<(appviewUrl?: string) => Promise<void>>; 15 + startSseConsumer: Mock<(indexerUrl?: string) => Promise<void>>; 16 16 stopSseConsumer: Mock<() => void>; 17 17 /** Inspect the last FileManager handed out (for cabinet). */ 18 18 lastCabinetFm: MockFileManager | null;
+1 -1
packages/opake-react/src/hooks/use-directory.ts
··· 51 51 * context; otherwise pass a specific directory at-uri. 52 52 * 53 53 * The returned snapshot reflects the latest state received from the 54 - * WASM TreeKeeper, which applies SSE events from the appview as they 54 + * WASM TreeKeeper, which applies SSE events from the indexer as they 55 55 * arrive. No manual refetch or cache invalidation needed — remote 56 56 * changes appear automatically within a firehose round-trip 57 57 * (typically <1s).
+5 -5
packages/opake-react/src/hooks/use-sse-consumer.ts
··· 18 18 /** 19 19 * Start the WASM SSE consumer imperatively. 20 20 * 21 - * Omit `appviewUrl` to use the URL stored on the Opake instance from 21 + * Omit `indexerUrl` to use the URL stored on the Opake instance from 22 22 * config (recommended). Pass an explicit value to override for 23 23 * instances without stored config. 24 24 * ··· 37 37 * } 38 38 * ``` 39 39 */ 40 - export function useSseConsumer(appviewUrl?: string | null): void { 40 + export function useSseConsumer(indexerUrl?: string | null): void { 41 41 const opake = useOpake(); 42 42 43 43 useEffect(() => { 44 44 // Skip when explicitly nulled — lets callers opt out conditionally 45 45 // (e.g., `useSseConsumer(isAuthenticated ? undefined : null)`) 46 46 // without breaking the rules of hooks. 47 - if (appviewUrl === null) return; 47 + if (indexerUrl === null) return; 48 48 49 - void opake.startSseConsumer(appviewUrl).catch((err: unknown) => { 49 + void opake.startSseConsumer(indexerUrl).catch((err: unknown) => { 50 50 console.warn("[opake-react] startSseConsumer failed:", err); 51 51 }); 52 - }, [opake, appviewUrl]); 52 + }, [opake, indexerUrl]); 53 53 }
+1 -1
packages/opake-react/src/hooks/use-tree.ts
··· 7 7 /** 8 8 * Load a directory tree (cabinet or workspace) as a one-shot query. 9 9 * 10 - * Read-only — loads from cache + AppView sync, no PDS writes. Uses 10 + * Read-only — loads from cache + Indexer sync, no PDS writes. Uses 11 11 * `keepPreviousData` so navigation between directories doesn't flash 12 12 * a loading state when refetching. 13 13 *
+3 -3
packages/opake-react/src/provider.tsx
··· 8 8 // - A refcounted FileManager cache via `useFileManagerCache()` (internal) 9 9 // 10 10 // On mount, the provider calls `opake.startSseConsumer()` unless 11 - // `disableSseAutoStart` is set. This uses the appview URL already 12 - // stored on the Opake instance (from config). No `appviewUrl` prop 11 + // `disableSseAutoStart` is set. This uses the indexer URL already 12 + // stored on the Opake instance (from config). No `indexerUrl` prop 13 13 // required — matches how `requestSseToken`, `listWorkspaces`, etc. 14 14 // resolve the URL internally. 15 15 // ··· 64 64 readonly opake: Opake; 65 65 /** 66 66 * Disable automatic SSE consumer start. Default false: the provider 67 - * calls `opake.startSseConsumer()` on mount, which uses the appview 67 + * calls `opake.startSseConsumer()` on mount, which uses the indexer 68 68 * URL already stored on the Opake instance from `Opake.init()`. Set 69 69 * true for tests, or for consumers that want explicit control via 70 70 * `useSseConsumer` or a manual `opake.startSseConsumer()` call.
+1 -1
packages/opake-sdk/docs/cabinet.md
··· 108 108 ``` 109 109 110 110 `loadTree()` is read-only — it loads from the local cache and syncs deltas 111 - from the AppView, but does not apply proposals or write to the PDS. 111 + from the Indexer, but does not apply proposals or write to the PDS. 112 112 113 113 For the full sync cycle (apply proposals, resolve metadata), use 114 114 `syncAndLoadTree()`:
+1 -1
packages/opake-sdk/docs/errors.md
··· 41 41 | `InvalidRecord` | PDS record doesn't match expected schema | Schema version mismatch or corrupted record | 42 42 | `Storage` | Storage backend failed (IndexedDB error, etc.) | Check storage implementation | 43 43 | `Xrpc` | PDS XRPC call failed | Check PDS connectivity, inspect `.message` for HTTP status | 44 - | `Appview` | AppView API call failed | Check AppView connectivity | 44 + | `Indexer` | Indexer API call failed | Check Indexer connectivity | 45 45 | `AlreadyExists` | Tried to create something that already exists | Check before creating, or handle idempotently | 46 46 | `AmbiguousName` | Multiple documents match a name query | Use AT URIs instead of names | 47 47 | `Serialization` | JSON serialization/deserialization failed | Usually a bug — report it |
+2 -2
packages/opake-sdk/docs/storage.md
··· 7 7 3. **Session** — OAuth tokens, DPoP keys, token endpoints 8 8 9 9 Plus an optional **cache layer** for directory trees and document records 10 - (avoids full AppView re-syncs on every load). 10 + (avoids full Indexer re-syncs on every load). 11 11 12 12 ## Built-in Implementations 13 13 ··· 86 86 ### Cache Methods 87 87 88 88 Cache methods are optional in the sense that returning `null` / no-op is 89 - valid — the SDK will just re-fetch from the AppView on every tree load. 89 + valid — the SDK will just re-fetch from the Indexer on every tree load. 90 90 But implementing them significantly improves performance: 91 91 92 92 | Method | Purpose |
+1 -1
packages/opake-sdk/docs/workspaces.md
··· 113 113 ### For Members 114 114 115 115 Your proposals are cleaned up automatically once the owner applies them. 116 - Call `loadTree()` to see the current state — the AppView tracks what's 116 + Call `loadTree()` to see the current state — the Indexer tracks what's 117 117 been applied. 118 118 119 119 ## Member Management
+2 -2
packages/opake-sdk/src/errors.ts
··· 15 15 | "InvalidRecord" 16 16 | "Storage" 17 17 | "Xrpc" 18 - | "Appview" 18 + | "Indexer" 19 19 | "AlreadyExists" 20 20 | "AmbiguousName" 21 21 | "Serialization" ··· 57 57 "InvalidRecord", 58 58 "Storage", 59 59 "Xrpc", 60 - "Appview", 60 + "Indexer", 61 61 "AlreadyExists", 62 62 "AmbiguousName", 63 63 "Serialization",
+1 -1
packages/opake-sdk/src/file-manager.ts
··· 592 592 * affects the tree. The handler receives `null` when the watched 593 593 * directory is deleted — the watcher auto-closes after that call. 594 594 * 595 - * Must be paired with `opake.startSseConsumer(appviewUrl)` to actually 595 + * Must be paired with `opake.startSseConsumer(indexerUrl)` to actually 596 596 * receive events. Without the consumer, the watcher only fires once 597 597 * with the initial snapshot. 598 598 *
+1 -1
packages/opake-sdk/src/index.ts
··· 69 69 } from "./types"; 70 70 71 71 // Real-time event streaming is WASM-owned: 72 - // - Start the consumer: `opake.startSseConsumer(appviewUrl?)` 72 + // - Start the consumer: `opake.startSseConsumer(indexerUrl?)` 73 73 // - Directory tree updates: `fileManager.watchDirectory(uri, handler)` → `DirectoryWatcher` 74 74 // - Workspace list updates: `opake.watchWorkspaces(handler)` → `WorkspaceWatcher` 75 75 // Both watcher handles are exported above.
+17 -17
packages/opake-sdk/src/opake.ts
··· 408 408 // --------------------------------------------------------------------------- 409 409 410 410 /** 411 - * Override the cached appview URL at runtime. 411 + * Override the cached indexer URL at runtime. 412 412 * 413 413 * `Opake.init` seeds the instance with the compile-time 414 - * `DEFAULT_APPVIEW_URL` baked into the WASM binary. Call this at boot 414 + * `DEFAULT_INDEXER_URL` baked into the WASM binary. Call this at boot 415 415 * to inject a host-specific runtime default (e.g. the web app's 416 - * `VITE_APPVIEW_URL`, which can't be baked in because one WASM binary 416 + * `VITE_INDEXER_URL`, which can't be baked in because one WASM binary 417 417 * is shipped to multiple deployments). 418 418 * 419 - * After this call, methods that resolve the appview URL internally 419 + * After this call, methods that resolve the indexer URL internally 420 420 * (`listWorkspaces`, `startSseConsumer`, etc.) pick up the new value 421 421 * automatically — JS callers don't pass the URL at the call site. 422 422 * 423 423 * Writes to `accountConfig` on the PDS still override this value, so 424 - * a user-configured appview (from settings) wins over the host default. 424 + * a user-configured indexer (from settings) wins over the host default. 425 425 */ 426 426 @wrapWasmErrors 427 - async setAppviewUrl(url: string): Promise<void> { 428 - await this.requireContext().setAppviewUrl(url); 427 + async setIndexerUrl(url: string): Promise<void> { 428 + await this.requireContext().setIndexerUrl(url); 429 429 } 430 430 431 431 /** ··· 585 585 * Also bootstraps the in-memory `WorkspaceKeeper` — `watchWorkspaces` 586 586 * callers see a fresh snapshot with `loaded = true` as a side effect. 587 587 * 588 - * The appview URL is resolved internally from the stored config 589 - * (set during `init` and overridable via `setAppviewUrl` or 588 + * The indexer URL is resolved internally from the stored config 589 + * (set during `init` and overridable via `setIndexerUrl` or 590 590 * by writing an `accountConfig` record). Callers do not pass it. 591 591 * 592 592 * @returns Array of workspace entries with decrypted names and roles. ··· 743 743 * 744 744 * Tri-state semantics (see `AccountConfigPatch`): 745 745 * - absent key / `undefined` → field unchanged on the PDS. 746 - * - `null` (`appviewUrl` only) → field cleared on the PDS. 746 + * - `null` (`indexerUrl` only) → field cleared on the PDS. 747 747 * - concrete value → field updated to that value. 748 748 * 749 749 * @returns The freshly-written record. ··· 759 759 if (updates.telemetryEnabled !== undefined) { 760 760 patch.telemetryEnabled = updates.telemetryEnabled; 761 761 } 762 - if (updates.appviewUrl !== undefined) { 762 + if (updates.indexerUrl !== undefined) { 763 763 // string or explicit null — both forwarded; Rust interprets null as clear. 764 - patch.appviewUrl = updates.appviewUrl; 764 + patch.indexerUrl = updates.indexerUrl; 765 765 } 766 766 const record = await ctx.updateAccountConfig(patch); 767 767 return record as AccountConfig; ··· 774 774 /** 775 775 * Start the WASM-level SSE consumer. 776 776 * 777 - * Spawns a background task inside WASM that connects to the appview's 777 + * Spawns a background task inside WASM that connects to the indexer's 778 778 * `/api/events` endpoint, pulls events, and applies them to any 779 779 * installed directory trees via the Rust-side `TreeKeeper`. Once 780 780 * started, `FileManager.watchDirectory` handlers fire automatically ··· 784 784 * snapshots cross into JS. Idempotent: safe to call multiple times 785 785 * (StrictMode double-mount is handled internally). 786 786 * 787 - * `appviewUrl` is optional: if omitted, the URL is resolved from the 787 + * `indexerUrl` is optional: if omitted, the URL is resolved from the 788 788 * Opake instance's stored config (loaded during `init`). Pass an 789 789 * explicit value as a fallback for instances without stored config. 790 790 */ 791 791 @wrapWasmErrors 792 - startSseConsumer(appviewUrl?: string): Promise<void> { 793 - return this.requireContext().startSseConsumer(appviewUrl ?? null); 792 + startSseConsumer(indexerUrl?: string): Promise<void> { 793 + return this.requireContext().startSseConsumer(indexerUrl ?? null); 794 794 } 795 795 796 796 /** ··· 907 907 // --------------------------------------------------------------------------- 908 908 909 909 /** 910 - * Fetch every incoming grant from the AppView. 910 + * Fetch every incoming grant from the Indexer. 911 911 * 912 912 * Also bootstraps the in-memory `InboxKeeper` — any current or future 913 913 * `watchInbox` callers receive a fresh snapshot with `loaded = true`.
+1 -1
packages/opake-sdk/src/schemas.ts
··· 238 238 export const grantEntriesSchema = z.array(grantEntrySchema); 239 239 240 240 /** 241 - * An incoming grant indexed by the AppView. Fields are snake_case on 241 + * An incoming grant indexed by the Indexer. Fields are snake_case on 242 242 * the wire (serde) and get camelCased here. 243 243 */ 244 244 export const inboxGrantSchema = z
+7 -7
packages/opake-sdk/src/types.ts
··· 12 12 export interface AccountConfig { 13 13 readonly opakeVersion: number; 14 14 readonly telemetryEnabled: boolean; 15 - /** Override the default appview. Absent means the built-in default is active. */ 16 - readonly appviewUrl?: string; 15 + /** Override the default indexer. Absent means the built-in default is active. */ 16 + readonly indexerUrl?: string; 17 17 /** ISO-8601 timestamp of last write. */ 18 18 readonly modifiedAt: string; 19 19 } ··· 23 23 * 24 24 * Tri-state semantics per field: 25 25 * - Key absent / `undefined`: field is unchanged on the PDS. 26 - * - Explicit `null` (for `appviewUrl`): field is cleared on the PDS. 26 + * - Explicit `null` (for `indexerUrl`): field is cleared on the PDS. 27 27 * - Concrete value: field is updated to that value. 28 28 * 29 29 * This avoids the footgun in `Partial<AccountConfig>` where 30 - * `{ appviewUrl: undefined }` is indistinguishable from an absent key 30 + * `{ indexerUrl: undefined }` is indistinguishable from an absent key 31 31 * at runtime, so passing `undefined` would silently clear the stored URL. 32 32 */ 33 33 export interface AccountConfigPatch { 34 34 /** Set or leave `telemetryEnabled` unchanged. */ 35 35 readonly telemetryEnabled?: boolean; 36 36 /** 37 - * `string` — set a new appview URL. 37 + * `string` — set a new indexer URL. 38 38 * `null` — explicitly clear the stored override (use the built-in default). 39 39 * absent — leave the current value untouched. 40 40 */ 41 - readonly appviewUrl?: string | null; 41 + readonly indexerUrl?: string | null; 42 42 } 43 43 44 44 /** Result of a mutation that may be applied directly or proposed for owner approval. */ ··· 188 188 readonly expiresAt: string | null; 189 189 } 190 190 191 - /** An incoming grant as indexed by the AppView (shared-with-me). */ 191 + /** An incoming grant as indexed by the Indexer (shared-with-me). */ 192 192 export interface InboxGrant { 193 193 readonly uri: string; 194 194 readonly ownerDid: string;
+1 -1
packages/opake-sdk/tests/errors.test.ts
··· 43 43 "InvalidRecord", 44 44 "Storage", 45 45 "Xrpc", 46 - "Appview", 46 + "Indexer", 47 47 "AlreadyExists", 48 48 "AmbiguousName", 49 49 "Serialization",
+26 -26
slumber.yml
··· 7 7 # 4. Fire `keyrings` → workspace recipes prompt you to pick one 8 8 # 9 9 # Auth helpers (pip install cryptography tomli): 10 - # tools/appview-auth.py — signs Opake-Ed25519 headers for appview endpoints 10 + # tools/indexer-auth.py — signs Opake-Ed25519 headers for indexer endpoints 11 11 # tools/xrpc-auth.py — generates DPoP proof JWTs for XRPC/PDS endpoints 12 12 # 13 - # Appview auth reads default_did from ~/.config/opake/config.toml. 13 + # Indexer auth reads default_did from ~/.config/opake/config.toml. 14 14 # XRPC auth reads the OAuth session for the selected account. 15 15 16 16 profiles: ··· 18 18 name: Development 19 19 default: true 20 20 data: 21 - appview: "http://localhost:6100" 21 + indexer: "http://localhost:6100" 22 22 # interactive account select — only triggered when firing resolve_handle 23 23 account: '{{select([{"label": "sans-self.org", "value": "sans-self.org"}, {"label": "annoiiyed.bsky.social", "value": "annoiiyed.bsky.social"}], message="Account")}}' 24 24 # chained resolution: resolve_handle → resolve_did → PDS ··· 29 29 production: 30 30 name: Production 31 31 data: 32 - appview: "https://appview.opake.app" 32 + indexer: "https://indexer.opake.app" 33 33 account: '{{select([{"label": "sans-self.org", "value": "sans-self.org"}, {"label": "annoiiyed.bsky.social", "value": "annoiiyed.bsky.social"}], message="Account")}}' 34 34 did: "{{response('resolve_handle') | json_parse() | jq('.did')}}" 35 35 pds: "{{response('resolve_did') | json_parse() | jq('.service[] | select(.type == \"AtprotoPersonalDataServer\") | .serviceEndpoint')}}" ··· 38 38 requests: 39 39 40 40 # ════════════════════════════════════════════════════════════════════════════ 41 - # AppView — Opake-Ed25519 authenticated 41 + # Indexer — Opake-Ed25519 authenticated 42 42 # ════════════════════════════════════════════════════════════════════════════ 43 43 44 - appview: 45 - name: AppView 44 + indexer: 45 + name: Indexer 46 46 requests: 47 47 48 48 health: 49 49 name: Health Check 50 50 method: GET 51 - url: "{{appview}}/api/health" 51 + url: "{{indexer}}/api/health" 52 52 53 53 inbox: 54 54 name: Inbox (grants shared with you) 55 55 method: GET 56 - url: "{{appview}}/api/inbox" 56 + url: "{{indexer}}/api/inbox" 57 57 query: 58 58 limit: "50" 59 59 headers: 60 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/inbox'])}}" 60 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/inbox'])}}" 61 61 62 62 keyrings: 63 63 name: Keyrings (your memberships) 64 64 method: GET 65 - url: "{{appview}}/api/keyrings" 65 + url: "{{indexer}}/api/keyrings" 66 66 query: 67 67 limit: "50" 68 68 headers: 69 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/keyrings'])}}" 69 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/keyrings'])}}" 70 70 71 71 # ── Cabinet (personal file tree) ────────────────────────────────────── 72 72 73 73 cabinet_snapshot: 74 74 name: Cabinet Snapshot 75 75 method: GET 76 - url: "{{appview}}/api/cabinet/snapshot" 76 + url: "{{indexer}}/api/cabinet/snapshot" 77 77 headers: 78 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/cabinet/snapshot'])}}" 78 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/cabinet/snapshot'])}}" 79 79 80 80 cabinet_sync: 81 81 name: Cabinet Sync (since timestamp) 82 82 method: GET 83 - url: "{{appview}}/api/cabinet/sync" 83 + url: "{{indexer}}/api/cabinet/sync" 84 84 query: 85 85 since: "2026-01-01T00:00:00Z" 86 86 headers: 87 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/cabinet/sync'])}}" 87 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/cabinet/sync'])}}" 88 88 89 89 # ── Workspace (shared keyring-scoped) ───────────────────────────────── 90 90 91 91 workspace_snapshot: 92 92 name: Workspace Snapshot 93 93 method: GET 94 - url: "{{appview}}/api/workspace/snapshot" 94 + url: "{{indexer}}/api/workspace/snapshot" 95 95 query: 96 96 keyring: "{{keyring_uri}}" 97 97 headers: 98 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/workspace/snapshot'])}}" 98 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/workspace/snapshot'])}}" 99 99 100 100 workspace_sync: 101 101 name: Workspace Sync (since timestamp) 102 102 method: GET 103 - url: "{{appview}}/api/workspace/sync" 103 + url: "{{indexer}}/api/workspace/sync" 104 104 query: 105 105 keyring: "{{keyring_uri}}" 106 106 since: "2026-01-01T00:00:00Z" 107 107 headers: 108 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/workspace/sync'])}}" 108 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/workspace/sync'])}}" 109 109 110 110 workspace_documents: 111 111 name: Workspace Documents 112 112 method: GET 113 - url: "{{appview}}/api/workspace" 113 + url: "{{indexer}}/api/workspace" 114 114 query: 115 115 keyring: "{{keyring_uri}}" 116 116 headers: 117 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/workspace'])}}" 117 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/workspace'])}}" 118 118 119 119 workspace_updates: 120 120 name: Workspace Document Updates 121 121 method: GET 122 - url: "{{appview}}/api/workspace/updates" 122 + url: "{{indexer}}/api/workspace/updates" 123 123 query: 124 124 limit: "50" 125 125 headers: 126 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/workspace/updates'])}}" 126 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/workspace/updates'])}}" 127 127 128 128 workspace_directory_updates: 129 129 name: Workspace Directory Updates 130 130 method: GET 131 - url: "{{appview}}/api/workspace/directory-updates" 131 + url: "{{indexer}}/api/workspace/directory-updates" 132 132 query: 133 133 keyring: "{{keyring_uri}}" 134 134 limit: "50" 135 135 headers: 136 - Authorization: "{{command(['python3', 'tools/appview-auth.py', 'GET', '/api/workspace/directory-updates'])}}" 136 + Authorization: "{{command(['python3', 'tools/indexer-auth.py', 'GET', '/api/workspace/directory-updates'])}}" 137 137 138 138 # ════════════════════════════════════════════════════════════════════════════ 139 139 # XRPC — PDS endpoints with OAuth DPoP
+1 -1
tests/global-setup.ts
··· 63 63 ...process.env, 64 64 VITE_RESOLVE_API: pds.url, 65 65 VITE_PLC_DIRECTORY_URL: pds.url, 66 - VITE_APPVIEW_URL: "", 66 + VITE_INDEXER_URL: "", 67 67 }, 68 68 stdio: ["pipe", "pipe", "pipe"], 69 69 });
+7 -7
tests/tests/cli/config.test.ts
··· 22 22 expect(show.stdout).toContain("enabled"); 23 23 }); 24 24 25 - it("sets appview-url", async () => { 26 - const set = await fx.opake(["config", "set", "appview-url", "https://appview.test"]); 25 + it("sets indexer-url", async () => { 26 + const set = await fx.opake(["config", "set", "indexer-url", "https://indexer.test"]); 27 27 expect(set.code).toBe(0); 28 - expect(set.stdout).toContain("appview.test"); 28 + expect(set.stdout).toContain("indexer.test"); 29 29 30 30 const show = await fx.opake(["config"]); 31 - expect(show.stdout).toContain("appview.test"); 31 + expect(show.stdout).toContain("indexer.test"); 32 32 }); 33 33 34 34 it("rejects unknown config key", async () => { ··· 38 38 expect(result.stderr).toContain("telemetry-enabled"); 39 39 }); 40 40 41 - it("clears appview-url with empty string", async () => { 42 - await fx.opake(["config", "set", "appview-url", "https://will-be-cleared.test"]); 43 - const clear = await fx.opake(["config", "set", "appview-url", ""]); 41 + it("clears indexer-url with empty string", async () => { 42 + await fx.opake(["config", "set", "indexer-url", "https://will-be-cleared.test"]); 43 + const clear = await fx.opake(["config", "set", "indexer-url", ""]); 44 44 expect(clear.code).toBe(0); 45 45 expect(clear.stdout).toContain("(not set)"); 46 46 });
+5 -5
tests/tests/web/settings.test.ts
··· 27 27 timeout: 10_000, 28 28 }); 29 29 await expect(page.getByLabel("Enable telemetry")).toBeVisible(); 30 - await expect(page.getByLabel("AppView URL")).toBeVisible(); 30 + await expect(page.getByLabel("Indexer URL")).toBeVisible(); 31 31 }); 32 32 33 33 test("telemetry toggle changes state", async ({ page, webUrl }) => { ··· 42 42 await expect(toggle).toBeChecked({ checked: !initialState }); 43 43 }); 44 44 45 - test("save button disabled when appview URL unchanged", async ({ 45 + test("save button disabled when indexer URL unchanged", async ({ 46 46 page, 47 47 webUrl, 48 48 }) => { 49 49 await page.goto(`${webUrl}/cabinet/settings`); 50 50 51 - await expect(page.getByLabel("AppView URL")).toBeVisible({ 51 + await expect(page.getByLabel("Indexer URL")).toBeVisible({ 52 52 timeout: 10_000, 53 53 }); 54 54 ··· 56 56 await expect(saveButton).toBeDisabled(); 57 57 }); 58 58 59 - test("save button enabled after changing appview URL", async ({ 59 + test("save button enabled after changing indexer URL", async ({ 60 60 page, 61 61 webUrl, 62 62 }) => { 63 63 await page.goto(`${webUrl}/cabinet/settings`); 64 64 65 - const input = page.getByLabel("AppView URL"); 65 + const input = page.getByLabel("Indexer URL"); 66 66 await expect(input).toBeVisible({ timeout: 10_000 }); 67 67 68 68 await input.fill("http://localhost:9999");
-101
tools/appview-auth.py
··· 1 - #!/usr/bin/env python3 2 - """Generate an Opake-Ed25519 Authorization header for the appview. 3 - 4 - Usage: 5 - ./tools/appview-auth.py <METHOD> <PATH> 6 - 7 - Reads the signing key from the active opake identity. The DID is taken from 8 - the opake config (default_did), or overridden via OPAKE_DID env var. 9 - 10 - Outputs the full header value to stdout, ready for Slumber's command() template. 11 - 12 - Requires: pip install cryptography 13 - """ 14 - 15 - import base64 16 - import json 17 - import os 18 - import sys 19 - import time 20 - from pathlib import Path 21 - 22 - try: 23 - import tomllib 24 - except ModuleNotFoundError: 25 - import tomli as tomllib # python < 3.11 26 - 27 - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 28 - 29 - 30 - def opake_data_dir() -> Path: 31 - if d := os.environ.get("OPAKE_DATA_DIR"): 32 - return Path(d) 33 - if xdg := os.environ.get("XDG_CONFIG_HOME"): 34 - return Path(xdg) / "opake" 35 - return Path.home() / ".config" / "opake" 36 - 37 - 38 - def sanitize_did(did: str) -> str: 39 - return did.replace(":", "_") 40 - 41 - 42 - def resolve_did(data_dir: Path) -> str: 43 - if did := os.environ.get("OPAKE_DID"): 44 - return did 45 - 46 - config_path = data_dir / "config.toml" 47 - if not config_path.exists(): 48 - print(f"error: no config at {config_path} — set OPAKE_DID", file=sys.stderr) 49 - sys.exit(1) 50 - 51 - config = tomllib.loads(config_path.read_text()) 52 - did = config.get("default_did") 53 - if not did: 54 - print("error: no default_did in config — set OPAKE_DID", file=sys.stderr) 55 - sys.exit(1) 56 - return did 57 - 58 - 59 - def load_signing_key(data_dir: Path, did: str) -> Ed25519PrivateKey: 60 - identity_path = data_dir / "accounts" / sanitize_did(did) / "identity.json" 61 - if not identity_path.exists(): 62 - print(f"error: no identity at {identity_path}", file=sys.stderr) 63 - sys.exit(1) 64 - 65 - identity = json.loads(identity_path.read_text()) 66 - signing_key_b64 = identity.get("signing_key") 67 - if not signing_key_b64: 68 - print("error: identity has no signing_key — run `opake recover` to upgrade", file=sys.stderr) 69 - sys.exit(1) 70 - 71 - key_bytes = base64.b64decode(signing_key_b64) 72 - return Ed25519PrivateKey.from_private_bytes(key_bytes) 73 - 74 - 75 - def sign_request(method: str, path: str, did: str, key: Ed25519PrivateKey) -> str: 76 - timestamp = int(time.time()) 77 - message = f"{method}:{path}:{timestamp}:{did}" 78 - signature = key.sign(message.encode()) 79 - sig_b64 = base64.b64encode(signature).decode() 80 - return f"Opake-Ed25519 {did}:{timestamp}:{sig_b64}" 81 - 82 - 83 - def main(): 84 - if len(sys.argv) != 3: 85 - print(f"usage: {sys.argv[0]} <METHOD> <PATH>", file=sys.stderr) 86 - sys.exit(1) 87 - 88 - method = sys.argv[1].upper() 89 - path = sys.argv[2] 90 - 91 - data_dir = opake_data_dir() 92 - did = resolve_did(data_dir) 93 - key = load_signing_key(data_dir, did) 94 - header = sign_request(method, path, did, key) 95 - 96 - # stdout only — slumber captures this 97 - print(header, end="") 98 - 99 - 100 - if __name__ == "__main__": 101 - main()
+101
tools/indexer-auth.py
··· 1 + #!/usr/bin/env python3 2 + """Generate an Opake-Ed25519 Authorization header for the indexer. 3 + 4 + Usage: 5 + ./tools/indexer-auth.py <METHOD> <PATH> 6 + 7 + Reads the signing key from the active opake identity. The DID is taken from 8 + the opake config (default_did), or overridden via OPAKE_DID env var. 9 + 10 + Outputs the full header value to stdout, ready for Slumber's command() template. 11 + 12 + Requires: pip install cryptography 13 + """ 14 + 15 + import base64 16 + import json 17 + import os 18 + import sys 19 + import time 20 + from pathlib import Path 21 + 22 + try: 23 + import tomllib 24 + except ModuleNotFoundError: 25 + import tomli as tomllib # python < 3.11 26 + 27 + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 28 + 29 + 30 + def opake_data_dir() -> Path: 31 + if d := os.environ.get("OPAKE_DATA_DIR"): 32 + return Path(d) 33 + if xdg := os.environ.get("XDG_CONFIG_HOME"): 34 + return Path(xdg) / "opake" 35 + return Path.home() / ".config" / "opake" 36 + 37 + 38 + def sanitize_did(did: str) -> str: 39 + return did.replace(":", "_") 40 + 41 + 42 + def resolve_did(data_dir: Path) -> str: 43 + if did := os.environ.get("OPAKE_DID"): 44 + return did 45 + 46 + config_path = data_dir / "config.toml" 47 + if not config_path.exists(): 48 + print(f"error: no config at {config_path} — set OPAKE_DID", file=sys.stderr) 49 + sys.exit(1) 50 + 51 + config = tomllib.loads(config_path.read_text()) 52 + did = config.get("default_did") 53 + if not did: 54 + print("error: no default_did in config — set OPAKE_DID", file=sys.stderr) 55 + sys.exit(1) 56 + return did 57 + 58 + 59 + def load_signing_key(data_dir: Path, did: str) -> Ed25519PrivateKey: 60 + identity_path = data_dir / "accounts" / sanitize_did(did) / "identity.json" 61 + if not identity_path.exists(): 62 + print(f"error: no identity at {identity_path}", file=sys.stderr) 63 + sys.exit(1) 64 + 65 + identity = json.loads(identity_path.read_text()) 66 + signing_key_b64 = identity.get("signing_key") 67 + if not signing_key_b64: 68 + print("error: identity has no signing_key — run `opake recover` to upgrade", file=sys.stderr) 69 + sys.exit(1) 70 + 71 + key_bytes = base64.b64decode(signing_key_b64) 72 + return Ed25519PrivateKey.from_private_bytes(key_bytes) 73 + 74 + 75 + def sign_request(method: str, path: str, did: str, key: Ed25519PrivateKey) -> str: 76 + timestamp = int(time.time()) 77 + message = f"{method}:{path}:{timestamp}:{did}" 78 + signature = key.sign(message.encode()) 79 + sig_b64 = base64.b64encode(signature).decode() 80 + return f"Opake-Ed25519 {did}:{timestamp}:{sig_b64}" 81 + 82 + 83 + def main(): 84 + if len(sys.argv) != 3: 85 + print(f"usage: {sys.argv[0]} <METHOD> <PATH>", file=sys.stderr) 86 + sys.exit(1) 87 + 88 + method = sys.argv[1].upper() 89 + path = sys.argv[2] 90 + 91 + data_dir = opake_data_dir() 92 + did = resolve_did(data_dir) 93 + key = load_signing_key(data_dir, did) 94 + header = sign_request(method, path, did, key) 95 + 96 + # stdout only — slumber captures this 97 + print(header, end="") 98 + 99 + 100 + if __name__ == "__main__": 101 + main()