A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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. */
9export const OAUTH_SCOPE = 'atproto transition:generic';
10
11/** Handle resolver service (a `com.atproto.identity.resolveHandle` XRPC endpoint). */
12export const HANDLE_RESOLVER = 'https://bsky.social';
13
14const 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 */
20export 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. */
25export function clientMetadataUrl( origin: string ): string {
26 return `${ origin.replace( /\/$/, '' ) }/client-metadata.json`;
27}
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 */
39export 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 */
47export 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
54/** Normalise a handle: trim, drop a leading `@`, lowercase. */
55export function normalizeHandle( input: string ): string {
56 return input.trim().replace( /^@/, '' ).toLowerCase();
57}
58
59const HANDLE_RE = /^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
60const DID_RE = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/;
61
62/**
63 * Accept the three things `signIn()` can resolve from: a handle (`alice.bsky.social`),
64 * a DID (`did:plc:…`), or a PDS/entryway URL (`https://…`). Rejects empty input and
65 * bare words with no domain.
66 */
67export function isValidAccountInput( input: string ): boolean {
68 const value = input.trim();
69 if ( ! value ) {
70 return false;
71 }
72 if ( DID_RE.test( value ) ) {
73 return true;
74 }
75 if ( /^https?:\/\//.test( value ) ) {
76 try {
77 return Boolean( new URL( value ).hostname );
78 } catch {
79 return false;
80 }
81 }
82 return HANDLE_RE.test( normalizeHandle( value ) );
83}
84
85/**
86 * Stricter sibling of `isValidAccountInput` for the public READ path. Accepts only a
87 * syntactic handle (`alice.bsky.social`) or DID (`did:plc:…`) — and, unlike
88 * `isValidAccountInput`, rejects `https://…` URLs and anything carrying a path, port,
89 * query or scheme. The renderer takes the author straight from the URL and uses it as a
90 * resolver fetch host, so this stops a value like `evil.com/x?y=` from smuggling into the
91 * outbound request (`safeFetch` still blocks internal hosts; this gates the syntax).
92 */
93export function isValidHandleOrDid( input: string ): boolean {
94 const value = input.trim();
95 if ( ! value ) {
96 return false;
97 }
98 if ( DID_RE.test( value ) ) {
99 return true;
100 }
101 return HANDLE_RE.test( normalizeHandle( value ) );
102}