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.

Return the OAuth round-trip to the page sign-in started on

Signing in from /write returned the writer to /editor with an empty editor:
atproto only redirects to a registered redirect_uri and, called without one,
defaults to client-metadata.json's first entry — which was the lone /editor/.
The writing-first draft survived (draft-store persists across the redirect) but
the publish-intent resume was orphaned on the wrong page.

Register every OAuth-island route and pin the round-trip to the originating page:
- OAUTH_REDIRECT_PATHS is the single source of truth (/editor/ first as the
safe fallback); client-metadata.json generates redirect_uris from it.
- AuthProvider.signIn passes redirect_uri for the current page in hosted mode;
loopback (dev) already round-trips per-page, so it passes none.

Regression tests cover the redirect helper, the generated metadata, and the
signIn wiring.

+218 -5
+16
docs/decisions/0020-writing-first-deferred-publish.md
··· 35 35 "Write" button and the signed-in account menu's "Write" item (`accountMenuItems`) also point at 36 36 `/write` now. `/editor` still exists (edit-an-existing-article + the gated flow) but is no longer 37 37 linked from the home page. 38 + 39 + ## Update — OAuth must return to the page sign-in started from 40 + The draft survives the redirect (above), but the round-trip itself returned the writer to the 41 + **wrong page**: signing in from `/write` landed them back on `/editor` with an empty editor (the 42 + draft was still on `/write`, recoverable only by navigating back manually). atproto only redirects 43 + to a **registered** `redirect_uri` and, when `signIn()` is called without one, defaults to 44 + `client-metadata.json`'s **first** `redirect_uris` entry — which was the single `/editor/`. Now 45 + that the editor island powers two routes, both must be registered and the round-trip pinned to the 46 + originating page: 47 + - `OAUTH_REDIRECT_PATHS` (`src/lib/auth/config.ts`) is the single source of truth — `['/editor/', 48 + '/write/']`, `/editor/` first as the safe fallback. `client-metadata.json` generates its 49 + `redirect_uris` from it. 50 + - `AuthProvider.signIn` passes `redirect_uri: redirectUriForLocation(origin, pathname)` in hosted 51 + mode, so the browser client's `findRedirectUrl()` matches the same page at the callback/token 52 + exchange. Loopback (dev) is unaffected: its lone auto-generated redirect URI already equals the 53 + current page, so no `redirect_uri` is passed.
+69
src/lib/auth/AuthProvider.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 + import { act, createElement, useContext, type ReactNode } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + import { AuthContext, AuthProvider, type AuthContextValue } from './AuthProvider'; 5 + 6 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 7 + 8 + // A signed-out browser OAuth client whose `signIn` we capture. `init()` resolves with no 9 + // session so the provider settles on 'signed-out' without touching the PDS/profile code. 10 + const oauth = vi.hoisted( () => ( { 11 + signIn: vi.fn( async () => {} ), 12 + } ) ); 13 + vi.mock( './oauth', () => ( { 14 + createOAuthClient: async () => ( { 15 + init: async () => null, 16 + signIn: oauth.signIn, 17 + revoke: async () => {}, 18 + } ), 19 + } ) ); 20 + 21 + /** Render the provider and hand back the live context value (incl. `signIn`). */ 22 + async function mountProvider() { 23 + const captured: { value: AuthContextValue | null } = { value: null }; 24 + function Capture( { sink }: { sink: { value: AuthContextValue | null } } ): ReactNode { 25 + sink.value = useContext( AuthContext ); 26 + return null; 27 + } 28 + const container = document.createElement( 'div' ); 29 + const root = createRoot( container ); 30 + await act( async () => { 31 + root.render( createElement( AuthProvider, null, createElement( Capture, { sink: captured } ) ) ); 32 + } ); 33 + return captured; 34 + } 35 + 36 + function setLocation( origin: string, pathname: string, hostname: string ) { 37 + vi.stubGlobal( 'location', { origin, pathname, hostname } ); 38 + } 39 + 40 + describe( 'AuthProvider signIn redirect target', () => { 41 + beforeEach( () => { 42 + oauth.signIn.mockClear(); 43 + } ); 44 + afterEach( () => { 45 + vi.unstubAllGlobals(); 46 + } ); 47 + 48 + it( 'hosted: returns to the page sign-in started from (/write/, not /editor/)', async () => { 49 + setLocation( 'https://skypress.blog', '/write/', 'skypress.blog' ); 50 + const ctx = await mountProvider(); 51 + await act( async () => { 52 + await ctx.value!.signIn( 'alice.bsky.social' ); 53 + } ); 54 + expect( oauth.signIn ).toHaveBeenCalledWith( 'alice.bsky.social', { 55 + redirect_uri: 'https://skypress.blog/write/', 56 + } ); 57 + } ); 58 + 59 + it( 'loopback (dev): passes no redirect_uri — the per-page loopback client already round-trips', async () => { 60 + setLocation( 'http://127.0.0.1:4321', '/write', '127.0.0.1' ); 61 + const ctx = await mountProvider(); 62 + await act( async () => { 63 + await ctx.value!.signIn( 'alice.bsky.social' ); 64 + } ); 65 + // `undefined` options is equivalent to omitting them — the loopback metadata's lone 66 + // redirect URI is the current page, so atproto comes back here on its own. 67 + expect( oauth.signIn ).toHaveBeenCalledWith( 'alice.bsky.social', undefined ); 68 + } ); 69 + } );
+24 -2
src/lib/auth/AuthProvider.tsx
··· 9 9 import { Agent } from '@atproto/api'; 10 10 import type { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser'; 11 11 import { createOAuthClient } from './oauth'; 12 - import { isValidAccountInput, normalizeHandle } from './config'; 12 + import { 13 + isValidAccountInput, 14 + normalizeHandle, 15 + getClientMode, 16 + redirectUriForLocation, 17 + } from './config'; 13 18 import { resolvePdsUrl } from '../media/pds'; 14 19 import { fetchViewerProfile } from './profile'; 15 20 ··· 104 109 } 105 110 setError( null ); 106 111 try { 112 + // Hosted (prod) registers several redirect URIs (one per OAuth-island route), 113 + // and atproto defaults to the first — so without this, signing in from /write 114 + // always returned the writer to /editor, losing the in-progress draft. Pin the 115 + // round-trip to the page sign-in started from. In loopback (dev) the client's 116 + // single redirect URI already equals the current page, so we pass nothing. 117 + // atproto brands `redirect_uri` as a URL template type; the value is a registered 118 + // app URL (and atproto re-validates it against the client metadata), so cast the 119 + // options object to the `signIn` parameter type. 120 + const options = 121 + getClientMode( window.location.hostname ) === 'hosted' 122 + ? ( { 123 + redirect_uri: redirectUriForLocation( 124 + window.location.origin, 125 + window.location.pathname 126 + ), 127 + } as Parameters< BrowserOAuthClient[ 'signIn' ] >[ 1 ] ) 128 + : undefined; 107 129 // Redirects to the user's authorization server; the promise never resolves. 108 - await client.signIn( normalizeHandle( input ) ); 130 + await client.signIn( normalizeHandle( input ), options ); 109 131 } catch ( err ) { 110 132 setError( err instanceof Error ? err.message : String( err ) ); 111 133 }
+45
src/lib/auth/config.test.ts
··· 5 5 normalizeHandle, 6 6 isValidAccountInput, 7 7 isValidHandleOrDid, 8 + redirectUriForLocation, 9 + OAUTH_REDIRECT_PATHS, 8 10 OAUTH_SCOPE, 9 11 } from './config'; 10 12 ··· 24 26 it( 'points at /client-metadata.json on the app origin', () => { 25 27 expect( clientMetadataUrl( 'https://skypress.blog' ) ).toBe( 26 28 'https://skypress.blog/client-metadata.json' 29 + ); 30 + } ); 31 + } ); 32 + 33 + describe( 'OAUTH_REDIRECT_PATHS', () => { 34 + it( 'lists every route that mounts the OAuth island, /editor/ first (the default)', () => { 35 + // Both editor surfaces must be registered redirect targets, or atproto rejects 36 + // the round-trip; /editor/ stays first so it is the safe fallback for any other 37 + // page that ever triggers sign-in. 38 + expect( OAUTH_REDIRECT_PATHS ).toEqual( [ '/editor/', '/write/' ] ); 39 + } ); 40 + } ); 41 + 42 + describe( 'redirectUriForLocation', () => { 43 + it( 'returns the originating page so the OAuth round-trip comes back to it', () => { 44 + // The bug: signing in from /write returned the writer to /editor (the only 45 + // registered redirect), orphaning the writing-first draft + publish intent. 46 + expect( redirectUriForLocation( 'https://skypress.blog', '/write/' ) ).toBe( 47 + 'https://skypress.blog/write/' 48 + ); 49 + expect( redirectUriForLocation( 'https://skypress.blog', '/editor/' ) ).toBe( 50 + 'https://skypress.blog/editor/' 51 + ); 52 + } ); 53 + 54 + it( 'normalises a missing trailing slash to the registered (slashed) path', () => { 55 + expect( redirectUriForLocation( 'https://skypress.blog', '/write' ) ).toBe( 56 + 'https://skypress.blog/write/' 57 + ); 58 + } ); 59 + 60 + it( 'falls back to /editor/ for any non-editor path', () => { 61 + expect( redirectUriForLocation( 'https://skypress.blog', '/' ) ).toBe( 62 + 'https://skypress.blog/editor/' 63 + ); 64 + expect( redirectUriForLocation( 'https://skypress.blog', '/dashboard/' ) ).toBe( 65 + 'https://skypress.blog/editor/' 66 + ); 67 + } ); 68 + 69 + it( 'strips a trailing slash off the origin before joining', () => { 70 + expect( redirectUriForLocation( 'https://skypress.blog/', '/write/' ) ).toBe( 71 + 'https://skypress.blog/write/' 27 72 ); 28 73 } ); 29 74 } );
+25
src/lib/auth/config.ts
··· 26 26 return `${ origin.replace( /\/$/, '' ) }/client-metadata.json`; 27 27 } 28 28 29 + /** 30 + * The routes that mount the OAuth island and may therefore be the page sign-in starts from 31 + * (and returns to). Every entry MUST appear in `client-metadata.json`'s `redirect_uris` 32 + * (it's generated from this list) — atproto only redirects back to a registered URI, and 33 + * the browser client matches the callback page against the same list to exchange the code. 34 + * 35 + * `/editor/` stays first: it's the historical default and the safe fallback for any other 36 + * page that ever reaches sign-in. (Decision 0020 — the writing-first `/write` flow shares 37 + * the editor island, so signing in from there must come back to `/write/`, not `/editor/`.) 38 + */ 39 + export const OAUTH_REDIRECT_PATHS = [ '/editor/', '/write/' ] as const; 40 + 41 + /** 42 + * The registered redirect URI for the page sign-in is starting on, so the full-page 43 + * OAuth round-trip returns the writer to where they were (carrying the draft + publish 44 + * intent that `draft-store` persisted). Unknown paths fall back to `/editor/` — passing 45 + * an unregistered URI to `signIn()` would make atproto throw. 46 + */ 47 + export function redirectUriForLocation( origin: string, pathname: string ): string { 48 + const base = origin.replace( /\/$/, '' ); 49 + const normalized = pathname.endsWith( '/' ) ? pathname : `${ pathname }/`; 50 + const match = OAUTH_REDIRECT_PATHS.find( ( path ) => path === normalized ); 51 + return `${ base }${ match ?? OAUTH_REDIRECT_PATHS[ 0 ] }`; 52 + } 53 + 29 54 /** Normalise a handle: trim, drop a leading `@`, lowercase. */ 30 55 export function normalizeHandle( input: string ): string { 31 56 return input.trim().replace( /^@/, '' ).toLowerCase();
+33
src/pages/_client-metadata.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import type { APIContext } from 'astro'; 3 + import { GET } from './client-metadata.json'; 4 + import { OAUTH_REDIRECT_PATHS } from '../lib/auth/config'; 5 + 6 + // Colocated under src/pages/, so the filename is underscore-prefixed to keep Astro's 7 + // file router from importing it during static-path collection (see AGENTS.md). 8 + 9 + async function metadata( origin: string ) { 10 + const response = await GET( { 11 + url: new URL( `${ origin }/client-metadata.json` ), 12 + } as APIContext ); 13 + return response.json() as Promise< { client_id: string; redirect_uris: string[] } >; 14 + } 15 + 16 + describe( '/client-metadata.json', () => { 17 + it( 'registers a redirect URI for every OAuth-island route', async () => { 18 + // Regression: with only /editor/ registered, signing in from /write returned the 19 + // writer to /editor — losing the in-progress writing-first draft (the publish 20 + // intent never resumed). Both surfaces must be registered redirect targets. 21 + const { redirect_uris } = await metadata( 'https://skypress.blog' ); 22 + expect( redirect_uris ).toEqual( 23 + OAUTH_REDIRECT_PATHS.map( ( path ) => `https://skypress.blog${ path }` ) 24 + ); 25 + expect( redirect_uris ).toContain( 'https://skypress.blog/write/' ); 26 + } ); 27 + 28 + it( 'derives client_id and redirect_uris from the request origin', async () => { 29 + const { client_id, redirect_uris } = await metadata( 'https://preview.example.com' ); 30 + expect( client_id ).toBe( 'https://preview.example.com/client-metadata.json' ); 31 + expect( redirect_uris[ 0 ] ).toBe( 'https://preview.example.com/editor/' ); 32 + } ); 33 + } );
+6 -3
src/pages/client-metadata.json.ts
··· 1 1 import type { APIRoute } from 'astro'; 2 + import { OAUTH_REDIRECT_PATHS } from '../lib/auth/config'; 2 3 3 4 /** 4 5 * The atproto OAuth client metadata document (Decision 0004). ··· 7 8 * serving on Cloudflare proved unreliable at the deployed origin, and the worker route is 8 9 * guaranteed to run. Generated from the REQUEST origin so `client_id` always equals the 9 10 * URL it's fetched from (works on the apex domain, www, or any preview origin), and the 10 - * `redirect_uri` matches the editor's canonical path `/editor/` (trailing slash — see 11 - * docs/specs/sp7-deploy.md). 11 + * `redirect_uris` cover every OAuth-island route (`/editor/` and `/write/`, canonical 12 + * trailing slash — see `OAUTH_REDIRECT_PATHS` and docs/specs/sp7-deploy.md). Both are 13 + * registered so signing in from the writing-first `/write` flow returns there, not to 14 + * `/editor` (Decision 0020). 12 15 * 13 16 * In dev (loopback) the browser client builds its own metadata and never fetches this; 14 17 * this is the production/hosted client. ··· 21 24 client_id: `${ origin }/client-metadata.json`, 22 25 client_name: 'SkyPress', 23 26 client_uri: origin, 24 - redirect_uris: [ `${ origin }/editor/` ], 27 + redirect_uris: OAUTH_REDIRECT_PATHS.map( ( path ) => `${ origin }${ path }` ), 25 28 scope: 'atproto transition:generic', 26 29 grant_types: [ 'authorization_code', 'refresh_token' ], 27 30 response_types: [ 'code' ],