diff --git a/app/edit/actions.tsx b/app/edit/actions.tsx index 8b452c2..2b0e41b 100644 --- a/app/edit/actions.tsx +++ b/app/edit/actions.tsx @@ -42,10 +42,42 @@ export async function checkNsidAction(nsid: string): Promise { }; } +const LEXICON_SCOPE = "repo:com.atproto.lexicon.schema"; + +async function hasLexiconScope(): Promise { + const session = await getSessionAgent(); + if (!session) return false; + const info = await session.getTokenInfo(false); + const scopes = info.scope.split(" "); + return scopes.some((s) => s.startsWith(LEXICON_SCOPE)); +} + +async function parsePdsError( + res: Response +): Promise<{ success: false; error: string; needsReauth?: boolean }> { + const body = await res.text().catch(() => ""); + let parsed: { error?: string; message?: string } = {}; + try { + parsed = JSON.parse(body); + } catch {} + if (res.status === 403 && parsed.error === "ScopeMissingError") { + return { + success: false, + error: "Inlay needs permission to manage lexicons. Please re-authorize.", + needsReauth: true, + }; + } + return { success: false, error: parsed.message ?? `PDS error ${res.status}` }; +} + +export type LexiconSaveResult = + | { success: true } + | { success: false; error: string; needsReauth?: boolean }; + export async function saveLexiconAction( nsid: string, lexiconJson: string -): Promise<{ success: true } | { success: false; error: string }> { +): Promise { let raw: unknown; try { raw = JSON.parse(lexiconJson); @@ -67,6 +99,14 @@ export async function saveLexiconAction( const did = await getCurrentDid(); if (!did) return { success: false, error: "Not authenticated" }; + if (!(await hasLexiconScope())) { + return { + success: false, + error: "Inlay needs permission to manage lexicons. Please re-authorize.", + needsReauth: true, + }; + } + // Verify ownership const ownerDid = await resolveNamespaceOwner(nsid); if (ownerDid !== did) { @@ -77,7 +117,7 @@ export async function saveLexiconAction( if (!session) return { success: false, error: "No session" }; try { - await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { + const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -87,6 +127,9 @@ export async function saveLexiconAction( record: doc, }), }); + if (res.status !== 200) { + return parsePdsError(res); + } updateTag(`lexicon:${nsid}`); updateTag(`lexicon-authority:${nsid}`); return { success: true }; @@ -97,10 +140,18 @@ export async function saveLexiconAction( export async function deleteLexiconAction( nsid: string -): Promise<{ success: true } | { success: false; error: string }> { +): Promise { const did = await getCurrentDid(); if (!did) return { success: false, error: "Not authenticated" }; + if (!(await hasLexiconScope())) { + return { + success: false, + error: "Inlay needs permission to manage lexicons. Please re-authorize.", + needsReauth: true, + }; + } + const ownerDid = await resolveNamespaceOwner(nsid); if (ownerDid !== did) { return { success: false, error: "You don't own this namespace" }; @@ -110,15 +161,21 @@ export async function deleteLexiconAction( if (!session) return { success: false, error: "No session" }; try { - await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - repo: did, - collection: "com.atproto.lexicon.schema", - rkey: nsid, - }), - }); + const res = await session.fetchHandler( + "/xrpc/com.atproto.repo.deleteRecord", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repo: did, + collection: "com.atproto.lexicon.schema", + rkey: nsid, + }), + } + ); + if (res.status !== 200) { + return parsePdsError(res); + } updateTag(`lexicon:${nsid}`); updateTag(`lexicon-authority:${nsid}`); return { success: true }; diff --git a/app/edit/lexicon-modal.tsx b/app/edit/lexicon-modal.tsx index f051d7f..a04df6f 100644 --- a/app/edit/lexicon-modal.tsx +++ b/app/edit/lexicon-modal.tsx @@ -9,7 +9,9 @@ import { saveLexiconAction, deleteLexiconAction, recheckNamespaceAction, + type LexiconSaveResult, } from "./actions"; +import { getReauthorizeUrl } from "@/auth/actions"; import { GhostButton } from "@/app/ghost-button"; import { PrimaryButton } from "@/app/primary-button"; import { @@ -125,17 +127,24 @@ export function LexiconModal({ }, []); // Save - type SaveResult = - | { success: true } - | { success: false; error: string } - | null; const [saveResult, dispatchSave, isSaving] = useActionState( - async (_prev: SaveResult, json: string) => { + async (_prev: LexiconSaveResult | null, json: string) => { const result = await saveLexiconAction(nsid, json); if (result.success) { onRefresh(); onClose(); } + if (!result.success && result.needsReauth) { + const url = await getReauthorizeUrl( + window.location.pathname + window.location.search, + [ + "repo:com.atproto.lexicon.schema?action=create", + "repo:com.atproto.lexicon.schema?action=update", + "repo:com.atproto.lexicon.schema?action=delete", + ] + ); + window.location.href = url; + } return result; }, null diff --git a/auth/actions.ts b/auth/actions.ts index 6c41d31..6bbe563 100644 --- a/auth/actions.ts +++ b/auth/actions.ts @@ -63,6 +63,28 @@ export async function getLoginUrl(handle: string): Promise { } } +export async function getReauthorizeUrl( + returnUrl: string, + extraScopes?: string[] +): Promise { + const session = await getSession(); + const did = session.did; + if (!did) throw new Error("Not authenticated"); + + const scope = [ + "atproto", + "include:at.inlay.authFullPermissions", + ...(extraScopes ?? []), + ].join(" "); + + const client = await getOAuthClient(); + const url = await client.authorize(did, { + scope, + state: JSON.stringify({ returnUrl }), + }); + return url.toString(); +} + export async function logout() { const session = await getSession(); session.did = undefined; diff --git a/auth/client.ts b/auth/client.ts index 5ffb463..6d263d5 100644 --- a/auth/client.ts +++ b/auth/client.ts @@ -71,13 +71,21 @@ async function createOAuthClient(): Promise { const hasSigningKey = keyset && keyset.size > 0; + const BASE_SCOPE = "atproto include:at.inlay.authFullPermissions"; + const LEXICON_SCOPES = [ + "repo:com.atproto.lexicon.schema?action=create", + "repo:com.atproto.lexicon.schema?action=update", + "repo:com.atproto.lexicon.schema?action=delete", + ].join(" "); + const FULL_SCOPE = `${BASE_SCOPE} ${LEXICON_SCOPES}`; + const clientMetadata: OAuthClientMetadataInput = PUBLIC_URL ? { client_name: "Inlay", client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, client_uri: PUBLIC_URL, redirect_uris: [`${PUBLIC_URL}/oauth/callback`], - scope: "atproto include:at.inlay.authFullPermissions", + scope: FULL_SCOPE, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "web", @@ -90,9 +98,9 @@ async function createOAuthClient(): Promise { client_name: "Inlay (Development)", client_id: `http://localhost?redirect_uri=${encodeURIComponent( "http://127.0.0.1:3000/oauth/callback" - )}&scope=${encodeURIComponent("atproto include:at.inlay.authFullPermissions")}`, + )}&scope=${encodeURIComponent(FULL_SCOPE)}`, redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], - scope: "atproto include:at.inlay.authFullPermissions", + scope: FULL_SCOPE, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "web", diff --git a/auth/index.ts b/auth/index.ts index e2ddd36..f58eadd 100644 --- a/auth/index.ts +++ b/auth/index.ts @@ -13,4 +13,4 @@ export { getServiceJwt, } from "./session"; export type { SessionData } from "./session"; -export { getLoginUrl, logout } from "./actions"; +export { getLoginUrl, getReauthorizeUrl, logout } from "./actions";