diff --git a/apps/hosting-service/src/lib/file-serving.integration.test.ts b/apps/hosting-service/src/lib/file-serving.integration.test.ts index 852a34a..ae5d4d2 100644 --- a/apps/hosting-service/src/lib/file-serving.integration.test.ts +++ b/apps/hosting-service/src/lib/file-serving.integration.test.ts @@ -216,13 +216,13 @@ describe('serveFileInternal directory-index fallback for extensioned paths', () expect(storageGetWithMetadataKeys).toContain(`${DID}/${RKEY}/modelapp.html`) }) - test('serves fingerprinted javascript assets with immutable cache headers', async () => { + test('keeps fingerprinted javascript assets on the standard cache policy', async () => { storeFile('assets/typescript-COf36OFD.js', 'export const ts = true', 'text/javascript') const response = await serveFileInternal(DID, RKEY, 'assets/typescript-COf36OFD.js') expect(response.status).toBe(200) - expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable') + expect(response.headers.get('Cache-Control')).toBe('public, max-age=600') }) test('keeps non-fingerprinted javascript assets on the standard cache policy', async () => { @@ -234,7 +234,7 @@ describe('serveFileInternal directory-index fallback for extensioned paths', () expect(response.headers.get('Cache-Control')).toBe('public, max-age=600') }) - test('returns immutable cache headers on fingerprinted asset 304 responses', async () => { + test('returns standard cache headers on fingerprinted asset 304 responses', async () => { storeFile('assets/typescript-COf36OFD.js', 'export const ts = true', 'text/javascript') const response = await serveFileInternal(DID, RKEY, 'assets/typescript-COf36OFD.js', null, { @@ -243,7 +243,7 @@ describe('serveFileInternal directory-index fallback for extensioned paths', () expect(response.status).toBe(304) expect(response.headers.get('ETag')).toBe('"test-checksum"') - expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable') + expect(response.headers.get('Cache-Control')).toBe('public, max-age=600') }) test('rejects absolute 200 rewrite targets without proxying them', async () => { diff --git a/apps/hosting-service/src/lib/file-serving.test.ts b/apps/hosting-service/src/lib/file-serving.test.ts index 13e6af0..cb67961 100644 --- a/apps/hosting-service/src/lib/file-serving.test.ts +++ b/apps/hosting-service/src/lib/file-serving.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { getCacheControlForPath, hasFileExtension } from './file-serving' +import { hasFileExtension } from './file-serving' describe('hasFileExtension', () => { describe('paths with extensions', () => { @@ -87,25 +87,3 @@ describe('hasFileExtension', () => { }) }) }) - -describe('getCacheControlForPath', () => { - test('uses immutable caching for vite-style hashed javascript assets', () => { - expect(getCacheControlForPath('assets/typescript-COf36OFD.js')).toBe('public, max-age=31536000, immutable') - }) - - test('uses immutable caching for dotted hashed assets', () => { - expect(getCacheControlForPath('assets/admin.na06kbvm.js')).toBe('public, max-age=31536000, immutable') - }) - - test('uses immutable caching for source maps of hashed assets', () => { - expect(getCacheControlForPath('assets/typescript-COf36OFD.js.map')).toBe('public, max-age=31536000, immutable') - }) - - test('keeps ordinary mutable javascript on the standard cache policy', () => { - expect(getCacheControlForPath('assets/app.js')).toBe('public, max-age=600') - }) - - test('keeps html on the standard cache policy even if the basename contains a hash-like suffix', () => { - expect(getCacheControlForPath('pages/index-COf36OFD.html')).toBe('public, max-age=600') - }) -}) diff --git a/apps/hosting-service/src/lib/file-serving.ts b/apps/hosting-service/src/lib/file-serving.ts index b81ed6d..3f8f4a2 100644 --- a/apps/hosting-service/src/lib/file-serving.ts +++ b/apps/hosting-service/src/lib/file-serving.ts @@ -27,26 +27,6 @@ import { getCachedSettings } from './utils' const logger = createLogger('file-serving') const STANDARD_CACHE_CONTROL = 'public, max-age=600' -const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable' -const IMMUTABLE_EXTENSIONS = new Set([ - 'css', - 'gif', - 'ico', - 'jpeg', - 'jpg', - 'js', - 'json', - 'map', - 'mjs', - 'otf', - 'png', - 'svg', - 'ttf', - 'wasm', - 'webp', - 'woff', - 'woff2', -]) type FileStorageResult = StorageResult type FileForRequestResult = { result: FileStorageResult; filePath: string; wasRewritten: boolean } @@ -61,30 +41,6 @@ export function hasFileExtension(path: string): boolean { return /\.[a-zA-Z0-9]+$/.test(basename) } -function getFinalExtension(path: string): string { - const basename = path.split('/').pop() || '' - const match = /\.([a-zA-Z0-9]+)$/.exec(basename) - return match?.[1]?.toLowerCase() ?? '' -} - -function stripFinalExtension(path: string): string { - const basename = (path.split('/').pop() || '').replace(/\.map$/i, '') - return basename.replace(/\.[a-zA-Z0-9]+$/, '') -} - -function hasFingerprintSuffix(path: string): boolean { - const stem = stripFinalExtension(path) - return /(?:^|[-.])[a-zA-Z0-9_-]{8,}$/.test(stem) -} - -export function getCacheControlForPath(filePath: string): string { - if (IMMUTABLE_EXTENSIONS.has(getFinalExtension(filePath)) && hasFingerprintSuffix(filePath)) { - return IMMUTABLE_CACHE_CONTROL - } - - return STANDARD_CACHE_CONTROL -} - /** * Helper to retrieve a file with metadata from tiered storage * Logs which tier the file was served from @@ -305,7 +261,7 @@ function buildResponseFromStorageResult( const content = Buffer.from(result.data) const meta = result.metadata.customMetadata as { encoding?: string; mimeType?: string } | undefined const mimeType = meta?.mimeType || lookup(filePath) || 'application/octet-stream' - const cacheControl = getCacheControlForPath(filePath) + const cacheControl = STANDARD_CACHE_CONTROL const etag = result.metadata.checksum ? `"${result.metadata.checksum}"` : undefined // Handle conditional requests (If-None-Match → 304 Not Modified) @@ -371,7 +327,7 @@ async function buildRewrittenHtmlResponse( const content = Buffer.from(result.data) const meta = result.metadata.customMetadata as { encoding?: string; mimeType?: string } | undefined const mimeType = meta?.mimeType || lookup(filePath) || 'application/octet-stream' - const cacheControl = getCacheControlForPath(filePath) + const cacheControl = STANDARD_CACHE_CONTROL const headers: Record = { 'Content-Type': mimeType, diff --git a/apps/main-app/public/editor/tabs/CLITab.tsx b/apps/main-app/public/editor/tabs/CLITab.tsx index 9026ab0..fa64ab0 100644 --- a/apps/main-app/public/editor/tabs/CLITab.tsx +++ b/apps/main-app/public/editor/tabs/CLITab.tsx @@ -9,27 +9,27 @@ const BINARIES = [ { platform: 'macOS (Apple Silicon)', filename: 'wisp-cli-aarch64-darwin', - sha256: 'af91f58c37957bf9ce28da443a536ee0267b4f8deb04fe220c585b8d9b9b43e5', + sha256: 'ed3b5d82291fd955ade565780837844d67eba60869948f1ec1e78fa810f30a70', }, { platform: 'macOS (Intel)', filename: 'wisp-cli-x86_64-darwin', - sha256: 'ec1529bf74f594e25390c066b06525c483a4d8bf664e4f1c14738d6d27554171', + sha256: '11b911d957480731974c6aa6a7ac274c8ec64074a0886bd095596d77dce58196', }, { platform: 'Linux (ARM64)', filename: 'wisp-cli-aarch64-linux', - sha256: '037521ce5fc756891a67dffb87d98c52c9112358ad1dd78adad05861fddf1d39', + sha256: '3fc6ce46f7d07b5b55a59d5fc2f648ec446e25fc1e0475f92de51eb0a361eec2', }, { platform: 'Linux (x86_64)', filename: 'wisp-cli-x86_64-linux', - sha256: '960364c5530b23660fe7d787a349b8a3c10472d1e85ea7f423173e4b18817a98', + sha256: 'e60bb9025c12dad7a1e4c53da58638737b7bc35c5e560aa3cf913573b2b8eb9f', }, { platform: 'Windows (x86_64)', filename: 'wisp-cli-x86_64-windows.exe', - sha256: 'd1aaa70fbc805b1b352dcfbc42c46b46e43c8a15725ca9f52cff92a45be1c0f0', + sha256: '5372c0ad37f2b925853d1fb1c9d9c85fbd4fbfbc11d58671cd084394e7584bdc', }, ] as const @@ -58,7 +58,7 @@ export const CLITab = memo(function CLITab() {
Wisp CLI - v1.3.0 + v1.3.1
@@ -100,7 +100,7 @@ export const CLITab = memo(function CLITab() { {/* Binary downloads */}
- Binary Downloads v1.3.0 + Binary Downloads v1.3.1
{BINARIES.map(({ platform, filename, sha256 }) => ( | null => { + if (!state) return null + try { + const parsed = JSON.parse(state) + return parsed && typeof parsed === 'object' ? (parsed as Record) : null + } catch { + return null + } +} + +/** True when this callback belongs to a request that already fell back. */ +export const isLegacyScopeState = (state: string | null | undefined): boolean => + parseState(state)?.[LEGACY_SCOPE_MARK] === true + +const markLegacyScopeState = (state: string | null | undefined): string => + JSON.stringify({ ...(parseState(state) ?? {}), [LEGACY_SCOPE_MARK]: true }) + +/** + * Strip the retry marker before handing `state` to code that expects the + * original value. + */ +export const unmarkLegacyScopeState = (state: string | null | undefined): string | undefined => { + const parsed = parseState(state) + if (parsed?.[LEGACY_SCOPE_MARK] !== true) return state ?? undefined + const { [LEGACY_SCOPE_MARK]: _mark, ...rest } = parsed + return Object.keys(rest).length > 0 ? JSON.stringify(rest) : undefined +} + +/** + * Start an authorization request, preferring the published `place.wisp.*` + * permission sets. + * + * An authorization server that cannot resolve them rejects the pushed request + * with `invalid_scope`, so retry once with the granular expansion of the same + * sets. Servers that predate permission sets entirely accept the request and + * silently drop the `include:` values instead — that case is caught after the + * callback by {@link missingGrantedCapabilities}. + */ +export const authorizeWisp = async ( + client: NodeOAuthClient, + identifier: string, + options: { state?: string } = {}, +): Promise => { + if (isLegacyScopeState(options.state)) { + return await client.authorize(identifier, { ...options, scope: OAUTH_LEGACY_SCOPE }) + } + + try { + return await client.authorize(identifier, { ...options, scope: OAUTH_SCOPE }) + } catch (err) { + logger.warn('[Auth] Permission set scope rejected, retrying with granular scopes', { + identifier, + err: err instanceof Error ? err.message : String(err), + }) + return await client.authorize(identifier, { + ...options, + state: markLegacyScopeState(options.state), + scope: OAUTH_LEGACY_SCOPE, + }) + } +} + +/** + * Re-authorize with the granular scopes, keeping the original application + * state so the post-login redirect still works. + */ +export const authorizeWispLegacy = async ( + client: NodeOAuthClient, + identifier: string, + state: string | null | undefined, +): Promise => await client.authorize(identifier, { state: markLegacyScopeState(state), scope: OAUTH_LEGACY_SCOPE }) + +/** + * What main-app still can not do with the session it was just handed. + * + * The granted scope is always the granular expansion — the authorization + * server rewrites `include:place.wisp.authSites` into the permissions the set + * contains before minting the token — so this compares meaning, not strings. + */ +export const missingGrantedCapabilities = async (session: OAuthSession): Promise => { + try { + const tokenInfo = await session.getTokenInfo(false) + return missingCapabilities(tokenInfo.scope, wispAppRequiredCapabilities()).map(describeCapability) + } catch (err) { + // Never block a login on an introspection failure. + logger.warn('[Auth] Could not read granted scope', { err: err instanceof Error ? err.message : String(err) }) + return [] + } +} diff --git a/apps/main-app/src/lib/oauth-client.ts b/apps/main-app/src/lib/oauth-client.ts index b7fbfae..63cd4ac 100644 --- a/apps/main-app/src/lib/oauth-client.ts +++ b/apps/main-app/src/lib/oauth-client.ts @@ -1,5 +1,6 @@ import { JoseKey } from '@atproto/jwk-jose' import { type ClientMetadata, NodeOAuthClient, type RuntimeLock } from '@atproto/oauth-client-node' +import { buildWispScopes, WISP_APP_PERMISSION_SETS } from '@wispplace/constants' import { SQL } from 'bun' import { db } from './db' import { logger } from './logger' @@ -51,9 +52,19 @@ const requestPgLock: RuntimeLock = async (name, fn) => { } } -// OAuth scope for all client types -const OAUTH_SCOPE = - 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings repo:place.wisp.v2.wh blob:*/*' +// OAuth scopes for all client types. +// +// `OAUTH_SCOPE` names the published `place.wisp.*` permission sets, so the +// consent screen reads "Manage your wisp.place sites" rather than listing six +// raw `repo:` values. `OAUTH_LEGACY_SCOPE` is the granular expansion of those +// same sets, used when an authorization server does not resolve permission +// sets. `OAUTH_CLIENT_SCOPE` is the union: a server rejects any requested scope +// value the client did not declare in its metadata, so both strategies have to +// be declared up front. +const wispScopes = buildWispScopes([...WISP_APP_PERMISSION_SETS]) +export const OAUTH_SCOPE = wispScopes.preferred +export const OAUTH_LEGACY_SCOPE = wispScopes.legacy +const OAUTH_CLIENT_SCOPE = wispScopes.metadata const oauthNetworkOptions = () => { const allowHttp = Bun.env.OAUTH_ALLOW_HTTP === 'true' @@ -186,7 +197,7 @@ export const createClientMetadata = ( const redirectUri = 'http://127.0.0.1:8000/api/auth/callback' const params = new URLSearchParams() params.append('redirect_uri', redirectUri) - params.append('scope', OAUTH_SCOPE) + params.append('scope', OAUTH_CLIENT_SCOPE) return { client_id: `http://localhost?${params.toString()}`, @@ -197,7 +208,7 @@ export const createClientMetadata = ( response_types: ['code'], application_type: 'web', token_endpoint_auth_method: 'none', - scope: OAUTH_SCOPE, + scope: OAUTH_CLIENT_SCOPE, dpop_bound_access_tokens: false, subject_type: 'public', authorization_signed_response_alg: 'ES256', @@ -218,7 +229,7 @@ export const createClientMetadata = ( application_type: 'web', token_endpoint_auth_method: 'private_key_jwt', token_endpoint_auth_signing_alg: 'ES256', - scope: OAUTH_SCOPE, + scope: OAUTH_CLIENT_SCOPE, dpop_bound_access_tokens: true, jwks_uri: `${config.domain}/jwks.json`, subject_type: 'public', diff --git a/apps/main-app/src/routes/auth.ts b/apps/main-app/src/routes/auth.ts index 1126ef5..6efcb5b 100644 --- a/apps/main-app/src/routes/auth.ts +++ b/apps/main-app/src/routes/auth.ts @@ -2,6 +2,13 @@ import type { NodeOAuthClient } from '@atproto/oauth-client-node' import { createLogger } from '@wispplace/observability' import { Elysia } from 'elysia' import { getDomainByDid, getSitesByDid } from '../lib/db' +import { + authorizeWisp, + authorizeWispLegacy, + isLegacyScopeState, + missingGrantedCapabilities, + unmarkLegacyScopeState, +} from '../lib/oauth-authorize' import { authenticateRequest, SESSION_COOKIE_NAME } from '../lib/wisp-auth' import { resolvePrivateShareState } from './private-redeem' @@ -37,7 +44,7 @@ export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => logger.info('Login attempt via entryway', { identifier }) const state = crypto.randomUUID() - const url = await client.authorize(identifier, { state }) + const url = await authorizeWisp(client, identifier, { state }) logger.info('Authorization URL generated', { identifier }) // Redirect to the OAuth authorization URL @@ -60,7 +67,7 @@ export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => handle = body.handle logger.info('Sign-in attempt', { handle }) const state = crypto.randomUUID() - const url = await client.authorize(handle, { state }) + const url = await authorizeWisp(client, handle, { state }) logger.info('Authorization URL generated', { handle }) return { url: url.toString() } } catch (err) { @@ -99,8 +106,24 @@ export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => maxAge: 30 * 24 * 60 * 60, // 30 days }) + // An authorization server that predates permission sets accepts the + // `include:place.wisp.*` values and then drops them, leaving a session + // that can not write records. Retry once with the granular expansion. + const missing = await missingGrantedCapabilities(session) + if (missing.length > 0) { + if (!isLegacyScopeState(state)) { + logger.warn('[Auth] Permission sets were not granted, retrying with granular scopes', { + did: session.did, + missing, + }) + const retryUrl = await authorizeWispLegacy(client, session.did, state) + return c.redirect(retryUrl.toString()) + } + logger.error('[Auth] Session is missing required permissions', { did: session.did, missing }) + } + // Revalidate the OAuth state token before returning a share visitor to its site. - const redeem = await resolvePrivateShareState(state, session.did) + const redeem = await resolvePrivateShareState(unmarkLegacyScopeState(state), session.did) if (redeem) { return c.redirect(redeem.url ?? '/private/denied') } diff --git a/apps/main-app/src/routes/private-redeem.ts b/apps/main-app/src/routes/private-redeem.ts index dd50672..30e4b5c 100644 --- a/apps/main-app/src/routes/private-redeem.ts +++ b/apps/main-app/src/routes/private-redeem.ts @@ -2,6 +2,7 @@ import type { NodeOAuthClient } from '@atproto/oauth-client-node' import { createLogger } from '@wispplace/observability' import { PRIVATE_ACCESS_PAGE_STYLES } from '@wispplace/private-sites' import { Elysia } from 'elysia' +import { authorizeWisp } from '../lib/oauth-authorize' import { openPrivateSiteForAccount, redeemScopedShare, resolveShareLink } from '../lib/private-sites-service' import { authenticateRequest, SESSION_COOKIE_NAME } from '../lib/wisp-auth' @@ -156,7 +157,7 @@ export const privateRedeemRoutes = (client: NodeOAuthClient, cookieSecret: strin } try { const state = JSON.stringify({ kind: token ? 'privateShare' : 'privateOpen', siteId, token }) - return { url: (await client.authorize(handle, { state })).toString() } + return { url: (await authorizeWisp(client, handle, { state })).toString() } } catch (err) { logger.error('[PrivateSite] Sign-in failed', err) set.status = 400 diff --git a/blog/src/pages/posts/wispctl-1-3.astro b/blog/src/pages/posts/wispctl-1-3-1.astro similarity index 85% rename from blog/src/pages/posts/wispctl-1-3.astro rename to blog/src/pages/posts/wispctl-1-3-1.astro index eed0528..5f1472f 100644 --- a/blog/src/pages/posts/wispctl-1-3.astro +++ b/blog/src/pages/posts/wispctl-1-3-1.astro @@ -5,9 +5,9 @@ import Terminal from '../../components/Terminal.astro'; import Post from '../../layouts/Post.astro'; --- - +

- wispctl 1.3 is a small version upgrade that makes using the cli across CWDs more ergonomic, so you and your agent can more easily push to wisp without reauthing often. + wispctl 1.3.1 is a small version upgrade that makes using the cli across CWDs more ergonomic, so you and your agent can more easily push to wisp without reauthing often.

log in once

@@ -52,7 +52,7 @@ import Post from '../../layouts/Post.astro';

--password still exists for compatibility, but command arguments can be visible in a - process list. 1.3 also reads WISPCTL_APP_PASSWORD, which is the better choice for ci and + process list. 1.3.1 also reads WISPCTL_APP_PASSWORD, which is the better choice for ci and headless machines:

@@ -72,5 +72,5 @@ wispctl deploy alice.bsky.social --path ./dist --site docs`}

upgrading

- +
diff --git a/blog/src/posts.ts b/blog/src/posts.ts index e8a1b10..9152440 100644 --- a/blog/src/posts.ts +++ b/blog/src/posts.ts @@ -46,14 +46,13 @@ export const authors = { const posts: Post[] = [ { - slug: 'wispctl-1-3', - title: 'wispctl 1.3 ', + slug: 'wispctl-1-3-1', + title: 'wispctl 1.3.1', description: 'Making wispctl\'s auth more ergonomic to use', date: '2026-08-26', author: authors.nekomimi, tags: ['release', 'cli'], - draft: true, }, { slug: 'hello-world', diff --git a/cli/lib/account-store.ts b/cli/lib/account-store.ts index 60f9936..06e2b6d 100644 --- a/cli/lib/account-store.ts +++ b/cli/lib/account-store.ts @@ -39,8 +39,19 @@ export interface StoredAccount { lastUsedAt: number /** When `handle` was last confirmed against the network. */ handleCheckedAt?: number + /** + * Which OAuth scope strategy minted this session. + * + * A loopback client declares its scopes inside the `client_id`, so the two + * strategies are two different client identities. Restoring a session has to + * rebuild the same one or the token refresh is rejected. + */ + oauthScope?: OAuthScopeStrategy } +/** `sets` requests `include:place.wisp.*`; `granular` requests their expansion. */ +export type OAuthScopeStrategy = 'sets' | 'granular' + // --------------------------------------------------------------------------- // Keychain access // --------------------------------------------------------------------------- @@ -430,7 +441,13 @@ function detachHandle(kv: KvAdapter, did: string, handle: string): void { export function upsertAccount( kv: KvAdapter, did: string, - updates: { handle?: string; method?: AuthMethod; pdsUrl?: string; handleChecked?: boolean } = {}, + updates: { + handle?: string + method?: AuthMethod + pdsUrl?: string + handleChecked?: boolean + oauthScope?: OAuthScopeStrategy + } = {}, ): StoredAccount { const now = Date.now() const existing = readAccount(kv, did) @@ -454,6 +471,7 @@ export function upsertAccount( addedAt: existing?.addedAt ?? now, lastUsedAt: now, handleCheckedAt: updates.handleChecked ? now : handle !== existing?.handle ? undefined : existing?.handleCheckedAt, + oauthScope: updates.oauthScope ?? existing?.oauthScope, } writeAccount(kv, account) return account diff --git a/cli/lib/auth.ts b/cli/lib/auth.ts index 283185d..13b0343 100644 --- a/cli/lib/auth.ts +++ b/cli/lib/auth.ts @@ -13,6 +13,7 @@ import { log } from '@clack/prompts' import { serve as honoNodeServe } from '@hono/node-server' import { resolvePdsFromHandle } from '@wispplace/atproto-utils' import { isBun } from '@wispplace/bun-firehose' +import { describeCapability, missingCapabilities, wispCliRequiredCapabilities } from '@wispplace/constants' import { Hono } from 'hono' import open from 'open' import { @@ -32,6 +33,7 @@ import { listAccounts, listDirsForDid, normalizeHandle, + type OAuthScopeStrategy, openAccountStore, probeKeychain, readAccount, @@ -44,7 +46,10 @@ import { setStoredOAuthSession, upsertAccount, } from './account-store.ts' -import { WISP_OAUTH_SCOPE } from './wisp-service' +import { WISP_OAUTH_LEGACY_SCOPE, WISP_OAUTH_SCOPE } from './wisp-service' + +/** Public resolvers used only when the system nameservers fail to answer. */ +const FALLBACK_NAMESERVERS = ['1.1.1.1', '8.8.8.8'] const LOOPBACK_PORT = 4000 const LOOPBACK_HOST = '127.0.0.1' @@ -214,14 +219,17 @@ export async function resolveAccountForCwd(dbPath?: string): Promise !granted.has(decodeURIComponent(s))) +function missingScopesFor(grantedScope: string | undefined) { + return missingCapabilities(grantedScope, wispCliRequiredCapabilities()) } /** @@ -237,10 +245,18 @@ export async function authenticateOAuth( const useKeychain = keychainProbe.available const stateStore = createStateStore(kv) const sessionStore = createSessionStore(kv, useKeychain) - const createOAuthClient = (redirectUri: string): NodeOAuthClient => { + // A loopback client declares its scopes inside the `client_id`, so declaring + // both strategies at once would double the length of every authorization URL + // the user sees. Build one client per strategy instead and only reach for the + // granular one if the permission sets are refused. + const scopeFor = (strategy: OAuthScopeStrategy) => + strategy === 'granular' ? WISP_OAUTH_LEGACY_SCOPE : WISP_OAUTH_SCOPE + + const createOAuthClient = (redirectUri: string, strategy: OAuthScopeStrategy = 'sets'): NodeOAuthClient => { + const scope = scopeFor(strategy) const clientIdParams = new URLSearchParams() clientIdParams.append('redirect_uri', redirectUri) - clientIdParams.append('scope', WISP_OAUTH_SCOPE) + clientIdParams.append('scope', scope) return new NodeOAuthClient({ clientMetadata: { client_id: `http://localhost?${clientIdParams.toString()}`, @@ -251,12 +267,16 @@ export async function authenticateOAuth( response_types: ['code'], application_type: 'web', token_endpoint_auth_method: 'none', - scope: WISP_OAUTH_SCOPE, + scope, dpop_bound_access_tokens: false, }, stateStore, sessionStore, requestLock: requestLocalLock, + // A handle with no /.well-known/atproto-did has DNS as its only route, + // so one flaky system resolver is enough to fail a login outright. + // These are consulted only after the system resolver gives nothing. + fallbackNameservers: FALLBACK_NAMESERVERS, }) } @@ -287,12 +307,18 @@ export async function authenticateOAuth( // Record the account against this directory so the next bare command here // resolves without a handle, and remember the handle for display. - const finish = async (agent: Agent, did: string, method: AuthMethod): Promise<{ agent: Agent; did: string }> => { + const finish = async ( + agent: Agent, + did: string, + method: AuthMethod, + oauthScope?: OAuthScopeStrategy, + ): Promise<{ agent: Agent; did: string }> => { const explicitHandle = handle && !handle.startsWith('did:') ? normalizeHandle(handle) : undefined upsertAccount(kv, did, { handle: explicitHandle, handleChecked: explicitHandle ? true : undefined, method, + oauthScope, }) setDirDid(kv, did) await backfillHandle(kv, did) @@ -308,7 +334,14 @@ export async function authenticateOAuth( const tryOAuth = async (): Promise<{ agent: Agent; did: string } | undefined> => { try { - const session = await client.restore(accountDid) + // The stored session belongs to whichever client_id minted it, and a + // loopback client_id contains its scopes — so restore through the same + // strategy or the refresh is rejected as a different client. + const restoreClient = + account?.oauthScope && account.oauthScope !== 'sets' + ? createOAuthClient(redirectUri, account.oauthScope) + : client + const session = await restoreClient.restore(accountDid) if (!session) return undefined // A stored token may predate an expanded scope set (e.g. the // privateSite/domain.verify scopes). Re-auth transparently so the @@ -386,130 +419,172 @@ export async function authenticateOAuth( // Start new OAuth flow emitStatus(options, `Starting OAuth flow for ${loginIdentifier}...`) - const callbackPromise = new Promise<{ params: URLSearchParams }>((resolve, reject) => { - const app = new Hono() - let serverHandle: { close: () => void } | null = null - let timeoutHandle: ReturnType | undefined - let settled = false - - const successHtml = ` - - - Wisp CLI - Authentication Successful - - - - -
-

Authentication Successful

-

You can close this window and return to the CLI.

-
- - - ` - - app.get('/oauth/callback', (c) => { - const params = new URLSearchParams(c.req.url.split('?')[1] || '') - if (timeoutHandle) { - clearTimeout(timeoutHandle) + const createCallbackServer = (): Promise<{ params: URLSearchParams }> => + new Promise<{ params: URLSearchParams }>((resolve, reject) => { + const app = new Hono() + let serverHandle: { close: () => void } | null = null + let timeoutHandle: ReturnType | undefined + let settled = false + + const successHtml = ` + + + Wisp CLI - Authentication Successful + + + + +
+

Authentication Successful

+

You can close this window and return to the CLI.

+
+ + + ` + + app.get('/oauth/callback', (c) => { + const params = new URLSearchParams(c.req.url.split('?')[1] || '') + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + settled = true + setTimeout(() => serverHandle?.close(), 100) + resolve({ params }) + return c.html(successHtml) + }) + + app.all('*', (c) => c.text('Not found', 404)) + + if (isBun) { + const bunServer = Bun.serve({ + port: oauthPort, + hostname: LOOPBACK_HOST, + fetch: app.fetch, + }) + serverHandle = { close: () => bunServer.stop() } + } else { + const nodeServer = honoNodeServe({ + fetch: app.fetch, + port: oauthPort, + hostname: LOOPBACK_HOST, + }) + serverHandle = { close: () => nodeServer.close() } + } + + timeoutHandle = setTimeout( + () => { + if (settled) return + settled = true + serverHandle?.close() + reject(new Error('OAuth callback timeout')) + }, + 5 * 60 * 1000, + ) + + if (typeof (timeoutHandle as { unref?: () => void }).unref === 'function') { + ;(timeoutHandle as { unref: () => void }).unref() } - settled = true - setTimeout(() => serverHandle?.close(), 100) - resolve({ params }) - return c.html(successHtml) }) - app.all('*', (c) => c.text('Not found', 404)) + // `loginIdentifier` is checked above; capture it so the closures below keep + // the narrowed type. + const identifier = loginIdentifier + + const completeFlow = async (authUrl: URL) => { + const callbackPromise = createCallbackServer() + emitStatus(options, 'Opening browser for authentication...') + emitStatus(options, `If browser does not open, visit: ${authUrl}`) + await open(authUrl.toString()) + const { params } = await callbackPromise + return await client.callback(params) + } - if (isBun) { - const bunServer = Bun.serve({ - port: oauthPort, - hostname: LOOPBACK_HOST, - fetch: app.fetch, - }) - serverHandle = { close: () => bunServer.stop() } - } else { - const nodeServer = honoNodeServe({ - fetch: app.fetch, - port: oauthPort, - hostname: LOOPBACK_HOST, - }) - serverHandle = { close: () => nodeServer.close() } - } + // Prefer the published permission sets. A server that cannot resolve them + // rejects the pushed authorization request outright, so fall straight back + // to the granular expansion. + let strategy: OAuthScopeStrategy = 'sets' + const useGranular = async (): Promise => { + strategy = 'granular' + client = createOAuthClient(redirectUri, 'granular') + return await client.authorize(identifier, { scope: scopeFor('granular') }) + } - timeoutHandle = setTimeout( - () => { - if (settled) return - settled = true - serverHandle?.close() - reject(new Error('OAuth callback timeout')) - }, - 5 * 60 * 1000, + let authUrl: URL + try { + authUrl = await client.authorize(identifier, { scope: scopeFor('sets') }) + } catch (err) { + emitWarning( + options, + `Authorization server rejected the wisp.place permission sets (${err instanceof Error ? err.message : String(err)}). Falling back to granular scopes.`, ) + authUrl = await useGranular() + } - if (typeof (timeoutHandle as { unref?: () => void }).unref === 'function') { - ;(timeoutHandle as { unref: () => void }).unref() - } - }) - - const authUrl = await client.authorize(loginIdentifier, { scope: WISP_OAUTH_SCOPE }) - - emitStatus(options, 'Opening browser for authentication...') - emitStatus(options, `If browser does not open, visit: ${authUrl}`) - await open(authUrl.toString()) + let { session } = await completeFlow(authUrl) + let tokenInfo = await session.getTokenInfo(false) + let missingScopes = missingScopesFor(tokenInfo.scope) - const { params } = await callbackPromise - const { session } = await client.callback(params) + // An older authorization server accepts the request but silently drops the + // `include:` values it does not understand, leaving a session that can not + // write anything. That only shows up in the granted scope. + if (missingScopes.length > 0 && strategy === 'sets') { + emitWarning( + options, + 'Authorization server ignored the wisp.place permission sets. Retrying with granular scopes...', + ) + await sessionStore.del(session.did) + ;({ session } = await completeFlow(await useGranular())) + tokenInfo = await session.getTokenInfo(false) + missingScopes = missingScopesFor(tokenInfo.scope) + } - const tokenInfo = await session.getTokenInfo(false) - const missingScopes = missingScopesFor(tokenInfo.scope) if (missingScopes.length > 0) { emitWarning( options, - `OAuth token is missing ${missingScopes.length} requested scope(s). First missing: ${missingScopes[0]}`, + `OAuth token is missing ${missingScopes.length} requested permission(s). First missing: ${describeCapability(missingScopes[0])}`, ) } @@ -526,7 +601,7 @@ export async function authenticateOAuth( kvSet(kv, dirOAuthPortKey, String(oauthPort)) } - const result = await finish(agent, did, 'oauth') + const result = await finish(agent, did, 'oauth', strategy) emitStatus(options, `Authenticated as ${did}`) diff --git a/cli/lib/wisp-service.ts b/cli/lib/wisp-service.ts index 8dd9c4a..2e67de2 100644 --- a/cli/lib/wisp-service.ts +++ b/cli/lib/wisp-service.ts @@ -1,31 +1,10 @@ +import { buildWispScopes, WISP_CLI_PERMISSION_SETS, WISP_HOSTING_LXMS } from '@wispplace/constants' + export const DEFAULT_WISP_SERVICE_DID = 'did:web:wisp.place' export const WISP_PROXY_SERVICE_ID = 'wisp_xrpc' -export const WISP_OAUTH_BASE_SCOPES = [ - 'atproto', - 'repo:place.wisp.fs', - 'repo:place.wisp.subfs', - 'repo:place.wisp.settings', - 'blob:*/*', -] as const - -export const WISP_SERVICE_LXMS = [ - 'place.wisp.v2.domain.addSite', - 'place.wisp.v2.domain.claim', - 'place.wisp.v2.domain.claimSubdomain', - 'place.wisp.v2.domain.delete', - 'place.wisp.v2.domain.getList', - 'place.wisp.v2.domain.getStatus', - 'place.wisp.v2.domain.verify', - 'place.wisp.v2.privateSite.create', - 'place.wisp.v2.privateSite.createShare', - 'place.wisp.v2.privateSite.delete', - 'place.wisp.v2.privateSite.list', - 'place.wisp.v2.privateSite.listShares', - 'place.wisp.v2.privateSite.revokeShare', - 'place.wisp.v2.site.delete', - 'place.wisp.v2.site.getList', -] as const +/** XRPC methods the CLI reaches through the hosting service proxy. */ +export const WISP_SERVICE_LXMS = WISP_HOSTING_LXMS function isDid(value: string): value is `did:${string}:${string}` { if (!value.startsWith('did:')) { @@ -58,16 +37,19 @@ export function parseServiceDid(input?: string): `did:${string}:${string}` { return value } -function buildRpcScope(aud: string, lxm: string): string { - return `rpc:${lxm}?aud=${aud}` -} - -export function buildWispRpcScopes(): string[] { - return WISP_SERVICE_LXMS.map((lxm) => buildRpcScope('*', lxm)) -} - -export function buildWispOAuthScopes(): string[] { - return [...WISP_OAUTH_BASE_SCOPES, ...buildWispRpcScopes()] -} - -export const WISP_OAUTH_SCOPE = buildWispOAuthScopes().join(' ') +const WISP_CLI_SCOPES = buildWispScopes([...WISP_CLI_PERMISSION_SETS]) + +/** + * Scope the CLI asks for: one `include:` per published permission set, so the + * consent screen shows "Manage your wisp.place sites" instead of a wall of + * `repo:`/`rpc:` values. + */ +export const WISP_OAUTH_SCOPE = WISP_CLI_SCOPES.preferred + +/** + * The granular expansion of those sets. Authorization servers that support + * granular scopes but not permission sets ignore an unknown `include:` value + * and hand back a session with no permissions at all, so the login flow falls + * back to this. + */ +export const WISP_OAUTH_LEGACY_SCOPE = WISP_CLI_SCOPES.legacy diff --git a/cli/package.json b/cli/package.json index cdc1b98..5e68ba2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "wispctl", - "version": "1.3.0", + "version": "1.3.1", "main": "./dist/index.js", "devDependencies": { "@atproto/api": "^0.20.28", diff --git a/docs/src/content/docs/cli.md b/docs/src/content/docs/cli.md index d247bd9..0d5d1cb 100644 --- a/docs/src/content/docs/cli.md +++ b/docs/src/content/docs/cli.md @@ -26,7 +26,7 @@ The Wisp CLI is a command-line tool for deploying static websites directly to yo
diff --git a/docs/src/content/docs/lexicons/index.md b/docs/src/content/docs/lexicons/index.md index cda55b6..f8170ef 100644 --- a/docs/src/content/docs/lexicons/index.md +++ b/docs/src/content/docs/lexicons/index.md @@ -17,6 +17,8 @@ Wisp.place uses three custom AT Protocol lexicons to store site data in your PDS **place.wisp.v2.secret.{create,list,delete,rotate}** — server-managed signing secrets for webhooks. Tokens are returned once at creation and never stored in plaintext. See the [webhooks doc](/lexicons/place-wisp-wh#signing-secrets-api) for usage. +**[place.wisp.auth*](/lexicons/permission-sets)** — OAuth permission sets that bundle the granular scopes clients request during login. + ## Storage Model Sites are stored as `place.wisp.fs` records in your AT Protocol repository: diff --git a/docs/src/content/docs/lexicons/permission-sets.md b/docs/src/content/docs/lexicons/permission-sets.md new file mode 100644 index 0000000..c80fdc7 --- /dev/null +++ b/docs/src/content/docs/lexicons/permission-sets.md @@ -0,0 +1,106 @@ +--- +title: OAuth Permission Sets +description: The place.wisp.auth* permission sets wisp.place clients request during OAuth +--- + +AT Protocol replaced the old coarse `transition:*` OAuth scopes with granular +`repo:` / `rpc:` / `blob:` scopes. Granular scopes are precise but unreadable — +a full wisp.place login needs more than twenty of them, which makes for a +terrible consent screen. + +**Permission sets** solve that. A permission set is a Lexicon document of type +`permission-set` that bundles granular permissions behind one human-readable +title. A client asks for the bundle with `include:`, and the +authorization server resolves the NSID, shows the set's title and detail on the +consent screen, and expands it into the underlying granular scopes before +minting the token. + +## The sets + +wisp.place publishes four, all under the `place.wisp` authority: + +| NSID | Consent screen title | Grants | +| --- | --- | --- | +| `place.wisp.authSites` | Manage your wisp.place sites | write access to `place.wisp.fs`, `place.wisp.subfs`, `place.wisp.settings`, `place.wisp.domain` | +| `place.wisp.authWebhooks` | Manage your wisp.place webhooks | write access to `place.wisp.v2.wh` | +| `place.wisp.authHosting` | Use wisp.place hosting features | the `place.wisp.v2.{site,domain,privateSite}.*` XRPC methods | +| `place.wisp.authFullAccess` | Full wisp.place access | everything above, plus `place.wisp.v2.secret.*` | + +Third-party clients are welcome to request them. A read-only viewer wants none +of these; a deploy tool wants `place.wisp.authSites`. + +## What each client asks for + +The web editor talks to your PDS directly, so it only needs record writes: + +``` +atproto blob:*/* include:place.wisp.authSites include:place.wisp.authWebhooks +``` + +`wispctl` writes records itself but reaches domain and private-site management +through proxied XRPC calls to the hosting service: + +``` +atproto blob:*/* include:place.wisp.authSites include:place.wisp.authHosting +``` + +`atproto` and `blob:*/*` are always requested directly — neither can live +inside a permission set. + +## How resolution works + +An authorization server resolves `include:place.wisp.authSites` like this: + +1. The NSID's authority is `wisp.place` (the NSID reversed, minus the name). +2. DNS `TXT _lexicon.wisp.place` → `did=did:plc:7puq73yz2hkvbcpdhnsze2qw`. +3. `com.atproto.repo.getRecord` on that repo, collection + `com.atproto.lexicon.schema`, rkey `place.wisp.authSites`. + +If any set in a request fails to resolve, the whole authorization request is +rejected with `invalid_scope` — the server never guesses. + +Two rules constrain what a set may contain: + +- It may only reference NSIDs under its own authority. `place.wisp.authSites` + can grant `repo:place.wisp.fs`, but never `repo:app.bsky.feed.post`. +- `rpc` permissions may not pin a concrete audience. wisp.place sets use + `aud: "*"` so that `wispctl --service did:web:my-instance.example` keeps + working against a self-hosted hosting service without re-authorizing. + +## Fallback for older servers + +Two things can go wrong on an authorization server that has not caught up: + +- It rejects the pushed authorization request outright. The client retries with + the granular expansion of the same sets. +- It accepts the request and *silently drops* the `include:` values it does not + understand, handing back a session that can not write anything. The client + catches this after the callback by comparing the granted scope against what + it actually needs, then re-authorizes with the granular scopes. + +An authorization server rejects any requested scope value the client did not +declare up front, so the two strategies have to be declared somewhere. The web +editor declares the union in its hosted client metadata, which costs nothing. +`wispctl` is a loopback client, whose `client_id` *contains* its declared +scopes — declaring both there would make every authorization URL the user sees +several times longer, so it builds a separate client per strategy and records +on the account which one minted the session. Restoring has to rebuild the same +client identity or the token refresh is rejected. + +Note that the granted scope on a token is always the *expanded* granular form, +never the `include:` value that was requested. Anything checking "did I get +what I asked for" has to compare meaning, not scope strings. + +## Publishing + +The sets live in `packages/@wispplace/constants/src/oauth-scopes.ts` so the +clients and the published records cannot drift. To regenerate +`lexicons/permissions/*.json` and publish the records: + +```bash +bun run publish:permission-sets --write # regenerate the JSON only +WISP_PUBLISH_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx bun run publish:permission-sets +``` + +The app password must belong to the account that `_lexicon.wisp.place` points +at. Publishing is idempotent — unchanged sets are skipped. diff --git a/lexicons/permissions/auth-full-access.json b/lexicons/permissions/auth-full-access.json new file mode 100644 index 0000000..43ee7d3 --- /dev/null +++ b/lexicons/permissions/auth-full-access.json @@ -0,0 +1,56 @@ +{ + "lexicon": 1, + "id": "place.wisp.authFullAccess", + "defs": { + "main": { + "type": "permission-set", + "title": "Full wisp.place access", + "detail": "Full control of your sites, domains, and webhooks.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "place.wisp.fs", + "place.wisp.subfs", + "place.wisp.settings", + "place.wisp.domain", + "place.wisp.v2.wh" + ], + "action": [ + "create", + "update", + "delete" + ] + }, + { + "type": "permission", + "resource": "rpc", + "aud": "*", + "lxm": [ + "place.wisp.v2.domain.addSite", + "place.wisp.v2.domain.claim", + "place.wisp.v2.domain.claimSubdomain", + "place.wisp.v2.domain.delete", + "place.wisp.v2.domain.getList", + "place.wisp.v2.domain.getStatus", + "place.wisp.v2.domain.verify", + "place.wisp.v2.privateSite.create", + "place.wisp.v2.privateSite.createShare", + "place.wisp.v2.privateSite.delete", + "place.wisp.v2.privateSite.list", + "place.wisp.v2.privateSite.listShares", + "place.wisp.v2.privateSite.revokeShare", + "place.wisp.v2.site.delete", + "place.wisp.v2.site.getDomains", + "place.wisp.v2.site.getList", + "place.wisp.v2.secret.create", + "place.wisp.v2.secret.delete", + "place.wisp.v2.secret.list", + "place.wisp.v2.secret.rotate" + ] + } + ] + } + } +} diff --git a/lexicons/permissions/auth-hosting.json b/lexicons/permissions/auth-hosting.json new file mode 100644 index 0000000..4d06b6f --- /dev/null +++ b/lexicons/permissions/auth-hosting.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "place.wisp.authHosting", + "defs": { + "main": { + "type": "permission-set", + "title": "Use wisp.place hosting features", + "detail": "Manage your sites, custom domains, and private site links.", + "permissions": [ + { + "type": "permission", + "resource": "rpc", + "aud": "*", + "lxm": [ + "place.wisp.v2.domain.addSite", + "place.wisp.v2.domain.claim", + "place.wisp.v2.domain.claimSubdomain", + "place.wisp.v2.domain.delete", + "place.wisp.v2.domain.getList", + "place.wisp.v2.domain.getStatus", + "place.wisp.v2.domain.verify", + "place.wisp.v2.privateSite.create", + "place.wisp.v2.privateSite.createShare", + "place.wisp.v2.privateSite.delete", + "place.wisp.v2.privateSite.list", + "place.wisp.v2.privateSite.listShares", + "place.wisp.v2.privateSite.revokeShare", + "place.wisp.v2.site.delete", + "place.wisp.v2.site.getDomains", + "place.wisp.v2.site.getList" + ] + } + ] + } + } +} diff --git a/lexicons/permissions/auth-sites.json b/lexicons/permissions/auth-sites.json new file mode 100644 index 0000000..21d4833 --- /dev/null +++ b/lexicons/permissions/auth-sites.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "place.wisp.authSites", + "defs": { + "main": { + "type": "permission-set", + "title": "Manage your wisp.place sites", + "detail": "Create, update, and delete your site files and settings.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "place.wisp.fs", + "place.wisp.subfs", + "place.wisp.settings", + "place.wisp.domain" + ], + "action": [ + "create", + "update", + "delete" + ] + } + ] + } + } +} diff --git a/lexicons/permissions/auth-webhooks.json b/lexicons/permissions/auth-webhooks.json new file mode 100644 index 0000000..0751ac6 --- /dev/null +++ b/lexicons/permissions/auth-webhooks.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "place.wisp.authWebhooks", + "defs": { + "main": { + "type": "permission-set", + "title": "Manage your wisp.place webhooks", + "detail": "Create, update, and delete your webhook subscriptions.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "place.wisp.v2.wh" + ], + "action": [ + "create", + "update", + "delete" + ] + } + ] + } + } +} diff --git a/package.json b/package.json index 5998244..683bee6 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "e2e:harness:down": "docker compose -p wispplace-monorepo-e2e -f docker-compose.dev.yml -f docker-compose.e2e.yml --profile dev down -v --remove-orphans", "codegen": "./scripts/codegen.sh", "download:aturi": "bun run scripts/download-place-wisp-fs.ts", + "publish:permission-sets": "bun run scripts/publish-permission-sets.ts", "publish:cli": "cd cli && bun run build && npm publish && cd ../packages/create-wisp && npm publish" }, "trustedDependencies": [ diff --git a/packages/@wispplace/constants/src/index.ts b/packages/@wispplace/constants/src/index.ts index 1a24d09..c894079 100644 --- a/packages/@wispplace/constants/src/index.ts +++ b/packages/@wispplace/constants/src/index.ts @@ -95,3 +95,6 @@ export const CLI_BINARIES = { 'linux-arm64': `${CLI_BINARY_BASE_URL}/wisp-cli-aarch64-linux`, 'linux-x86_64': `${CLI_BINARY_BASE_URL}/wisp-cli-x86_64-linux`, } as const + +// AT Protocol OAuth permission sets for the place.wisp.* namespace +export * from './oauth-scopes' diff --git a/packages/@wispplace/constants/src/oauth-scopes.test.ts b/packages/@wispplace/constants/src/oauth-scopes.test.ts new file mode 100644 index 0000000..c0b6ba8 --- /dev/null +++ b/packages/@wispplace/constants/src/oauth-scopes.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'bun:test' +import { + buildWispScopes, + expandPermissionSets, + missingCapabilities, + parseScopeValue, + permissionSetCapabilities, + WISP_APP_PERMISSION_SETS, + WISP_CLI_PERMISSION_SETS, + WISP_CLI_USED_LXMS, + WISP_PERMISSION_SET_FULL, + WISP_PERMISSION_SET_HOSTING, + WISP_PERMISSION_SET_SITES, + WISP_PERMISSION_SETS, + wispAppRequiredCapabilities, + wispCliRequiredCapabilities, +} from './oauth-scopes' + +/** + * What an authorization server actually puts in the token after resolving our + * `include:` values. Produced by `IncludeScope#toScopes` from + * `@atproto/oauth-scopes` against the published lexicons — note that a set + * collapses into one grouped scope per permission entry, not one scope per + * collection, which is why the checks below have to be semantic. + */ +const GRANTED_BY_SERVER = { + [WISP_PERMISSION_SET_SITES]: + 'repo?collection=place.wisp.domain&collection=place.wisp.fs&collection=place.wisp.settings&collection=place.wisp.subfs', + [WISP_PERMISSION_SET_HOSTING]: + 'rpc?lxm=place.wisp.v2.domain.addSite&lxm=place.wisp.v2.domain.claim&lxm=place.wisp.v2.domain.claimSubdomain&lxm=place.wisp.v2.domain.delete&lxm=place.wisp.v2.domain.getList&lxm=place.wisp.v2.domain.getStatus&lxm=place.wisp.v2.domain.verify&lxm=place.wisp.v2.privateSite.create&lxm=place.wisp.v2.privateSite.createShare&lxm=place.wisp.v2.privateSite.delete&lxm=place.wisp.v2.privateSite.list&lxm=place.wisp.v2.privateSite.listShares&lxm=place.wisp.v2.privateSite.revokeShare&lxm=place.wisp.v2.site.delete&lxm=place.wisp.v2.site.getDomains&lxm=place.wisp.v2.site.getList&aud=*', + 'place.wisp.authWebhooks': 'repo:place.wisp.v2.wh', +} + +describe('permission sets', () => { + it('only references NSIDs under the place.wisp authority', () => { + // A permission set may only grant access to resources in its own NSID + // group; anything else is dropped by the authorization server. + for (const nsid of Object.keys(WISP_PERMISSION_SETS)) { + expect(nsid.startsWith('place.wisp.')).toBe(true) + for (const capability of permissionSetCapabilities(nsid)) { + expect(capability.resource === 'repo' || capability.resource === 'rpc').toBe(true) + const target = capability.resource === 'repo' ? capability.collection : (capability as { lxm: string }).lxm + expect(target.startsWith('place.wisp.')).toBe(true) + } + } + }) + + it('never pins a concrete audience, which sets are not allowed to do', () => { + for (const set of Object.values(WISP_PERMISSION_SETS)) { + for (const permission of set.permissions) { + if (permission.resource === 'rpc') expect(permission.aud ?? '*').toBe('*') + } + } + }) + + it('makes authFullAccess a superset of every other set', () => { + const full = new Set(permissionSetCapabilities(WISP_PERMISSION_SET_FULL).map((c) => JSON.stringify(c))) + for (const nsid of Object.keys(WISP_PERMISSION_SETS)) { + if (nsid === WISP_PERMISSION_SET_FULL) continue + for (const capability of permissionSetCapabilities(nsid)) { + expect(full.has(JSON.stringify(capability))).toBe(true) + } + } + }) +}) + +describe('scope strings', () => { + it('asks for permission sets and declares the fallback alongside them', () => { + const { preferred, legacy, metadata } = buildWispScopes([...WISP_CLI_PERMISSION_SETS]) + + expect(preferred.split(' ')).toContain('include:place.wisp.authSites') + expect(preferred).not.toContain('repo:place.wisp.fs') + expect(legacy.split(' ')).toContain('repo:place.wisp.fs') + expect(legacy).not.toContain('include:') + + // The authorization server rejects any requested value not declared in + // the client metadata, so the union has to cover both strategies. + for (const scope of [...preferred.split(' '), ...legacy.split(' ')]) { + expect(metadata.split(' ')).toContain(scope) + } + }) + + it('keeps atproto and the blob scope out of the sets', () => { + // Neither can live inside a permission set, so both must be requested + // directly every time. + const { preferred, legacy } = buildWispScopes([...WISP_APP_PERMISSION_SETS]) + for (const scope of [preferred, legacy]) { + expect(scope.split(' ')).toContain('atproto') + expect(scope.split(' ')).toContain('blob:*/*') + } + expect(expandPermissionSets([WISP_PERMISSION_SET_SITES])).not.toContain('atproto') + }) +}) + +describe('parseScopeValue', () => { + it('reads the positional form', () => { + expect(parseScopeValue('repo:place.wisp.fs')).toMatchObject({ prefix: 'repo', positional: 'place.wisp.fs' }) + }) + + it('reads the grouped form', () => { + const parsed = parseScopeValue('rpc?lxm=place.wisp.v2.site.getList&aud=*') + expect(parsed.prefix).toBe('rpc') + expect(parsed.params?.getAll('lxm')).toEqual(['place.wisp.v2.site.getList']) + expect(parsed.params?.get('aud')).toBe('*') + }) + + it('reads a bare scope value', () => { + expect(parseScopeValue('atproto')).toEqual({ prefix: 'atproto' }) + }) +}) + +describe('missingCapabilities', () => { + const grantedForSets = (nsids: readonly string[]) => + ['atproto', 'blob:*/*', ...nsids.map((nsid) => GRANTED_BY_SERVER[nsid as keyof typeof GRANTED_BY_SERVER])].join(' ') + + it('accepts the grouped scopes a server expands our sets into', () => { + expect(missingCapabilities(grantedForSets(WISP_APP_PERMISSION_SETS), wispAppRequiredCapabilities())).toEqual([]) + expect(missingCapabilities(grantedForSets(WISP_CLI_PERMISSION_SETS), wispCliRequiredCapabilities())).toEqual([]) + }) + + it('accepts the granular fallback scopes', () => { + const legacy = buildWispScopes([...WISP_CLI_PERMISSION_SETS]).legacy + expect(missingCapabilities(legacy, wispCliRequiredCapabilities())).toEqual([]) + }) + + it('reports everything when a server silently ignored the include: values', () => { + // This is the failure mode the login retry exists for: the request is + // accepted, but nothing in it was understood. + const missing = missingCapabilities('atproto blob:*/*', wispCliRequiredCapabilities()) + expect(missing.length).toBe(wispCliRequiredCapabilities().length - 1) + }) + + it('rejects a repo scope that is missing an action', () => { + expect( + missingCapabilities('repo:place.wisp.fs?action=create&action=update', [ + { resource: 'repo', collection: 'place.wisp.fs', action: 'delete' }, + ]), + ).toHaveLength(1) + expect( + missingCapabilities('repo:place.wisp.fs?action=create&action=update', [ + { resource: 'repo', collection: 'place.wisp.fs', action: 'update' }, + ]), + ).toEqual([]) + }) + + it('treats a wildcard grant as covering everything', () => { + expect( + missingCapabilities('repo:*', [{ resource: 'repo', collection: 'place.wisp.fs', action: 'delete' }]), + ).toEqual([]) + }) + + it('does not accept a service-specific grant where any service is needed', () => { + // The CLI can be pointed at a self-hosted service after login, so its rpc + // capabilities ask for `aud=*` and a pinned audience is not enough. + const pinned = 'rpc:place.wisp.v2.site.getList?aud=did:web:wisp.place%23wisp_xrpc' + expect( + missingCapabilities(pinned, [{ resource: 'rpc', lxm: 'place.wisp.v2.site.getList', aud: '*' }]), + ).toHaveLength(1) + expect( + missingCapabilities(pinned, [ + { resource: 'rpc', lxm: 'place.wisp.v2.site.getList', aud: 'did:web:wisp.place#wisp_xrpc' }, + ]), + ).toEqual([]) + }) + + it('matches blob scopes by media type', () => { + expect(missingCapabilities('blob:image/*', [{ resource: 'blob', mime: 'image/png' }])).toEqual([]) + expect(missingCapabilities('blob:image/*', [{ resource: 'blob', mime: 'text/html' }])).toHaveLength(1) + }) +}) + +describe('upgrade compatibility', () => { + // The exact scope strings shipped before permission sets existed. Sessions + // minted with these must keep working, or every user is silently forced + // through a re-login on upgrade. + const PRE_PERMISSION_SET_CLI_SCOPE = [ + 'atproto', + 'repo:place.wisp.fs', + 'repo:place.wisp.subfs', + 'repo:place.wisp.settings', + 'blob:*/*', + ...WISP_CLI_USED_LXMS.map((lxm) => `rpc:${lxm}?aud=*`), + ].join(' ') + + const PRE_PERMISSION_SET_APP_SCOPE = + 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings repo:place.wisp.v2.wh blob:*/*' + + it('still accepts sessions minted before permission sets', () => { + expect(missingCapabilities(PRE_PERMISSION_SET_CLI_SCOPE, wispCliRequiredCapabilities())).toEqual([]) + expect(missingCapabilities(PRE_PERMISSION_SET_APP_SCOPE, wispAppRequiredCapabilities())).toEqual([]) + }) + + it('requires no more than the sets grant', () => { + const granted = new Set( + [...WISP_CLI_PERMISSION_SETS.flatMap(permissionSetCapabilities), { resource: 'blob', mime: '*/*' }].map((c) => + JSON.stringify(c), + ), + ) + for (const capability of wispCliRequiredCapabilities()) { + expect(granted.has(JSON.stringify(capability))).toBe(true) + } + }) +}) diff --git a/packages/@wispplace/constants/src/oauth-scopes.ts b/packages/@wispplace/constants/src/oauth-scopes.ts new file mode 100644 index 0000000..b06d7be --- /dev/null +++ b/packages/@wispplace/constants/src/oauth-scopes.ts @@ -0,0 +1,401 @@ +/** + * AT Protocol OAuth permission sets for the `place.wisp.*` namespace. + * + * Background: atproto replaced the coarse `transition:*` scopes with granular + * `repo:`/`rpc:`/`blob:` scopes, and then added *permission sets* on top — a + * Lexicon document of type `permission-set` that bundles granular permissions + * behind one human-readable name. A client asks for a set with + * `include:`; the authorization server resolves `` through Lexicon + * resolution (DNS `_lexicon.` -> a repo -> the + * `com.atproto.lexicon.schema` record whose rkey is the NSID), shows the set's + * `title`/`detail` on the consent screen, and *expands* the `include:` value + * into the underlying granular scopes before minting the token. + * + * Two consequences drive the design of this file: + * + * 1. The access token's granted `scope` contains the **expanded** granular + * scopes, never the `include:` value we asked for. So a client can not + * check "did I get what I asked for" with string equality against the + * requested scope — it has to check semantically. That is what + * {@link missingCapabilities} is for. + * 2. An authorization server that supports granular scopes but predates + * permission sets will silently ignore an unknown `include:` value and + * hand back a session with none of the permissions. Clients therefore keep + * the pre-expansion scope string around ({@link expandPermissionSets}) and + * retry with it when the granted scope comes back short. + * + * Permission sets may only reference NSIDs under their own authority + * (`place.wisp.*` here), and may only contain `repo`/`rpc` permissions — + * `atproto` and the wildcard `blob:` scope always have to be requested + * alongside the `include:` value. + */ + +export type RepoAction = 'create' | 'update' | 'delete' + +/** All repo actions, in the order the atproto scope parser canonicalizes them. */ +export const REPO_ACTIONS: readonly RepoAction[] = ['create', 'update', 'delete'] + +export interface LexiconRepoPermission { + type: 'permission' + resource: 'repo' + collection: string[] + action?: RepoAction[] +} + +export interface LexiconRpcPermission { + type: 'permission' + resource: 'rpc' + lxm: string[] + /** `*` means "any service". Concrete audiences are rejected inside sets. */ + aud?: string + /** Take the audience from the `?aud=` param on the `include:` scope. */ + inheritAud?: boolean +} + +export type LexiconPermission = LexiconRepoPermission | LexiconRpcPermission + +export interface LexiconPermissionSet { + type: 'permission-set' + title: string + detail: string + permissions: LexiconPermission[] +} + +/** Repo collections that hold a user's sites. */ +export const WISP_SITE_COLLECTIONS = [ + 'place.wisp.fs', + 'place.wisp.subfs', + 'place.wisp.settings', + 'place.wisp.domain', +] as const + +/** Repo collections that hold a user's webhook subscriptions. */ +export const WISP_WEBHOOK_COLLECTIONS = ['place.wisp.v2.wh'] as const + +/** XRPC methods the wisp.place hosting service exposes to site owners. */ +export const WISP_HOSTING_LXMS = [ + 'place.wisp.v2.domain.addSite', + 'place.wisp.v2.domain.claim', + 'place.wisp.v2.domain.claimSubdomain', + 'place.wisp.v2.domain.delete', + 'place.wisp.v2.domain.getList', + 'place.wisp.v2.domain.getStatus', + 'place.wisp.v2.domain.verify', + 'place.wisp.v2.privateSite.create', + 'place.wisp.v2.privateSite.createShare', + 'place.wisp.v2.privateSite.delete', + 'place.wisp.v2.privateSite.list', + 'place.wisp.v2.privateSite.listShares', + 'place.wisp.v2.privateSite.revokeShare', + 'place.wisp.v2.site.delete', + 'place.wisp.v2.site.getDomains', + 'place.wisp.v2.site.getList', +] as const + +/** + * The subset of {@link WISP_HOSTING_LXMS} the CLI actually calls today. + * + * The set grants more than this, but the CLI only *requires* what it uses, so + * a session minted before the permission sets existed still satisfies the + * check and upgrading does not force everyone through a re-login. + */ +export const WISP_CLI_USED_LXMS = WISP_HOSTING_LXMS.filter((lxm) => lxm !== 'place.wisp.v2.site.getDomains') + +/** XRPC methods for webhook signing secrets. */ +export const WISP_SECRET_LXMS = [ + 'place.wisp.v2.secret.create', + 'place.wisp.v2.secret.delete', + 'place.wisp.v2.secret.list', + 'place.wisp.v2.secret.rotate', +] as const + +export const WISP_PERMISSION_SET_SITES = 'place.wisp.authSites' +export const WISP_PERMISSION_SET_WEBHOOKS = 'place.wisp.authWebhooks' +export const WISP_PERMISSION_SET_HOSTING = 'place.wisp.authHosting' +export const WISP_PERMISSION_SET_FULL = 'place.wisp.authFullAccess' + +/** + * The permission sets published at + * `at://did:plc:.../com.atproto.lexicon.schema/`. + * + * `aud: '*'` rather than `inheritAud: true` is deliberate: the hosting service + * is self-hostable, so the CLI can be pointed at a different service DID with + * `--service` *after* the session was minted. A set that pinned the audience + * would force a re-authorization every time that flag changed. + */ +export const WISP_PERMISSION_SETS: Record = { + [WISP_PERMISSION_SET_SITES]: { + type: 'permission-set', + title: 'Manage your wisp.place sites', + detail: 'Create, update, and delete your site files and settings.', + permissions: [ + { + type: 'permission', + resource: 'repo', + collection: [...WISP_SITE_COLLECTIONS], + action: [...REPO_ACTIONS], + }, + ], + }, + [WISP_PERMISSION_SET_WEBHOOKS]: { + type: 'permission-set', + title: 'Manage your wisp.place webhooks', + detail: 'Create, update, and delete your webhook subscriptions.', + permissions: [ + { + type: 'permission', + resource: 'repo', + collection: [...WISP_WEBHOOK_COLLECTIONS], + action: [...REPO_ACTIONS], + }, + ], + }, + [WISP_PERMISSION_SET_HOSTING]: { + type: 'permission-set', + title: 'Use wisp.place hosting features', + detail: 'Manage your sites, custom domains, and private site links.', + permissions: [ + { + type: 'permission', + resource: 'rpc', + aud: '*', + lxm: [...WISP_HOSTING_LXMS], + }, + ], + }, + [WISP_PERMISSION_SET_FULL]: { + type: 'permission-set', + title: 'Full wisp.place access', + detail: 'Full control of your sites, domains, and webhooks.', + permissions: [ + { + type: 'permission', + resource: 'repo', + collection: [...WISP_SITE_COLLECTIONS, ...WISP_WEBHOOK_COLLECTIONS], + action: [...REPO_ACTIONS], + }, + { + type: 'permission', + resource: 'rpc', + aud: '*', + lxm: [...WISP_HOSTING_LXMS, ...WISP_SECRET_LXMS], + }, + ], + }, +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +export type WispCapability = + | { resource: 'repo'; collection: string; action: RepoAction } + | { resource: 'rpc'; lxm: string; aud: string } + | { resource: 'blob'; mime: string } + +/** Human-readable rendering, used in CLI warnings and server logs. */ +export function describeCapability(capability: WispCapability): string { + switch (capability.resource) { + case 'repo': + return `${capability.action} ${capability.collection} records` + case 'rpc': + return `call ${capability.lxm}` + case 'blob': + return `upload ${capability.mime} blobs` + } +} + +/** Every capability a permission set grants, flattened. */ +export function permissionSetCapabilities(nsid: string): WispCapability[] { + const set = WISP_PERMISSION_SETS[nsid] + if (!set) throw new Error(`Unknown wisp permission set: ${nsid}`) + + const capabilities: WispCapability[] = [] + for (const permission of set.permissions) { + if (permission.resource === 'repo') { + for (const collection of permission.collection) { + for (const action of permission.action ?? REPO_ACTIONS) { + capabilities.push({ resource: 'repo', collection, action }) + } + } + } else { + for (const lxm of permission.lxm) { + capabilities.push({ resource: 'rpc', lxm, aud: permission.aud ?? '*' }) + } + } + } + return capabilities +} + +// --------------------------------------------------------------------------- +// Scope strings +// --------------------------------------------------------------------------- + +/** `include:` values for the given sets. */ +export function includeScopes(nsids: readonly string[]): string[] { + return nsids.map((nsid) => `include:${nsid}`) +} + +/** + * The granular scopes an authorization server expands the given sets into. + * + * Used as the fallback scope for servers that do not resolve permission sets, + * so it has to stay semantically identical to the sets themselves. + */ +export function expandPermissionSets(nsids: readonly string[]): string[] { + const scopes = new Set() + for (const nsid of nsids) { + for (const permission of WISP_PERMISSION_SETS[nsid]?.permissions ?? []) { + if (permission.resource === 'repo') { + const actions = permission.action ?? REPO_ACTIONS + // The `action` param is omitted when it matches the default, which + // is what the atproto scope formatter does too. + const suffix = actions.length === REPO_ACTIONS.length ? '' : `?${actions.map((a) => `action=${a}`).join('&')}` + for (const collection of permission.collection) { + scopes.add(`repo:${collection}${suffix}`) + } + } else { + const aud = permission.aud ?? '*' + for (const lxm of permission.lxm) { + scopes.add(`rpc:${lxm}?aud=${aud}`) + } + } + } + } + return [...scopes] +} + +/** + * Build the two scope strings a wisp client needs: the permission-set request + * it prefers, and the pre-expansion fallback for servers that ignore it. + * + * `metadata` is the union of both, because an authorization server rejects any + * requested scope value that the client did not declare up front. + */ +export function buildWispScopes(nsids: readonly string[], extra: readonly string[] = ['atproto', 'blob:*/*']) { + const preferred = [...extra, ...includeScopes(nsids)] + const legacy = [...extra, ...expandPermissionSets(nsids)] + const metadata = [...new Set([...preferred, ...legacy])] + return { + preferred: preferred.join(' '), + legacy: legacy.join(' '), + metadata: metadata.join(' '), + } +} + +// --------------------------------------------------------------------------- +// Checking what was actually granted +// --------------------------------------------------------------------------- + +interface ParsedScope { + prefix: string + positional?: string + params?: URLSearchParams +} + +/** + * Split `repo:place.wisp.fs?action=create` into its parts, following the + * atproto scope syntax (optional positional value after `:`, optional query + * string after `?`, both percent-decoded). + */ +export function parseScopeValue(value: string): ParsedScope { + const paramIdx = value.indexOf('?') + const colonIdx = value.indexOf(':') + const prefixEnd = paramIdx === -1 ? colonIdx : colonIdx === -1 ? paramIdx : Math.min(paramIdx, colonIdx) + if (prefixEnd === -1) return { prefix: value } + + const prefix = value.slice(0, prefixEnd) + let positional: string | undefined + if (colonIdx !== -1 && (paramIdx === -1 || colonIdx < paramIdx)) { + const raw = paramIdx === -1 ? value.slice(colonIdx + 1) : value.slice(colonIdx + 1, paramIdx) + try { + positional = decodeURIComponent(raw) + } catch { + positional = raw + } + } + const params = + paramIdx !== -1 && paramIdx < value.length - 1 ? new URLSearchParams(value.slice(paramIdx + 1)) : undefined + return { prefix, positional, params } +} + +/** Values of a repeatable scope param, including the positional shorthand. */ +function scopeValues(parsed: ParsedScope, key: string): string[] { + const fromParams = parsed.params?.getAll(key) ?? [] + if (fromParams.length > 0) return fromParams + return parsed.positional !== undefined ? [parsed.positional] : [] +} + +function grantsCapability(parsed: ParsedScope, capability: WispCapability): boolean { + if (parsed.prefix !== capability.resource) return false + + switch (capability.resource) { + case 'repo': { + const collections = scopeValues(parsed, 'collection') + if (!collections.includes('*') && !collections.includes(capability.collection)) return false + const actions = parsed.params?.getAll('action') ?? [] + return actions.length === 0 || actions.includes(capability.action) + } + case 'rpc': { + const lxms = scopeValues(parsed, 'lxm') + if (!lxms.includes('*') && !lxms.includes(capability.lxm)) return false + const aud = parsed.params?.get('aud') + // A capability that names `*` needs a grant for every audience. + return aud === '*' || (capability.aud !== '*' && aud === capability.aud) + } + case 'blob': { + const accepts = scopeValues(parsed, 'accept') + if (accepts.length === 0) return false + const [type] = capability.mime.split('/') + return accepts.some((accept) => accept === '*/*' || accept === capability.mime || accept === `${type}/*`) + } + } +} + +/** Which of `required` the granted scope does not cover. */ +export function missingCapabilities( + grantedScope: string | undefined, + required: readonly WispCapability[], +): WispCapability[] { + const granted = (grantedScope ?? '').split(/\s+/).filter(Boolean).map(parseScopeValue) + return required.filter((capability) => !granted.some((parsed) => grantsCapability(parsed, capability))) +} + +// --------------------------------------------------------------------------- +// Per-client scope definitions +// --------------------------------------------------------------------------- + +/** + * main-app talks to the user's PDS directly (it *is* the wisp.place service), + * so it only ever needs repo writes plus blob uploads. + */ +export const WISP_APP_PERMISSION_SETS = [WISP_PERMISSION_SET_SITES, WISP_PERMISSION_SET_WEBHOOKS] as const + +/** + * The CLI writes records itself but reaches domain/private-site management + * through proxied XRPC calls to the hosting service. + */ +export const WISP_CLI_PERMISSION_SETS = [WISP_PERMISSION_SET_SITES, WISP_PERMISSION_SET_HOSTING] as const + +const BLOB_CAPABILITY: WispCapability = { resource: 'blob', mime: '*/*' } + +/** What main-app must actually be able to do for the editor to work. */ +export function wispAppRequiredCapabilities(): WispCapability[] { + return [...WISP_APP_PERMISSION_SETS.flatMap(permissionSetCapabilities), BLOB_CAPABILITY] +} + +/** + * What the CLI must actually be able to do for `deploy` and friends to work. + * + * Deliberately narrower than what {@link WISP_CLI_PERMISSION_SETS} grants — + * see {@link WISP_CLI_USED_LXMS}. Still wide enough to catch the failure that + * matters: an authorization server that ignored the `include:` values grants + * none of this. + */ +export function wispCliRequiredCapabilities(): WispCapability[] { + const repo = ['place.wisp.fs', 'place.wisp.subfs', 'place.wisp.settings'].flatMap((collection) => + REPO_ACTIONS.map((action): WispCapability => ({ resource: 'repo', collection, action })), + ) + const rpc = WISP_CLI_USED_LXMS.map((lxm): WispCapability => ({ resource: 'rpc', lxm, aud: '*' })) + return [...repo, ...rpc, BLOB_CAPABILITY] +} diff --git a/packages/create-wisp/package.json b/packages/create-wisp/package.json index bbc2959..3c2295a 100644 --- a/packages/create-wisp/package.json +++ b/packages/create-wisp/package.json @@ -1,6 +1,6 @@ { "name": "create-wisp", - "version": "1.1.5", + "version": "1.1.6", "description": "CLI for wisp.place - deploy static sites to the AT Protocol", "type": "module", "bin": { @@ -10,6 +10,6 @@ "bin.js" ], "dependencies": { - "wispctl": "^1.3.0" + "wispctl": "^1.3.1" } }