A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

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

Add SP1 atproto OAuth (login, session, the Agent)

Gate the editor behind atproto OAuth using a browser public client
(@atproto/oauth-client-browser), per Decision 0004 — chosen over the brief's
named @atproto/oauth-client-node because a confidential client needs a
server-side session store, which would reintroduce the database the no-DB/edge
architecture avoids. A public client (PKCE/DPoP, tokens in IndexedDB) needs no
backend; "secrets never in the client" holds trivially (no secret).

Flow: client.init() once on load (restore session or process the redirect
callback) -> signIn(handle) redirects to the user's auth server -> session ->
new Agent(session) for later com.atproto.repo.* calls (SP2).

- Pure config/handle/scope logic in src/lib/auth/config.ts (7 Vitest tests).
- AuthProvider context + useAuth + LoginForm; Studio island gates SkyEditor.
- Dev uses an atproto loopback client; the client_id must be path-less and the
origin must be 127.0.0.1 (not localhost) — oauth.ts builds it explicitly.
- public/client-metadata.json for the hosted (prod) client (origin finalized in
SP7).

Verified end-to-end against a real Bluesky account: redirect to the genuine
bsky.social authorize page, callback exchange, signed-in editor, and session
persistence across reload. SP1 scope is atproto + transition:generic at the
metadata level; granular write scopes land in SP2.

+1117 -11
+4
AGENTS.md
··· 33 33 one after content exists is a breaking change. (Decision 0002) 34 34 6. **Untrusted content:** stored block trees come from arbitrary PDSes. The reader must 35 35 **sanitise** HTML before injecting it (tracked for SP4). 36 + 7. **OAuth is a browser public client** (`@atproto/oauth-client-browser`, Decision 0004). 37 + In **dev you must serve on `http://127.0.0.1:<port>`, not `localhost`** (atproto 38 + loopback requirement), and the loopback `client_id` must be path-less — see 39 + `src/lib/auth/oauth.ts`. Auth + editor live in the `Studio` client-only island. 36 40 37 41 ## Product guardrails (from the brief) 38 42
+2 -2
README.md
··· 58 58 | | Sub-project | Status | 59 59 |---|---|---| 60 60 | SP0 | Foundations + editor spike | ✅ Complete | 61 - | SP1 | atproto OAuth, session, the `Agent` | Next | 62 - | SP2 | Lexicon (`blog.skypress.*`) + two-record publish | | 61 + | SP1 | atproto OAuth, session, the `Agent` | ✅ Complete | 62 + | SP2 | Lexicon (`blog.skypress.*`) + two-record publish | Next | 63 63 | SP3 | Image/blob pipeline (`mediaUpload` → `uploadBlob`) | | 64 64 | SP4 | Public renderer (`/<handle>/<slug>`, link tags, edge SSR, sanitisation) | | 65 65 | SP5 | Edit flow (the "puppy problem") | |
+65
docs/decisions/0004-oauth-browser-public-client.md
··· 1 + # 0004 — atproto OAuth: browser public client (not server confidential) 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-08 5 + - **Scope:** SP1 (auth) and the hosting story (SP7) 6 + 7 + ## Context 8 + 9 + The brief (§2, §10) requires OAuth-only auth (no app passwords), names 10 + `@atproto/oauth-client-node` (a server-side *confidential* client), and asks for 11 + "server-side session handling done correctly" with "secrets never in the client." 12 + 13 + But the brief's own north star (§2, §8) is a **read-through renderer with no database 14 + and no persistent worker**, on free serverless/edge hosting — and it explicitly grants 15 + authority to re-decide where its specifics conflict with the goals. 16 + 17 + A server confidential client (`@atproto/oauth-client-node`) needs a **server-side 18 + session + state store** (a KV/DB) and a **private signing key** (a secret). That 19 + reintroduces exactly the database the architecture is designed to avoid, and adds a 20 + backend SkyPress otherwise doesn't need: the editor is already a client-side island, 21 + and all PDS writes (records, blobs) happen from the browser via the post-auth `Agent`. 22 + 23 + atproto also offers `@atproto/oauth-client-browser@0.4.1` — a **public** client for SPAs: 24 + PKCE + DPoP, no client secret, sessions/keys in the browser's IndexedDB, token refresh 25 + in-browser. 26 + 27 + ## Decision 28 + 29 + Use **`@atproto/oauth-client-browser`** (public client, PKCE/DPoP). 30 + 31 + - "Secrets never in the client" is satisfied trivially: a public client has **no 32 + secret**. 33 + - No server session store → preserves the no-database / free-edge architecture (§2/§8). 34 + The whole app can ship as static + edge-rendered reading pages, with **no auth 35 + backend at all**. 36 + - The post-auth `Agent` (`@atproto/api`) runs in the browser — exactly where the editor 37 + needs it for `com.atproto.repo.createRecord` / `uploadBlob` (SP2/SP3). 38 + 39 + ### Configuration 40 + - **Dev (loopback):** `new BrowserOAuthClient({ handleResolver })` — the library 41 + auto-derives the loopback `client_id` from `window.location` and redirects 42 + `localhost`→`127.0.0.1` (atproto requires an IP origin for loopback clients). 43 + - **Prod (hosted):** `BrowserOAuthClient.load({ clientId: '<origin>/client-metadata.json' })` 44 + with a static `client-metadata.json` served at the app origin (generated for the real 45 + origin at deploy time, SP7). 46 + - **Handle resolver:** `https://bsky.social` for now. This leaks the user's IP + handle 47 + to Bluesky (documented privacy caveat from the lib); a self-hosted resolver is the 48 + later privacy improvement. 49 + - **Scope (SP1):** `atproto transition:generic` — enough to establish a session. The 50 + granular `repo:site.standard.*` / `repo:app.bsky.feed.post` write scopes (brief §2) 51 + are added in **SP2**, when records are actually written, and verified against the live 52 + permission spec then. 53 + 54 + ## Consequences 55 + 56 + - Tokens live in the browser (IndexedDB). This is the standard, accepted SPA model; 57 + Bluesky issues shorter-lived tokens to public clients than to a BFF — acceptable for a 58 + single-author writing tool. 59 + - Trade-off vs. the brief's named lib is explicit: we lose server-side token control and 60 + longer-lived BFF tokens, and gain a far simpler, database-free, edge-deployable app. 61 + If a future need (e.g. server-side scheduled publishing) requires a confidential 62 + client, that's an additive backend, not a rewrite. 63 + - **OAuth E2E is inherently interactive** (real account + redirect). SP1 unit-tests the 64 + pure config/handle logic and smoke-tests the flow up to the auth-server redirect; the 65 + full round-trip is a documented manual check.
+91
docs/specs/sp1-oauth.md
··· 1 + # SP1 — atproto OAuth (login, session, the Agent) 2 + 3 + - **Date:** 2026-06-08 4 + - **Status:** ✅ Complete — verified end-to-end with a real account (see Outcome) 5 + - **Goal (brief §9.2):** A writer logs in with their existing atproto identity via OAuth; 6 + the app holds a session and exposes an `@atproto/api` `Agent` for later 7 + `com.atproto.repo.*` calls. The editor is gated behind auth. 8 + 9 + ## Approach 10 + 11 + Browser public OAuth client (`@atproto/oauth-client-browser`) — see Decision 0004. No 12 + backend. The flow: 13 + 14 + ``` 15 + load → client.init() // restore session OR process redirect callback 16 + signed out → enter handle → client.signIn(handle) // redirects to the PDS auth server 17 + …redirect back → client.init() returns { session } // session established 18 + session → new Agent(session) // ready for repo.* (SP2) 19 + ``` 20 + 21 + ## Success criteria 22 + 23 + 1. **Auth state machine.** On load, `init()` runs once; status resolves to 24 + `loading → signed-out` (fresh) or `signed-in` (restored session). 25 + 2. **Sign in.** Entering a handle and submitting calls `signIn()`, which resolves the 26 + identity and redirects to the user's authorization server. 27 + 3. **Session + Agent.** After the callback, a session exists and an `Agent` is 28 + constructed; the signed-in identity (handle/DID) is shown. 29 + 4. **Persistence + sign out.** Reload keeps the session (IndexedDB); sign-out revokes it 30 + and returns to signed-out. 31 + 5. **Editor gated.** `/editor` shows the login form when signed out, the editor when 32 + signed in. 33 + 6. **Pure logic tested; flow smoke-tested** up to the redirect (full E2E = manual, needs 34 + a real account — Decision 0004). 35 + 36 + ## Architecture 37 + 38 + ``` 39 + src/lib/auth/ 40 + config.ts PURE, TESTABLE: client mode (loopback vs hosted), metadata URL, 41 + handle normalisation/validation, scope constant 42 + config.test.ts Vitest unit tests (written first) 43 + oauth.ts browser-only: createOAuthClient() (dev loopback / prod load) 44 + AuthProvider.tsx React context: { status, session, agent, signIn, signOut } 45 + useAuth.ts hook to consume the context 46 + LoginForm.tsx handle input → signIn 47 + src/components/ 48 + Studio.tsx top-level island: AuthProvider → gated (LoginForm | SkyEditor) 49 + src/pages/editor.astro renders <Studio client:only="react" /> 50 + public/ 51 + client-metadata.json prod client metadata (origin filled at deploy, SP7) 52 + ``` 53 + 54 + ## TDD plan (pure `config.ts`) 55 + 56 + - `getClientMode()` → 'loopback' for localhost/127.0.0.1/[::1], else 'hosted' 57 + - `clientMetadataUrl(origin)` → `${origin}/client-metadata.json` 58 + - `normalizeHandle()` strips a leading `@`, trims, lowercases 59 + - `isValidAccountInput()` accepts handles, DIDs (`did:plc:…`), and PDS URLs; rejects junk 60 + 61 + ## Out of scope for SP1 62 + 63 + Writing records / lexicon (SP2), granular write scopes (SP2), blob upload (SP3), 64 + self-hosted handle resolver, the real deployed `client-metadata.json` origin (SP7). 65 + 66 + ## Outcome (2026-06-08) 67 + 68 + All criteria met; the full flow was verified against a **real** Bluesky account. 69 + 70 + | # | Criterion | Result | 71 + |---|---|---| 72 + | 1 | Auth state machine | `init()` runs once; resolves `loading → signed-out` (fresh) / `signed-in` (restored) | 73 + | 2 | Sign in | Handle → redirect to the genuine `https://bsky.social/oauth/authorize` (PAR `request_uri`), correct loopback `client_id` | 74 + | 3 | Session + Agent | Callback exchanged; session established; `new Agent(session)`; identity shown (`did:plc:…`) | 75 + | 4 | Persistence | Reload restores the session from IndexedDB — no re-auth | 76 + | 5 | Editor gated | `/editor` shows the login form when signed out, the editor when signed in | 77 + | 6 | Pure logic tested | 7 Vitest tests for config/handle/scope | 78 + 79 + **Findings & notes:** 80 + - The atproto **loopback `client_id` must be path-less**; the library's auto-builder 81 + derives it from `window.location.pathname` and breaks off `/`. `oauth.ts` builds it 82 + explicitly (root client_id + `/editor` redirect_uri). Loopback requires a `127.0.0.1` 83 + origin (not `localhost`). 84 + - The README's `client.addEventListener('deleted', …)` is **not** available at runtime on 85 + this version — removed. Session invalidation is currently handled lazily (a failed 86 + `Agent` call surfaces it); a typed invalidation hook is a later refinement. 87 + - Identity shows the **DID**, not the handle, in dev: `agent.getProfile` (appview read) 88 + is rejected (401/403) under the loopback base `atproto` scope. Expected graceful 89 + fallback; prod's `transition:generic` will resolve the handle. 90 + - Granular write scopes (`repo:site.standard.*`, `repo:app.bsky.feed.post`) are added in 91 + SP2, including wiring them into the dev loopback client_id so publish can be tested.
+470
package-lock.json
··· 10 10 "license": "GPL-2.0-only", 11 11 "dependencies": { 12 12 "@astrojs/react": "5.0.7", 13 + "@atproto/api": "^0.20.9", 14 + "@atproto/oauth-client-browser": "^0.4.1", 13 15 "@automattic/isolated-block-editor": "2.30.0", 14 16 "@wordpress/block-library": "9.24.0", 15 17 "@wordpress/blocks": "14.13.0", ··· 508 510 "yaml": "^2.8.3" 509 511 } 510 512 }, 513 + "node_modules/@atproto-labs/did-resolver": { 514 + "version": "0.3.1", 515 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.3.1.tgz", 516 + "integrity": "sha512-5AnmT38iqoKC3irmJqrmtoj/uEFYPbPZKAms4FMuBuhckmKd5Q8GuVwJoYsPk/+ZdKgAHNJfgnefWCo0UsE47Q==", 517 + "license": "MIT", 518 + "dependencies": { 519 + "@atproto-labs/fetch": "^0.3.0", 520 + "@atproto-labs/pipe": "^0.2.0", 521 + "@atproto-labs/simple-store": "^0.4.0", 522 + "@atproto-labs/simple-store-memory": "^0.2.0", 523 + "@atproto/did": "^0.5.0", 524 + "zod": "^3.23.8" 525 + }, 526 + "engines": { 527 + "node": ">=22" 528 + } 529 + }, 530 + "node_modules/@atproto-labs/did-resolver/node_modules/zod": { 531 + "version": "3.25.76", 532 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 533 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 534 + "license": "MIT", 535 + "funding": { 536 + "url": "https://github.com/sponsors/colinhacks" 537 + } 538 + }, 539 + "node_modules/@atproto-labs/fetch": { 540 + "version": "0.3.0", 541 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.3.0.tgz", 542 + "integrity": "sha512-yZhNsRZyqhjmzHlXmE9k6XU0f6+ynfNyrHhB4FlEyUjkBvf5vNLjdJyBq+Jp7ISGMigA1fn2lAgaE/qkrVo9iw==", 543 + "license": "MIT", 544 + "dependencies": { 545 + "@atproto-labs/pipe": "^0.2.0" 546 + }, 547 + "engines": { 548 + "node": ">=22" 549 + } 550 + }, 551 + "node_modules/@atproto-labs/handle-resolver": { 552 + "version": "0.4.1", 553 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.4.1.tgz", 554 + "integrity": "sha512-9WTrmj2a7ybDlMMkhlqUgnBkNJX1PuRcgnzf5G+3XgKKnOqRA1sNUbykEvyzKXpJgWA9k+CjIemXHitX6Q99cQ==", 555 + "license": "MIT", 556 + "dependencies": { 557 + "@atproto-labs/simple-store": "^0.4.0", 558 + "@atproto-labs/simple-store-memory": "^0.2.0", 559 + "@atproto/did": "^0.5.0", 560 + "zod": "^3.23.8" 561 + }, 562 + "engines": { 563 + "node": ">=22" 564 + } 565 + }, 566 + "node_modules/@atproto-labs/handle-resolver/node_modules/zod": { 567 + "version": "3.25.76", 568 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 569 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 570 + "license": "MIT", 571 + "funding": { 572 + "url": "https://github.com/sponsors/colinhacks" 573 + } 574 + }, 575 + "node_modules/@atproto-labs/identity-resolver": { 576 + "version": "0.4.0", 577 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.4.0.tgz", 578 + "integrity": "sha512-6ZGeWminDTXfYE/jw6BqXm5Nrv2lTBox8wHsnWUQkOs2m0oam47dMcMf3KQfo3hNhRZmTY2JB/F0woRV5MvhpA==", 579 + "license": "MIT", 580 + "dependencies": { 581 + "@atproto-labs/did-resolver": "^0.3.0", 582 + "@atproto-labs/handle-resolver": "^0.4.0" 583 + }, 584 + "engines": { 585 + "node": ">=22" 586 + } 587 + }, 588 + "node_modules/@atproto-labs/pipe": { 589 + "version": "0.2.0", 590 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.0.tgz", 591 + "integrity": "sha512-89X6gv+8/SiZYwJ4+Hw8iaU1WymbElZO6LsMgHd+c1XFs/78KhsIL4xp0RCs7/W8izK9BxaTEnYN5csRmwQ99Q==", 592 + "license": "MIT", 593 + "engines": { 594 + "node": ">=22" 595 + } 596 + }, 597 + "node_modules/@atproto-labs/simple-store": { 598 + "version": "0.4.0", 599 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.4.0.tgz", 600 + "integrity": "sha512-sbPEju2QXzN6lGWfr5p2Kg82ZeaG1UyVFOrMQc4eCjUvxGOjIxuYOVIiTuPTFOC5Rsvsb8jMOmLjLi8FR/CMYw==", 601 + "license": "MIT", 602 + "engines": { 603 + "node": ">=22" 604 + } 605 + }, 606 + "node_modules/@atproto-labs/simple-store-memory": { 607 + "version": "0.2.0", 608 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.2.0.tgz", 609 + "integrity": "sha512-nDZIS6vOBvR8NqObGmj0GFSdz3N6gyQ7ny3l/uiezwuCWShsxtA9OkYcjaQ7Kw3hydXTe4iEGu/BDOACncnxDg==", 610 + "license": "MIT", 611 + "dependencies": { 612 + "@atproto-labs/simple-store": "^0.4.0", 613 + "lru-cache": "^10.2.0" 614 + }, 615 + "engines": { 616 + "node": ">=22" 617 + } 618 + }, 619 + "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { 620 + "version": "10.4.3", 621 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 622 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 623 + "license": "ISC" 624 + }, 625 + "node_modules/@atproto/api": { 626 + "version": "0.20.9", 627 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.9.tgz", 628 + "integrity": "sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==", 629 + "license": "MIT", 630 + "dependencies": { 631 + "@atproto/common-web": "^0.5.0", 632 + "@atproto/lexicon": "^0.7.1", 633 + "@atproto/syntax": "^0.6.1", 634 + "@atproto/xrpc": "^0.8.0", 635 + "await-lock": "^3.0.0", 636 + "multiformats": "^13.0.0", 637 + "tlds": "^1.234.0", 638 + "zod": "^3.23.8" 639 + }, 640 + "engines": { 641 + "node": ">=22" 642 + } 643 + }, 644 + "node_modules/@atproto/api/node_modules/zod": { 645 + "version": "3.25.76", 646 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 647 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 648 + "license": "MIT", 649 + "funding": { 650 + "url": "https://github.com/sponsors/colinhacks" 651 + } 652 + }, 653 + "node_modules/@atproto/common-web": { 654 + "version": "0.5.0", 655 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.0.tgz", 656 + "integrity": "sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==", 657 + "license": "MIT", 658 + "dependencies": { 659 + "@atproto/lex-data": "^0.1.0", 660 + "@atproto/lex-json": "^0.1.0", 661 + "@atproto/syntax": "^0.6.0", 662 + "zod": "^3.23.8" 663 + }, 664 + "engines": { 665 + "node": ">=22" 666 + } 667 + }, 668 + "node_modules/@atproto/common-web/node_modules/zod": { 669 + "version": "3.25.76", 670 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 671 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 672 + "license": "MIT", 673 + "funding": { 674 + "url": "https://github.com/sponsors/colinhacks" 675 + } 676 + }, 677 + "node_modules/@atproto/did": { 678 + "version": "0.5.0", 679 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.5.0.tgz", 680 + "integrity": "sha512-CBtFhMNGgjEEUl5hma7XFj6USDjhPFpFK4dR/XKNgyoztwdcXej2vcZpkuUUhkndPIh1zKFwMQyNSkus8mSNsQ==", 681 + "license": "MIT", 682 + "dependencies": { 683 + "zod": "^3.23.8" 684 + }, 685 + "engines": { 686 + "node": ">=22" 687 + } 688 + }, 689 + "node_modules/@atproto/did/node_modules/zod": { 690 + "version": "3.25.76", 691 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 692 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 693 + "license": "MIT", 694 + "funding": { 695 + "url": "https://github.com/sponsors/colinhacks" 696 + } 697 + }, 698 + "node_modules/@atproto/jwk": { 699 + "version": "0.7.0", 700 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.7.0.tgz", 701 + "integrity": "sha512-HJ0crIKTbeTIiXCodgcgpaTcfKmeUE4IawjpoiVav1KFiIrD8ML2c7NdLETo8XnHuuj6DNzCtfD/381od/QjkA==", 702 + "license": "MIT", 703 + "dependencies": { 704 + "multiformats": "^13.0.0", 705 + "zod": "^3.23.8" 706 + }, 707 + "engines": { 708 + "node": ">=22" 709 + } 710 + }, 711 + "node_modules/@atproto/jwk-jose": { 712 + "version": "0.2.0", 713 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.2.0.tgz", 714 + "integrity": "sha512-emI0XXWoZvcy64Q1NnVictPtez3p2jWUU5TMHxalIqo917z9QEJ/aMcso6X9YPfnbL8sa1n6VGo+5THBMobZzg==", 715 + "license": "MIT", 716 + "dependencies": { 717 + "@atproto/jwk": "^0.7.0", 718 + "jose": "^5.2.0" 719 + }, 720 + "engines": { 721 + "node": ">=22" 722 + } 723 + }, 724 + "node_modules/@atproto/jwk-webcrypto": { 725 + "version": "0.3.0", 726 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.3.0.tgz", 727 + "integrity": "sha512-llguCJA5vlaTfNhWF1RTiRfg0HWCehTumZpQB84ZkhdJp6TBWgfMw431K6B2bE4FbGB3824v/+Q3Y2y8OQEltQ==", 728 + "license": "MIT", 729 + "dependencies": { 730 + "@atproto/jwk": "^0.7.0", 731 + "@atproto/jwk-jose": "^0.2.0", 732 + "zod": "^3.23.8" 733 + }, 734 + "engines": { 735 + "node": ">=22" 736 + } 737 + }, 738 + "node_modules/@atproto/jwk-webcrypto/node_modules/zod": { 739 + "version": "3.25.76", 740 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 741 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 742 + "license": "MIT", 743 + "funding": { 744 + "url": "https://github.com/sponsors/colinhacks" 745 + } 746 + }, 747 + "node_modules/@atproto/jwk/node_modules/zod": { 748 + "version": "3.25.76", 749 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 750 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 751 + "license": "MIT", 752 + "funding": { 753 + "url": "https://github.com/sponsors/colinhacks" 754 + } 755 + }, 756 + "node_modules/@atproto/lex-data": { 757 + "version": "0.1.1", 758 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.1.1.tgz", 759 + "integrity": "sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==", 760 + "license": "MIT", 761 + "dependencies": { 762 + "multiformats": "^13.0.0", 763 + "tslib": "^2.8.1", 764 + "uint8arrays": "^5.0.0", 765 + "unicode-segmenter": "^0.14.0" 766 + }, 767 + "engines": { 768 + "node": ">=22" 769 + } 770 + }, 771 + "node_modules/@atproto/lex-json": { 772 + "version": "0.1.0", 773 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.1.0.tgz", 774 + "integrity": "sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==", 775 + "license": "MIT", 776 + "dependencies": { 777 + "@atproto/lex-data": "^0.1.0", 778 + "tslib": "^2.8.1" 779 + }, 780 + "engines": { 781 + "node": ">=22" 782 + } 783 + }, 784 + "node_modules/@atproto/lexicon": { 785 + "version": "0.7.1", 786 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.7.1.tgz", 787 + "integrity": "sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==", 788 + "license": "MIT", 789 + "dependencies": { 790 + "@atproto/common-web": "^0.5.0", 791 + "@atproto/syntax": "^0.6.1", 792 + "multiformats": "^13.0.0", 793 + "zod": "^3.23.8" 794 + }, 795 + "engines": { 796 + "node": ">=22" 797 + } 798 + }, 799 + "node_modules/@atproto/lexicon/node_modules/zod": { 800 + "version": "3.25.76", 801 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 802 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 803 + "license": "MIT", 804 + "funding": { 805 + "url": "https://github.com/sponsors/colinhacks" 806 + } 807 + }, 808 + "node_modules/@atproto/oauth-client": { 809 + "version": "0.7.2", 810 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.7.2.tgz", 811 + "integrity": "sha512-CbzI1rjDffrIZZoKZjK0RE1UfJFlq6QaZ/uWro0keTZvs2RF5dzilrpRM2Nxr+dvQgmduOBYjtL1wokQ1+7tEg==", 812 + "license": "MIT", 813 + "dependencies": { 814 + "@atproto-labs/did-resolver": "^0.3.1", 815 + "@atproto-labs/fetch": "^0.3.0", 816 + "@atproto-labs/handle-resolver": "^0.4.1", 817 + "@atproto-labs/identity-resolver": "^0.4.0", 818 + "@atproto-labs/simple-store": "^0.4.0", 819 + "@atproto-labs/simple-store-memory": "^0.2.0", 820 + "@atproto/did": "^0.5.0", 821 + "@atproto/jwk": "^0.7.0", 822 + "@atproto/oauth-types": "^0.7.1", 823 + "@atproto/xrpc": "^0.8.0", 824 + "core-js": "^3", 825 + "multiformats": "^13.0.0", 826 + "zod": "^3.23.8" 827 + }, 828 + "engines": { 829 + "node": ">=22" 830 + } 831 + }, 832 + "node_modules/@atproto/oauth-client-browser": { 833 + "version": "0.4.1", 834 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.4.1.tgz", 835 + "integrity": "sha512-GGT83f1Cz2kVaryKva1ieLvrVp0x1rq+3xP1fNdAKxcmiSPkxOhgr0GkcF7QR4Uvy1x3VJ6i6DLZJt9uSoG79g==", 836 + "license": "MIT", 837 + "dependencies": { 838 + "@atproto-labs/did-resolver": "^0.3.1", 839 + "@atproto-labs/handle-resolver": "^0.4.1", 840 + "@atproto-labs/simple-store": "^0.4.0", 841 + "@atproto/did": "^0.5.0", 842 + "@atproto/jwk": "^0.7.0", 843 + "@atproto/jwk-webcrypto": "^0.3.0", 844 + "@atproto/oauth-client": "^0.7.2", 845 + "@atproto/oauth-types": "^0.7.1", 846 + "core-js": "^3" 847 + }, 848 + "engines": { 849 + "node": ">=22" 850 + } 851 + }, 852 + "node_modules/@atproto/oauth-client/node_modules/zod": { 853 + "version": "3.25.76", 854 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 855 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 856 + "license": "MIT", 857 + "funding": { 858 + "url": "https://github.com/sponsors/colinhacks" 859 + } 860 + }, 861 + "node_modules/@atproto/oauth-types": { 862 + "version": "0.7.1", 863 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.7.1.tgz", 864 + "integrity": "sha512-YE+HOg/4a9a6X0+75wDUoL3KQFoM8FcWlrAa/h8lZozBLddaekJUM85Vg7aW4nD59tEvXH7kBLJOS6ZpeWpxxA==", 865 + "license": "MIT", 866 + "dependencies": { 867 + "@atproto/did": "^0.5.0", 868 + "@atproto/jwk": "^0.7.0", 869 + "zod": "^3.23.8" 870 + }, 871 + "engines": { 872 + "node": ">=22" 873 + } 874 + }, 875 + "node_modules/@atproto/oauth-types/node_modules/zod": { 876 + "version": "3.25.76", 877 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 878 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 879 + "license": "MIT", 880 + "funding": { 881 + "url": "https://github.com/sponsors/colinhacks" 882 + } 883 + }, 884 + "node_modules/@atproto/syntax": { 885 + "version": "0.6.1", 886 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.6.1.tgz", 887 + "integrity": "sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==", 888 + "license": "MIT", 889 + "dependencies": { 890 + "iso-datestring-validator": "^2.2.2", 891 + "tslib": "^2.8.1" 892 + }, 893 + "engines": { 894 + "node": ">=22" 895 + } 896 + }, 897 + "node_modules/@atproto/xrpc": { 898 + "version": "0.8.0", 899 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.8.0.tgz", 900 + "integrity": "sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==", 901 + "license": "MIT", 902 + "dependencies": { 903 + "@atproto/lexicon": "^0.7.0", 904 + "zod": "^3.23.8" 905 + }, 906 + "engines": { 907 + "node": ">=22" 908 + } 909 + }, 910 + "node_modules/@atproto/xrpc/node_modules/zod": { 911 + "version": "3.25.76", 912 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 913 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 914 + "license": "MIT", 915 + "funding": { 916 + "url": "https://github.com/sponsors/colinhacks" 917 + } 918 + }, 511 919 "node_modules/@automattic/isolated-block-editor": { 512 920 "version": "2.30.0", 513 921 "resolved": "https://registry.npmjs.org/@automattic/isolated-block-editor/-/isolated-block-editor-2.30.0.tgz", ··· 5796 6204 "integrity": "sha512-5yxLQ22O0fCRGoxGfeLSNt3J8LB1v+umtpMnPW6XjkTWXKoN0AmXAIhelJcDtFT/Y/wYWmfE+oqU10Q0b8FhaQ==", 5797 6205 "license": "MIT" 5798 6206 }, 6207 + "node_modules/await-lock": { 6208 + "version": "3.0.0", 6209 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-3.0.0.tgz", 6210 + "integrity": "sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==", 6211 + "license": "MIT" 6212 + }, 5799 6213 "node_modules/axobject-query": { 5800 6214 "version": "4.1.0", 5801 6215 "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", ··· 6286 6700 "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", 6287 6701 "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", 6288 6702 "license": "MIT" 6703 + }, 6704 + "node_modules/core-js": { 6705 + "version": "3.49.0", 6706 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", 6707 + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", 6708 + "hasInstallScript": true, 6709 + "license": "MIT", 6710 + "funding": { 6711 + "type": "opencollective", 6712 + "url": "https://opencollective.com/core-js" 6713 + } 6289 6714 }, 6290 6715 "node_modules/cosmiconfig": { 6291 6716 "version": "7.1.0", ··· 7805 8230 "url": "https://github.com/sponsors/sindresorhus" 7806 8231 } 7807 8232 }, 8233 + "node_modules/iso-datestring-validator": { 8234 + "version": "2.2.2", 8235 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 8236 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 8237 + "license": "MIT" 8238 + }, 7808 8239 "node_modules/isomorphic.js": { 7809 8240 "version": "0.2.5", 7810 8241 "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", ··· 7815 8246 "url": "https://github.com/sponsors/dmonad" 7816 8247 } 7817 8248 }, 8249 + "node_modules/jose": { 8250 + "version": "5.10.0", 8251 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 8252 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 8253 + "license": "MIT", 8254 + "funding": { 8255 + "url": "https://github.com/sponsors/panva" 8256 + } 8257 + }, 7818 8258 "node_modules/js-tokens": { 7819 8259 "version": "4.0.0", 7820 8260 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", ··· 8980 9420 "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", 8981 9421 "dev": true, 8982 9422 "license": "MIT" 9423 + }, 9424 + "node_modules/multiformats": { 9425 + "version": "13.4.2", 9426 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", 9427 + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 9428 + "license": "Apache-2.0 OR MIT" 8983 9429 }, 8984 9430 "node_modules/nanoid": { 8985 9431 "version": "3.3.12", ··· 10691 11137 "node": ">=14.0.0" 10692 11138 } 10693 11139 }, 11140 + "node_modules/tlds": { 11141 + "version": "1.261.0", 11142 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", 11143 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 11144 + "license": "MIT", 11145 + "bin": { 11146 + "tlds": "bin.js" 11147 + } 11148 + }, 10694 11149 "node_modules/tldts": { 10695 11150 "version": "6.1.86", 10696 11151 "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", ··· 10813 11268 "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", 10814 11269 "license": "MIT" 10815 11270 }, 11271 + "node_modules/uint8arrays": { 11272 + "version": "5.1.1", 11273 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", 11274 + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", 11275 + "license": "Apache-2.0 OR MIT", 11276 + "dependencies": { 11277 + "multiformats": "^13.0.0" 11278 + } 11279 + }, 10816 11280 "node_modules/ultrahtml": { 10817 11281 "version": "1.6.0", 10818 11282 "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", ··· 10829 11293 "version": "7.24.6", 10830 11294 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", 10831 11295 "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", 11296 + "license": "MIT" 11297 + }, 11298 + "node_modules/unicode-segmenter": { 11299 + "version": "0.14.5", 11300 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 11301 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 10832 11302 "license": "MIT" 10833 11303 }, 10834 11304 "node_modules/unified": {
+2
package.json
··· 18 18 }, 19 19 "dependencies": { 20 20 "@astrojs/react": "5.0.7", 21 + "@atproto/api": "^0.20.9", 22 + "@atproto/oauth-client-browser": "^0.4.1", 21 23 "@automattic/isolated-block-editor": "2.30.0", 22 24 "@wordpress/block-library": "9.24.0", 23 25 "@wordpress/blocks": "14.13.0",
+12
public/client-metadata.json
··· 1 + { 2 + "client_id": "https://skypress.blog/client-metadata.json", 3 + "client_name": "SkyPress", 4 + "client_uri": "https://skypress.blog", 5 + "redirect_uris": ["https://skypress.blog/editor"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+52
src/components/Studio.tsx
··· 1 + import { AuthProvider } from '../lib/auth/AuthProvider'; 2 + import { useAuth } from '../lib/auth/useAuth'; 3 + import LoginForm from '../lib/auth/LoginForm'; 4 + import SkyEditor from './SkyEditor'; 5 + 6 + /** 7 + * The authenticated writing surface. Gates the editor behind atproto OAuth: 8 + * loading → (signed-out: login form) | (signed-in: account bar + editor). 9 + */ 10 + function StudioGate() { 11 + const { status, handle, did, error, signOut } = useAuth(); 12 + 13 + if ( status === 'loading' ) { 14 + return <p className="studio__loading">Connecting to your identity…</p>; 15 + } 16 + 17 + if ( status === 'signed-in' ) { 18 + return ( 19 + <> 20 + <div className="studio__account"> 21 + <span> 22 + Signed in as <strong>{ handle ?? did }</strong> 23 + </span> 24 + <button type="button" className="studio__signout" onClick={ () => void signOut() }> 25 + Sign out 26 + </button> 27 + </div> 28 + <SkyEditor /> 29 + </> 30 + ); 31 + } 32 + 33 + // signed-out or error 34 + return ( 35 + <div className="studio__login"> 36 + <LoginForm /> 37 + { status === 'error' && error && ( 38 + <p className="studio__error" role="alert"> 39 + Couldn't start the auth client: { error } 40 + </p> 41 + ) } 42 + </div> 43 + ); 44 + } 45 + 46 + export default function Studio() { 47 + return ( 48 + <AuthProvider> 49 + <StudioGate /> 50 + </AuthProvider> 51 + ); 52 + }
+129
src/lib/auth/AuthProvider.tsx
··· 1 + import { 2 + createContext, 3 + useCallback, 4 + useEffect, 5 + useRef, 6 + useState, 7 + type ReactNode, 8 + } from 'react'; 9 + import { Agent } from '@atproto/api'; 10 + import type { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser'; 11 + import { createOAuthClient } from './oauth'; 12 + import { isValidAccountInput, normalizeHandle } from './config'; 13 + 14 + export type AuthStatus = 'loading' | 'signed-out' | 'signed-in' | 'error'; 15 + 16 + export interface AuthContextValue { 17 + status: AuthStatus; 18 + agent: Agent | null; 19 + did: string | null; 20 + handle: string | null; 21 + error: string | null; 22 + signIn: ( input: string ) => Promise< void >; 23 + signOut: () => Promise< void >; 24 + } 25 + 26 + export const AuthContext = createContext< AuthContextValue | null >( null ); 27 + 28 + /** Best-effort handle lookup; falls back to the DID if the appview is unreachable. */ 29 + async function resolveHandle( agent: Agent, did: string ): Promise< string | null > { 30 + try { 31 + const profile = await agent.getProfile( { actor: did } ); 32 + return profile.data.handle ?? null; 33 + } catch { 34 + return null; 35 + } 36 + } 37 + 38 + export function AuthProvider( { children }: { children: ReactNode } ) { 39 + const clientRef = useRef< BrowserOAuthClient | null >( null ); 40 + const [ status, setStatus ] = useState< AuthStatus >( 'loading' ); 41 + const [ agent, setAgent ] = useState< Agent | null >( null ); 42 + const [ did, setDid ] = useState< string | null >( null ); 43 + const [ handle, setHandle ] = useState< string | null >( null ); 44 + const [ error, setError ] = useState< string | null >( null ); 45 + 46 + const adoptSession = useCallback( async ( session: OAuthSession ) => { 47 + const nextAgent = new Agent( session ); 48 + setAgent( nextAgent ); 49 + setDid( session.did ); 50 + setStatus( 'signed-in' ); 51 + setError( null ); 52 + setHandle( await resolveHandle( nextAgent, session.did ) ); 53 + }, [] ); 54 + 55 + // Initialise once: process an OAuth callback or restore an existing session. 56 + useEffect( () => { 57 + let cancelled = false; 58 + 59 + ( async () => { 60 + try { 61 + const client = await createOAuthClient(); 62 + if ( cancelled ) { 63 + return; 64 + } 65 + clientRef.current = client; 66 + 67 + const result = await client.init(); 68 + if ( cancelled ) { 69 + return; 70 + } 71 + if ( result?.session ) { 72 + await adoptSession( result.session ); 73 + } else { 74 + setStatus( 'signed-out' ); 75 + } 76 + } catch ( err ) { 77 + if ( ! cancelled ) { 78 + setError( err instanceof Error ? err.message : String( err ) ); 79 + setStatus( 'error' ); 80 + } 81 + } 82 + } )(); 83 + 84 + return () => { 85 + cancelled = true; 86 + }; 87 + }, [ adoptSession ] ); 88 + 89 + const signIn = useCallback( async ( input: string ) => { 90 + const client = clientRef.current; 91 + if ( ! client ) { 92 + return; 93 + } 94 + if ( ! isValidAccountInput( input ) ) { 95 + setError( 'Enter a handle (alice.bsky.social), a DID, or a PDS URL.' ); 96 + return; 97 + } 98 + setError( null ); 99 + try { 100 + // Redirects to the user's authorization server; the promise never resolves. 101 + await client.signIn( normalizeHandle( input ) ); 102 + } catch ( err ) { 103 + setError( err instanceof Error ? err.message : String( err ) ); 104 + } 105 + }, [] ); 106 + 107 + const signOut = useCallback( async () => { 108 + const client = clientRef.current; 109 + if ( client && did ) { 110 + try { 111 + await client.revoke( did ); 112 + } catch { 113 + // best effort — fall through to local reset 114 + } 115 + } 116 + setAgent( null ); 117 + setDid( null ); 118 + setHandle( null ); 119 + setStatus( 'signed-out' ); 120 + }, [ did ] ); 121 + 122 + return ( 123 + <AuthContext.Provider 124 + value={ { status, agent, did, handle, error, signIn, signOut } } 125 + > 126 + { children } 127 + </AuthContext.Provider> 128 + ); 129 + }
+50
src/lib/auth/LoginForm.tsx
··· 1 + import { useState } from 'react'; 2 + import { useAuth } from './useAuth'; 3 + 4 + /** Handle/DID/PDS-URL entry that kicks off the OAuth redirect. */ 5 + export default function LoginForm() { 6 + const { signIn, error } = useAuth(); 7 + const [ value, setValue ] = useState( '' ); 8 + 9 + return ( 10 + <form 11 + className="login" 12 + onSubmit={ ( event ) => { 13 + event.preventDefault(); 14 + void signIn( value ); 15 + } } 16 + > 17 + <h1 className="login__title">Sign in to write</h1> 18 + <p className="login__lede"> 19 + Use your existing Bluesky / AT Protocol identity. Your work is saved to 20 + your own server. 21 + </p> 22 + <label className="login__label" htmlFor="handle"> 23 + Your handle, DID, or PDS URL 24 + </label> 25 + <input 26 + id="handle" 27 + className="login__input" 28 + name="handle" 29 + autoComplete="username" 30 + autoCapitalize="none" 31 + autoCorrect="off" 32 + spellCheck={ false } 33 + placeholder="alice.bsky.social" 34 + value={ value } 35 + onChange={ ( event ) => setValue( event.target.value ) } 36 + /> 37 + <button className="login__submit" type="submit"> 38 + Sign in with AT Protocol 39 + </button> 40 + { error && ( 41 + <p className="login__error" role="alert"> 42 + { error } 43 + </p> 44 + ) } 45 + <p className="login__note"> 46 + You'll be sent to your provider to authorize SkyPress, then returned here. 47 + </p> 48 + </form> 49 + ); 50 + }
+56
src/lib/auth/config.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { 3 + getClientMode, 4 + clientMetadataUrl, 5 + normalizeHandle, 6 + isValidAccountInput, 7 + OAUTH_SCOPE, 8 + } from './config'; 9 + 10 + describe( 'getClientMode', () => { 11 + it( 'treats loopback hosts as dev (loopback) clients', () => { 12 + expect( getClientMode( 'localhost' ) ).toBe( 'loopback' ); 13 + expect( getClientMode( '127.0.0.1' ) ).toBe( 'loopback' ); 14 + expect( getClientMode( '[::1]' ) ).toBe( 'loopback' ); 15 + } ); 16 + 17 + it( 'treats real hosts as hosted clients', () => { 18 + expect( getClientMode( 'skypress.blog' ) ).toBe( 'hosted' ); 19 + } ); 20 + } ); 21 + 22 + describe( 'clientMetadataUrl', () => { 23 + it( 'points at /client-metadata.json on the app origin', () => { 24 + expect( clientMetadataUrl( 'https://skypress.blog' ) ).toBe( 25 + 'https://skypress.blog/client-metadata.json' 26 + ); 27 + } ); 28 + } ); 29 + 30 + describe( 'normalizeHandle', () => { 31 + it( 'strips a leading @, trims, and lowercases', () => { 32 + expect( normalizeHandle( ' @Alice.BSKY.social ' ) ).toBe( 'alice.bsky.social' ); 33 + expect( normalizeHandle( 'bob.com' ) ).toBe( 'bob.com' ); 34 + } ); 35 + } ); 36 + 37 + describe( 'isValidAccountInput', () => { 38 + it( 'accepts handles, DIDs and PDS URLs', () => { 39 + expect( isValidAccountInput( 'alice.bsky.social' ) ).toBe( true ); 40 + expect( isValidAccountInput( '@alice.bsky.social' ) ).toBe( true ); 41 + expect( isValidAccountInput( 'did:plc:abc123' ) ).toBe( true ); 42 + expect( isValidAccountInput( 'https://pds.example.com' ) ).toBe( true ); 43 + } ); 44 + 45 + it( 'rejects empty input and bare words without a domain', () => { 46 + expect( isValidAccountInput( '' ) ).toBe( false ); 47 + expect( isValidAccountInput( ' ' ) ).toBe( false ); 48 + expect( isValidAccountInput( 'notahandle' ) ).toBe( false ); 49 + } ); 50 + } ); 51 + 52 + describe( 'OAUTH_SCOPE', () => { 53 + it( 'requests atproto + transitional generic access for SP1', () => { 54 + expect( OAUTH_SCOPE ).toBe( 'atproto transition:generic' ); 55 + } ); 56 + } );
+58
src/lib/auth/config.ts
··· 1 + /** 2 + * Pure, dependency-free OAuth configuration helpers (Decision 0004). 3 + * 4 + * Kept free of `@atproto/*` imports so it's unit-testable in Node and importable 5 + * from anywhere. The browser-only client construction lives in `oauth.ts`. 6 + */ 7 + 8 + /** Scope for SP1 — establishes a session. Granular write scopes are added in SP2. */ 9 + export const OAUTH_SCOPE = 'atproto transition:generic'; 10 + 11 + /** Handle resolver service (a `com.atproto.identity.resolveHandle` XRPC endpoint). */ 12 + export const HANDLE_RESOLVER = 'https://bsky.social'; 13 + 14 + const LOOPBACK_HOSTS = new Set( [ 'localhost', '127.0.0.1', '[::1]', '::1' ] ); 15 + 16 + /** 17 + * Loopback hosts use atproto's development client (auto-generated metadata); 18 + * everything else uses a hosted `client-metadata.json`. 19 + */ 20 + export function getClientMode( hostname: string ): 'loopback' | 'hosted' { 21 + return LOOPBACK_HOSTS.has( hostname ) ? 'loopback' : 'hosted'; 22 + } 23 + 24 + /** The hosted client metadata URL — the `client_id` for production. */ 25 + export function clientMetadataUrl( origin: string ): string { 26 + return `${ origin.replace( /\/$/, '' ) }/client-metadata.json`; 27 + } 28 + 29 + /** Normalise a handle: trim, drop a leading `@`, lowercase. */ 30 + export function normalizeHandle( input: string ): string { 31 + return input.trim().replace( /^@/, '' ).toLowerCase(); 32 + } 33 + 34 + const HANDLE_RE = /^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; 35 + const DID_RE = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 36 + 37 + /** 38 + * Accept the three things `signIn()` can resolve from: a handle (`alice.bsky.social`), 39 + * a DID (`did:plc:…`), or a PDS/entryway URL (`https://…`). Rejects empty input and 40 + * bare words with no domain. 41 + */ 42 + export function isValidAccountInput( input: string ): boolean { 43 + const value = input.trim(); 44 + if ( ! value ) { 45 + return false; 46 + } 47 + if ( DID_RE.test( value ) ) { 48 + return true; 49 + } 50 + if ( /^https?:\/\//.test( value ) ) { 51 + try { 52 + return Boolean( new URL( value ).hostname ); 53 + } catch { 54 + return false; 55 + } 56 + } 57 + return HANDLE_RE.test( normalizeHandle( value ) ); 58 + }
+34
src/lib/auth/oauth.ts
··· 1 + import { 2 + BrowserOAuthClient, 3 + atprotoLoopbackClientMetadata, 4 + } from '@atproto/oauth-client-browser'; 5 + import { getClientMode, clientMetadataUrl, HANDLE_RESOLVER } from './config'; 6 + 7 + /** 8 + * Create the browser OAuth client (Decision 0004). BROWSER-ONLY — reads 9 + * `window.location` and uses WebCrypto/IndexedDB, so call it from an effect in a 10 + * client-only island, never during SSR. 11 + * 12 + * - Loopback (dev): an atproto loopback `client_id` must be **path-less** 13 + * (`http://localhost?…`), but its `redirect_uri` may carry a path. The library's 14 + * auto-builder derives both from `window.location`, which breaks when the app isn't 15 + * at `/` (e.g. `/editor`). So we build it explicitly: a root client_id with the 16 + * current page as the redirect target. Loopback origins must be `127.0.0.1`/`[::1]`. 17 + * - Hosted (prod): loads `client-metadata.json` from the app origin (full scope). 18 + */ 19 + export async function createOAuthClient(): Promise< BrowserOAuthClient > { 20 + if ( getClientMode( window.location.hostname ) === 'loopback' ) { 21 + const redirectUri = `${ window.location.origin }${ window.location.pathname }`; 22 + const clientId = `http://localhost?redirect_uri=${ encodeURIComponent( 23 + redirectUri 24 + ) }`; 25 + return new BrowserOAuthClient( { 26 + clientMetadata: atprotoLoopbackClientMetadata( clientId ), 27 + handleResolver: HANDLE_RESOLVER, 28 + } ); 29 + } 30 + return BrowserOAuthClient.load( { 31 + clientId: clientMetadataUrl( window.location.origin ), 32 + handleResolver: HANDLE_RESOLVER, 33 + } ); 34 + }
+11
src/lib/auth/useAuth.ts
··· 1 + import { useContext } from 'react'; 2 + import { AuthContext, type AuthContextValue } from './AuthProvider'; 3 + 4 + /** Consume the auth context. Must be used inside an <AuthProvider>. */ 5 + export function useAuth(): AuthContextValue { 6 + const value = useContext( AuthContext ); 7 + if ( ! value ) { 8 + throw new Error( 'useAuth must be used within an <AuthProvider>' ); 9 + } 10 + return value; 11 + }
+81 -9
src/pages/editor.astro
··· 1 1 --- 2 2 import Base from '../layouts/Base.astro'; 3 - import SkyEditor from '../components/SkyEditor.tsx'; 3 + import Studio from '../components/Studio.tsx'; 4 4 --- 5 5 6 6 <Base title="Write — SkyPress"> ··· 8 8 <header class="editor-shell__bar"> 9 9 <a class="editor-shell__home" href="/">SkyPress</a> 10 10 <span class="editor-shell__hint" 11 - >SP0 spike — the block tree is captured to localStorage on every 12 - change.</span 11 + >Sign in with your AT Protocol identity to write.</span 13 12 > 14 13 </header> 15 - <!-- client:only — the editor never server-renders and its bundle never 16 - reaches reading pages (Decision 0001). --> 17 - <SkyEditor client:only="react"> 18 - <p slot="fallback" class="editor-shell__loading">Loading the editor…</p> 19 - </SkyEditor> 14 + <!-- client:only — auth + editor run only in the browser; their bundle never 15 + reaches reading pages (Decisions 0001 & 0004). --> 16 + <Studio client:only="react"> 17 + <p slot="fallback" class="editor-shell__loading">Loading…</p> 18 + </Studio> 20 19 </main> 21 20 </Base> 22 21 ··· 38 37 color: var(--muted); 39 38 font-size: 0.85rem; 40 39 } 41 - .editor-shell__loading { 40 + .editor-shell__loading, 41 + .studio__loading { 42 42 padding: 2rem 1.25rem; 43 43 color: var(--muted); 44 + } 45 + 46 + /* Account bar (signed in) */ 47 + .studio__account { 48 + display: flex; 49 + align-items: center; 50 + justify-content: space-between; 51 + gap: 1rem; 52 + padding: 0.5rem 1.25rem; 53 + background: #f1eee7; 54 + border-bottom: 1px solid #e7e3da; 55 + font-size: 0.9rem; 56 + flex-wrap: wrap; 57 + } 58 + .studio__signout { 59 + border: 1px solid #d6d0c4; 60 + background: #fff; 61 + border-radius: 6px; 62 + padding: 0.3rem 0.7rem; 63 + cursor: pointer; 64 + font: inherit; 65 + } 66 + 67 + /* Login (signed out) */ 68 + .studio__login { 69 + max-width: 30rem; 70 + margin: 0 auto; 71 + padding: 4rem 1.5rem; 72 + } 73 + .studio__error, 74 + .login__error { 75 + color: #b3261e; 76 + font-size: 0.9rem; 77 + } 78 + .login__title { 79 + font-size: 1.6rem; 80 + margin: 0 0 0.5rem; 81 + } 82 + .login__lede { 83 + color: var(--muted); 84 + margin: 0 0 1.5rem; 85 + } 86 + .login__label { 87 + display: block; 88 + font-size: 0.85rem; 89 + font-weight: 600; 90 + margin-bottom: 0.35rem; 91 + } 92 + .login__input { 93 + width: 100%; 94 + box-sizing: border-box; 95 + padding: 0.6rem 0.7rem; 96 + border: 1px solid #d6d0c4; 97 + border-radius: 8px; 98 + font: inherit; 99 + margin-bottom: 0.85rem; 100 + } 101 + .login__submit { 102 + width: 100%; 103 + padding: 0.65rem 1rem; 104 + border: 0; 105 + border-radius: 8px; 106 + background: var(--sky); 107 + color: #fff; 108 + font: inherit; 109 + font-weight: 600; 110 + cursor: pointer; 111 + } 112 + .login__note { 113 + color: var(--muted); 114 + font-size: 0.8rem; 115 + margin-top: 1rem; 44 116 } 45 117 </style>