diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a7d5de6 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,391 @@ +# Rove Architecture + +## Stack decisions and rational +* Go backend + * Familiarity + 80/20 perf:cost to implement + * SQLite-backed AppView + * cost/simplicity at low scale + * easy to migrate later + * Modular monolith design + * Ease of deploy + * Can break apart later into separate api/backend/ingestor services + * BFF auth model — Rove backend is the OAuth client for all surfaces; PDS tokens never leave the server +* React Native (Expo) for iOS + Android + * Write once, ship to both stores +* Separate web codebase (Vite + React + TanStack Router) + * Shared TS-only packages cover ~25–35% of frontend code (API client, domain logic, data-fetching hooks) + * UI components, styling, navigation, and maps diverge by design — letting each surface be best-in-class for its medium + * SEO / link-preview HTML served by the Go backend for public URLs (activity pages, profiles); the SPA hydrates on top + +## Data flow + +PDS holds canonical records. Jetstream is the JSON firehose. The Go backend is the AppView (queryable index) and the OAuth client for both surfaces — all PDS interactions flow through it. The four core flows: + +### 1. Login (BFF — backend is the OAuth client) + +```mermaid +sequenceDiagram + participant Client as RN / Web client + participant BE as Rove backend + participant PDS as User's PDS + + Client->>BE: POST /auth/login {handle} + Note over BE: resolve handle → DID → PDS
discover PDS auth server + BE-->>Client: authorize URL (state + PKCE challenge) + Client->>PDS: redirect to /authorize + Note over Client,PDS: user authenticates +
grants scopes to rove.social + PDS-->>Client: redirect → BE /auth/callback?code=... + Client->>BE: GET /auth/callback?code=... + BE->>PDS: POST /token
(private_key_jwt + PKCE verifier) + PDS-->>BE: access + refresh tokens (DPoP-bound) + Note over BE: encrypt tokens, store in sessions.db
create user session + BE-->>Client: session cookie (web) /
session token (mobile) +``` + +Tokens never leave the backend. The client only ever sees a Rove session. + +### 2. User submits a post + +```mermaid +sequenceDiagram + participant Client as RN client + participant BE as Rove backend + participant PDS as User's PDS + + Client->>BE: POST /activities
(GPX, photos, metadata) + Note over BE: validate session
compute metrics
trim privacy zones
render static map + BE->>PDS: com.atproto.repo.uploadBlob
(GPX, photos, static map) + PDS-->>BE: blob CIDs + BE->>PDS: com.atproto.repo.createRecord
(canonical social.rove.activity) + PDS-->>BE: record URI + CID + BE-->>Client: activity URI + Note over PDS: emits firehose event
→ ingest worker indexes into appview.db +``` + +No proxy header, no service-auth lxm dance — the backend writes to the PDS directly using the user's stored OAuth tokens. Likes/follows/comments take the same shape, just smaller payloads. + +### 3. Post lands in the AppView index + +```mermaid +sequenceDiagram + participant PDS as User's PDS + participant JS as Jetstream + participant BE as Rove backend (ingest worker) + participant DB as SQLite + + PDS->>JS: commit event (firehose) + JS->>BE: filtered event (collection: social.rove.*) + Note over BE: validate record schema
extract indexable fields + BE->>DB: INSERT activity row + advance cursor +``` + +This runs continuously as a separate goroutine in the Go monolith. Lag from PDS commit to indexed row is ~1s. + +### 4. User views their feed + +```mermaid +sequenceDiagram + participant Client as RN / Web client + participant BE as Rove backend + participant DB as SQLite + + Client->>BE: GET social.rove.feed.getActivityFeed
(Rove session cookie / token) + Note over BE: validate session
resolve viewer DID + BE->>DB: SELECT activities for viewer's follows
ORDER BY indexedAt DESC + DB-->>BE: rows + BE-->>Client: feed page (records + cursor) + Note over Client: hydrate & render +``` + +Feed reads never touch the PDS — entirely served from `appview.db`. + +## Reads + +All feed/profile/activity queries hit the Go backend's SQLite index. The backend updates SQLite by consuming Jetstream filtered to `social.rove.*`. Lag is ~1s. + +## Auth flow + +**Rove backend is the OAuth client for both surfaces (BFF — Backend for Frontend).** Mobile and web clients authenticate to the Rove backend; the backend authenticates to the user's PDS. PDS access tokens never live on user devices. + +### Why BFF for both surfaces + +The OAuth spec explicitly endorses BFF for mobile + web combinations: + +> *"It is acceptable for a web service to act as a public client, and conversely it is possible for mobile apps and browser apps to coordinate with a token-mediating backend service and for the combination to form a confidential client. Mobile apps and browser apps can also adopt a 'backend-for-frontend' (BFF) architecture with a web service backend acting as the OAuth client."* — [atproto.com/specs/oauth](https://atproto.com/specs/oauth) + +Tangled (an ATProto-native git platform) runs this pattern on web today via a Caddy-based identity-aware proxy issuing signed session cookies. + +The backend-write requirement decides it for Rove: + +> Activity records need server-side computation before they're written — metric derivation, privacy-zone trimming, static map generation. The realistic options are (a) backend is the OAuth client and writes directly (BFF), (b) the `atproto-proxy` header + service-auth dance (constrained by `lxm`-binding), or (c) a client-writes-back hop after the backend prepares the record. (a) is cleanest; (b) and (c) are operationally clunkier. + +Concrete benefits of BFF on both surfaces: + +- **Spec-supported confidential session lifetimes.** Confidential clients get **180-day refresh tokens and unlimited overall sessions**; public clients are capped at 2 weeks for both. Our users won't get bumped to login every fortnight. +- **Single OAuth grant per user.** One consent screen, one client metadata document, one set of scopes. +- **Same auth code path for mobile and web.** Solo-dev win. +- **DPoP keys on the backend.** Spec only requires "tokens must not be shared or reused across client devices" — per-session backend keys satisfy that. +- **Tokens never leave the backend.** Fewer rotation/revocation edge cases. + +The cost: every PDS interaction flows through the backend. At Rove's scale that's fine — the AppView already serves all hot reads from local SQLite. + +This is an auth-architecture choice, not a data-architecture one. Records still live on the user's PDS; lexicons are still public; the data is still ATProto-native and portable. Only the credential-handling pattern changes — the user can revoke our OAuth grant from their PDS at any time. + +### OAuth client setup + +| Item | Value / location | +|---|---| +| `client_id` | `https://rove.social/oauth/client-metadata.json` (the URL itself is the ID) | +| `token_endpoint_auth_method` | `private_key_jwt` | +| Signing key (private, ES256) | Fly secret → env var on backend | +| JWKS (public) | `https://rove.social/.well-known/jwks.json` | +| DPoP keys | Generated per user, stored alongside their tokens | +| Scopes | Granular per-NSID — see below | + +`private_key_jwt` is how the backend proves its identity to the PDS token endpoint without a shared `client_secret`: it signs a short-lived JWT assertion with its private key on every token request, and the PDS verifies the signature via the public JWKS. + +### Scopes & permission set + +ATProto OAuth uses granular per-NSID scopes. Per the [permission spec](https://atproto.com/specs/permission), partial wildcards are **not** supported (no `repo:social.rove.*`), so each collection has to be enumerated. Rove's scope string: + +``` +atproto +include:social.rove.authBasicFeatures +``` + +Where `social.rove.authBasicFeatures` is a **permission-set Lexicon** we publish under our own NSID. Resolved permissions: + +- `repo:social.rove.activity?action=create&action=update&action=delete` — manage activity records +- `repo:social.rove.activity.like?action=create&action=delete` — manage likes +- `blob:application/gpx+xml`, `blob:image/jpeg`, `blob:image/png` — upload activity media + +We deliberately do **not** request: + +- `repo:app.bsky.graph.follow` — we only *read* the user's bsky follow graph from the firehose, never create follow records on their behalf. +- Any `rpc:` scopes — those grant the user's PDS permission to forward calls to a third-party service via the `atproto-proxy` header. In BFF, our clients call our backend directly with a Rove session, and our backend calls the user's PDS directly with OAuth tokens. No proxy header in the request path → no `rpc:` needed. + +Permission sets are themselves Lexicons (type: `permission-set`) with a human-readable `title` and `detail`, so the consent screen reads "Rove wants permission to: post activities, like activities, upload activity media" instead of a raw scope list. We publish the permission-set Lexicon once and reference it via `include:` in our client metadata's `scope` field. + +### Token storage — two SQLite files + +| File | Contents | Backed up? | Encryption | +|---|---|---|---| +| `appview.db` | Indexed activities, follow graph cache, derived metrics, ingest cursor — reconstructible from the firehose | Yes (snapshots + Litestream → Tigris) | None — content is public anyway | +| `sessions.db` | OAuth access + refresh tokens, DPoP keys, browser session rows | Yes (snapshots + Litestream → Tigris) | App-level (libsodium / age) before insert; key in Fly secret | + +**Why two files:** +- Clean backup and concurrency boundaries — the auth DB is small and write-heavy on login/refresh; the AppView DB is large and read-heavy. +- Easy to swap durability strategies independently later (e.g. take `sessions.db` out of Litestream if you ever want to). +- Obvious separation of "encrypted-at-rest matters" from "doesn't really." + +**Why the auth DB is still in Litestream backups:** losing all sessions on a disaster restore means every user has to re-auth — much worse than the marginal risk of an extra encrypted blob in Tigris. Layered defense: Tigris encrypts at rest, *and* the token rows are encrypted at the app layer with a key that lives in Fly secrets, not in either DB. An attacker who stole the bucket without the key gets nothing. + +**Crash recovery:** SQLite WAL replays cleanly on restart. The one sharp edge is in-flight refresh-token rotation — if the server crashes after the PDS issued a new refresh token but before the row commits, the user gets bumped to login on next request. Annoying but rare and recoverable; "force re-login" is a path we want anyway for security. + +**Refresh-token concurrency:** the OAuth spec says refresh tokens are *single-use* and *"client implementations may need locking primitives to prevent concurrent token refresh requests."* Two requests from the same user racing to refresh will conflict — one wins, one fails and gets the user logged out. We use [`golang.org/x/sync/singleflight`](https://pkg.go.dev/golang.org/x/sync/singleflight) keyed by user DID: the first caller performs the refresh, all concurrent callers share its result. Cleaner than a per-DID mutex — no wasted second refresh after the first wins, and all callers receive the new tokens directly. + +### Session model on the client + +- **Web** — HTTP-only signed cookie, CSRF tokens for mutations, sliding expiry. +- **Mobile** — long-lived session token issued by the Rove backend, stored in Keychain (iOS) / Keystore (Android), refreshed on use. +- Neither client ever sees PDS tokens. + +## Backend API surface (XRPC) + +Lexicons under `social.rove.*` define our endpoints; the Go backend serves them XRPC-style at `https://rove.social/xrpc/`. Examples: + +| Endpoint | Type | Description | +|---|---|---| +| `social.rove.feed.getActivityFeed` | query | Indexed feed, paginated by cursor | +| `social.rove.feed.getActivity` | query | Single activity by URI | +| `social.rove.actor.getProfile` | query | Profile + activity stats | +| `social.rove.activity.create` | procedure | Submit activity (raw GPX, photos, metadata) | +| `social.rove.activity.delete` | procedure | Delete activity | + +**Lexicon-first**: define each schema as a Lexicon JSON file under `lexicons/`, then run `lexgen` (from `bluesky-social/indigo`) to generate Go server stubs and `@atproto/lex-cli` to generate TypeScript client types for the shared `packages/api`. One source of truth, both ends type-safe. + +**Auth on these endpoints**: Rove session (cookie or token), not OAuth bearer. XRPC's standard auth is OAuth, but in BFF the client doesn't have a PDS token — only a Rove session — so our endpoints check session validity instead. If we ever expose endpoints externally to other ATProto services, we'd add bearer/service-auth support alongside. + +### Future: backend-issued service auth for direct blob upload + +For the Submit-a-post flow, the client currently sends raw GPX/photos to our backend, which uploads them to the user's PDS — a double-hop on bandwidth. The optimization, when bandwidth becomes an issue: backend mints a short-lived service-auth JWT via `com.atproto.server.getServiceAuth` bound to `lxm = com.atproto.repo.uploadBlob`, hands it to the client, client uploads blobs directly to PDS, then sends the resulting blob CIDs back to the backend for record assembly. Defer until measured bandwidth pain. + +## Frontend split + +Two codebases (mobile + web) with shared TS-only packages: + +``` +rove/ +├── packages/ +│ ├── api/ # lexicon-generated types, XRPC client wrappers +│ ├── core/ # domain types, business logic, formatters, validators +│ └── hooks/ # TanStack Query hooks; no platform imports +├── apps/ +│ ├── mobile/ # Expo (iOS + Android) +│ └── web/ # Vite + React + TanStack Router +``` + +Roughly 25–35% of frontend code is genuinely shareable: lexicon types & API client (~15%), business logic & formatters (~10%), data-fetching hooks (~10%). Components, styling, navigation, and maps diverge by platform — and that divergence accelerates as the map UX matures (web hover/viewport interactions vs native gesture/offline-tile patterns). + +A `packages/tokens` for design tokens (colors, spacing, typography scale) is worth doing — both apps consume the same values and render them in their native styling system. + +### Tooling to explore + +Pick before starting; defaults below are what I'd reach for solo, but the trade-offs matter. + +**Monorepo plumbing** +- [pnpm](https://www.npmjs.com/package/pnpm) — workspaces are built-in. Fast, disk-efficient (content-addressable store), the default I'd start with. + - Alternatives: [npm](https://www.npmjs.com/package/npm) workspaces (works but slower), [yarn](https://www.npmjs.com/package/yarn) (fine if you already use it). +- [turbo](https://www.npmjs.com/package/turbo) — task caching across the monorepo. Worth adding once `pnpm build` gets slow in CI; skip until then. + - Alternative: [nx](https://www.npmjs.com/package/nx) — heavier, more opinionated, multi-team-flavored. Overkill solo. + +**Data fetching (universal — works in RN and web)** +- [@tanstack/react-query](https://www.npmjs.com/package/@tanstack/react-query) — canonical choice. Caching, retries, optimistic updates. Use this in `packages/hooks`. + - Alternative: [swr](https://www.npmjs.com/package/swr) — simpler, less powerful. Probably too thin for our needs. + +**State (most state should live in TanStack Query — only reach for these for UI state)** +- [zustand](https://www.npmjs.com/package/zustand) — minimal, no boilerplate, both platforms. My default. + - Alternative: [jotai](https://www.npmjs.com/package/jotai) — atomic state, slightly different mental model. + +**Forms** +- [react-hook-form](https://www.npmjs.com/package/react-hook-form) + [zod](https://www.npmjs.com/package/zod) — universal, lightweight, type-safe validation. Validation logic lives in `packages/core` and is reused on both surfaces. + +**Mobile-specific** +- [expo](https://www.npmjs.com/package/expo) — committed. +- [expo-router](https://www.npmjs.com/package/expo-router) — file-system routing for RN. +- [nativewind](https://www.npmjs.com/package/nativewind) — Tailwind for RN. If web uses Tailwind too, the same class strings work on both surfaces (rendering still differs). + +**Web-specific** +- [vite](https://www.npmjs.com/package/vite) — fast dev server + bundler. SPA build target. +- [@tanstack/react-router](https://www.npmjs.com/package/@tanstack/react-router) — type-safe routing, file-based optional. Pairs naturally with TanStack Query. +- [tailwindcss](https://www.npmjs.com/package/tailwindcss) — styling. + +**SEO and link previews without SSR** +The Go backend serves a small, server-rendered HTML shell at public URLs (`/activity/`, `/u/`) with the right `og:image` / `og:title` / `og:description` meta tags. The static-map blob already lives in Tigris — point `og:image` at it. For everything else the SPA loads as normal and hydrates the page client-side. This sidesteps the need for a JS framework with SSR/RSC. + +**Cross-platform UI (escape hatch if you change your mind)** +- [tamagui](https://www.npmjs.com/package/tamagui) — universal styling system that targets RN + web from one component tree. If you ever decide a few high-value components (buttons, inputs, cards) are worth sharing, this is the route — but commit to it up front; mixing in late is painful. + +## Lexicon principle + +`social.rove.activity` records are **fat**: GPX blob CID, photo blob CIDs, computed metrics, polyline summary, static-map blob CID — all in the record on the user's PDS. If Rove vanishes, another AppView can index the data without us. + +**Likes:** define our own `social.rove.activity.like`. The `app.bsky.feed.like` schema technically accepts a strongRef to anything, but no AppView outside bsky cross-indexes by subject collection — there's no real "free interop" benefit, and our own lexicon keeps semantics clean. + +**Follows:** read the user's existing `app.bsky.graph.follow` records to power Rove's social graph. The schema is `{subject: did, createdAt}` — a generic DID-to-DID edge, not tied to bsky posts. Reusing it gives users a populated social graph from day one. Defer a Rove-specific follow lexicon unless user signal demands it. + +NSIDs are forever; reverse-DNS the domain and don't rename them later. + +## Hosting + +| Component | Choice | Cost | +|---|---|---| +| Backend VM | Fly Machine | ~$5/mo | +| Block storage (SQLite) | Fly Volume + daily snapshots | included | +| Object storage (Litestream backups, map cache, static assets) | Fly Tigris (S3-compatible, colocated with the machine) | ~$1–3/mo | +| CDN | Cloudflare (free tier) | $0 | +| DNS | Cloudflare | $0 (with the registrar fee paid elsewhere) | +| Map tiles | MapTiler — free tier through alpha/beta; Startup plan at launch | $0 → $25/mo | +| Domain | Any registrar | ~$15/yr | + +## Durability & availability + +Two distinct concerns. Fly's docs warn about both in one breath, but they have different answers and different timelines for Rove. + +### Durability — solved from day one + +Belt + suspenders, both cheap on Fly: + +- **Fly Volume snapshots** — daily, included; coarse-grained but easy point-in-time restores via the Fly UI. +- **Litestream → Fly Tigris** — continuous WAL streaming to the same-region S3-compatible bucket. ~1s RPO, runs as a sidecar process in the same machine. Tigris itself has S3-style 11-nines internal durability. Tigris is already in the stack for map cache + static assets, so the marginal cost is ~$0. + +Restore order in a disaster: try Litestream first (smaller RPO), fall back to volume snapshot if Litestream state is corrupt. The AppView's primary content is also reconstructible from the firehose by replaying Jetstream from a stored cursor — so the backups exist mainly to preserve non-replayable state (OAuth refresh tokens, the cursor itself, derived caches) and to avoid a full PDS crawl if the outage exceeds Jetstream's replay window. + +If the volume + machine die simultaneously, we spin up a fresh machine, `litestream restore` from Tigris, and we're back. Maximum data loss: ~1 second. + +### Availability — single-machine until launch, then LiteFS + +Fly's `volumes/overview` doc warns: *"Always provision at least two volumes per app... Volumes don't have built-in replication."* That warning is about uptime, not data — and it explicitly carves out our case: *"In a few cases, you can run a single Machine with an attached volume. For example, if your app is in development... or if you're running an app that can handle downtime and has a custom backup procedure."* + +For Alpha/Beta, single-machine is fine. The realistic downtime budget: + +| Event | Impact | +|---|---| +| Routine deploy | ~30s–2min while the machine restarts | +| Host hardware failure (rare) | ~10–30min while Fly relocates and we restore from Litestream | +| Tigris-region outage | Tigris is replicated; full outage is rare, but possible | + +A free-to-use social adventure app pre-launch can absorb this comfortably. + +**At launch**, add HA via [LiteFS](https://fly.io/docs/litefs/) — Fly's purpose-built SQLite replication layer: + +- Primary machine takes writes; replicas get the WAL streamed via FUSE +- Read replicas serve queries with a few-ms lag +- Automatic failover via consensus; or manual flip +- Layers cleanly with Litestream — LiteFS handles live replication, Litestream still owns long-term object-storage backup +- Cost: another ~$5/mo per replica machine + +LiteFS adds non-trivial operational complexity (FUSE, primary/replica routing, lease semantics, careful migration handling). The reason to defer it: solo-dev time. The roadmap's *"Backend infrastructure improvements to handle real traffic"* line item at Launch is where this lands. + +## Defer until post-launch + +- **HA via LiteFS** — single-machine through alpha/beta; add a replica + LiteFS at launch +- Running our own PDS or relay +- Postgres / managed DB / replicas +- Multi-region anything +- Video (capture, upload, transcoding) — punt to post-launch entirely + +--- + +## Links + +### Core protocol & specs +- [Protocol overview](https://atproto.com/guides/overview) +- [Lexicon guide](https://atproto.com/guides/lexicon) +- [XRPC spec](https://atproto.com/specs/xrpc) — HTTP convention for our `social.rove.*` endpoints +- [Blob spec](https://atproto.com/specs/blob) + +### Auth (canonical) +- [About OAuth](https://atproto.com/guides/about-oauth) — explicitly endorses the BFF pattern +- [Auth overview](https://atproto.com/guides/auth) +- [SDK auth](https://atproto.com/guides/sdk-auth) +- [OAuth patterns](https://atproto.com/guides/oauth-patterns) +- [Permission requests](https://atproto.com/guides/permission-requests) — granular scope format +- [Permission sets](https://atproto.com/guides/permission-sets) — bundling scopes for a clean consent UI +- [OAuth spec](https://atproto.com/specs/oauth) +- [Permissions / lxm / service auth spec](https://atproto.com/specs/permission) + +### Reference architectures +- [Statusphere example app](https://github.com/bluesky-social/statusphere-example-app) +- [Serverless Statusphere on Cloudflare](https://blog.cloudflare.com/serverless-atproto/) — same structural pattern, different infra +- [Frontpage (link aggregator)](https://github.com/likeandscribe/frontpage) +- [Tech Talk: Smoke Signal Events](https://atprotocol.dev/tech-talk-smoke-signal-events/) — closest analog to Rove (events + RSVP, custom lexicons) +- [Tangled](https://tangled.sh) — ATProto-native git platform; confidential-client BFF on web, useful pattern reference for our web app +- [caddy-atproto-auth](https://tangled.org/vvill.dev/caddy-atproto-auth) — Caddy module powering Tangled's auth portal + +### Go (backend) ecosystem +- [bluesky-social/indigo](https://github.com/bluesky-social/indigo) — Bluesky's official Go monorepo: lexicon codegen (`lexgen`), DID/identity resolution, repo, firehose/jetstream consumers, OAuth helpers +- [bluesky-social/jetstream](https://github.com/bluesky-social/jetstream) — the JSON-translated firehose service to consume from +- [Jetstream blog post (jaz)](https://jazco.dev/2024/09/24/jetstream/) — why it exists, how to use it +- [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — cgo-free SQLite for Go (recommended) or [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (cgo, faster) +- [Litestream](https://litestream.io/) — SQLite WAL streaming to any S3-compatible target (Tigris in our case) +- [Fly.io: All-in on Server-Side SQLite](https://fly.io/blog/all-in-on-sqlite-litestream/) — the canonical "why this works" essay + +### React Native (frontend) ecosystem +- [Expo](https://expo.dev/) — strongly recommended for solo dev; handles native build + OTA updates +- [@atproto/api](https://www.npmjs.com/package/@atproto/api) — main JS client +- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client) — OAuth core; bring your own storage/redirect handlers for RN +- [Bluesky social-app](https://github.com/bluesky-social/social-app) — Bluesky's own RN+RN-Web app; reference for OAuth flow, agent setup, blob upload + +### Maps +- [MapTiler SDK JS](https://docs.maptiler.com/sdk-js/) — for the web app; built on MapLibre GL JS with MapTiler-specific helpers +- [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/) — pure open-source web renderer; works with MapTiler tile URLs directly +- [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) — RN wrapper around MapLibre Native; uses MapTiler tile URLs +- [MapTiler tile + style URLs](https://docs.maptiler.com/cloud/api/) — same tile sources work in both web and native + +### Hosting & infra +- [Fly Machines](https://fly.io/docs/machines/) — the VM tier +- [Fly Volumes](https://fly.io/docs/volumes/overview/) — block storage for the SQLite file + snapshots +- [Fly Tigris](https://fly.io/docs/tigris/) — S3-compatible object storage, colocated with the machine +- [Cloudflare](https://www.cloudflare.com/) — DNS + free CDN tier +- [MapTiler pricing](https://www.maptiler.com/cloud/pricing/)