···11+# portable.agency
22+33+Link your platformed accounts to an Atmosphere account.
44+55+Live at [link.portable.agency](https://link.portable.agency). First trial: linking User & Agents Discord members to their atproto accounts (preserving the "fascinator" role).
66+77+## How this works
88+99+Each linkage is a *pair of records* on atproto PDSes — one under your control, one under portable.agency's. Those records are the only durable state; there's no service database to go stale or lose.
1010+1111+1. **Link a platformed account.** Authorize the external service (e.g. Discord) so we can confirm your membership and any relevant role. Nothing is written yet.
1212+2. **Sign in with your Atmosphere account.** Fine-grained OAuth — we only request permission to write to the `agency.portable.membership` collection.
1313+3. **Two records are written.**
1414+ - An **attestation** (`agency.portable.attestation`) on portable.agency's PDS — a third-party statement that your DID owns the linked account.
1515+ - A **claim** (`agency.portable.membership`) on your own PDS — a self-claim naming portable.agency as the attester.
1616+1717+ Both records carry the same `service` block. Matching them is the proof.
1818+1919+**Multiple linkages.** Record keys are deterministic (hash of `did + service.type + community + identifier`), so re-linking the same external account is idempotent; linking a different account (e.g. a second Discord alt) creates a separate record. You can have N linkages per platform.
2020+2121+**Where your state lives.** Nothing server-side except a short-lived cookie that holds the in-flight OAuth handshake. After a successful link, your browser stores just your DID in `localStorage`. When you load the page, the browser uses that DID to query your PDS directly and renders the linkages it finds — no server involvement. Clearing browser data only clears the view, not the linkages themselves.
2222+2323+**Verification requires both halves.** The two records reference each other by DID, so confirming a linkage means fetching both — your claim from your PDS and portable.agency's attestation from its PDS. If portable.agency's PDS goes away without a repo backup, the attestation half is lost; your self-claim remains but becomes unverifiable on its own. Atproto repos are cryptographically signed, so anyone archiving portable.agency's repo could keep the attestations verifiable independently.
2424+2525+**Unlinking.** Unlinking requires signing in with your Atmosphere account to prove ownership of the DID. Clicking Unlink redirects you to atproto OAuth; once you confirm, both records — the attestation on portable.agency's PDS and the matching claim on your own PDS — are deleted, leaving no orphan pointers.
2626+2727+## Run locally
2828+2929+```bash
3030+npm install
3131+cp .env.example .env # fill in secrets
3232+npm run dev
3333+```
3434+3535+Note: atproto OAuth rejects localhost origins, so end-to-end testing needs a deployed HTTPS URL. The rest (record shapes, Discord handshake) can be exercised locally.
3636+3737+## Lexicons
3838+3939+- [`agency.portable.membership`](lexicons/agency.portable.membership.json) — user's self-claim
4040+- [`agency.portable.attestation`](lexicons/agency.portable.attestation.json) — third-party attestation
4141+4242+Served at `/lexicons/:nsid`.