@getrefino/onboarding 0.1.0-rc.5 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +16 -0
- package/dist/templates-hosted.d.ts +6 -3
- package/dist/templates-hosted.js +286 -54
- package/dist/templates-legacy.d.ts +16 -1
- package/dist/templates-legacy.js +311 -1
- package/dist/verify.js +30 -0
- package/package.json +2 -2
package/dist/apply.d.ts
CHANGED
|
@@ -16,6 +16,22 @@ export interface RefinoConfig {
|
|
|
16
16
|
readonly siteId: string;
|
|
17
17
|
readonly appUrl: string;
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* Article editing: which directories hold editable Markdown and MDX.
|
|
21
|
+
* Owner-written and never generated — Refino has no way to know which of a
|
|
22
|
+
* repository's Markdown files are articles the owner wants edited. It is
|
|
23
|
+
* merged like every other key the owner may have tuned, so a run of `init`
|
|
24
|
+
* leaves it exactly as it is. `refino verify` checks it.
|
|
25
|
+
* See `docs/content-editing.md`.
|
|
26
|
+
*/
|
|
27
|
+
readonly content?: {
|
|
28
|
+
readonly sources: readonly {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly dir: string;
|
|
31
|
+
readonly extensions?: readonly string[];
|
|
32
|
+
readonly fields?: Readonly<Record<string, string>>;
|
|
33
|
+
}[];
|
|
34
|
+
};
|
|
19
35
|
readonly framework: string;
|
|
20
36
|
readonly router: string;
|
|
21
37
|
readonly routes: readonly string[];
|
|
@@ -17,11 +17,14 @@ export declare const REFINO_SITE_ID_PATTERN: RegExp;
|
|
|
17
17
|
/** Public configuration; safe to ship to the browser and to commit. */
|
|
18
18
|
export declare const REFINO_SITE: (config: RefinoSiteConfig) => string;
|
|
19
19
|
/** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
|
|
20
|
-
export declare const REFINO_CLIENT = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(scope === \"one\" || scope === \"all\" ? { event: name, scope } : { event: name }),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n\n/**\n * Updates to this site's Refino integration, offered in the editor\n * toolbar. Asking is read-only. Nothing changes unless the owner presses\n * \"Update Refino\": Refino then commits the new Refino version to this\n * repository, and the site's normal deployment takes it from there. The\n * editor checks every answer itself, so this stays a plain pipe.\n */\nexport const refinoUpdates = {\n check: (): Promise<unknown> => updateRequest(\"GET\"),\n apply: (version: string): Promise<unknown> => updateRequest(\"POST\", JSON.stringify({ version })),\n};\n\nasync function updateRequest(method: \"GET\" | \"POST\", body?: string): Promise<unknown> {\n const session = readEditorSession();\n if (!session) return null;\n const headers: Record<string, string> = { accept: \"application/json\", authorization: `Bearer ${session.token}` };\n if (body) headers[\"content-type\"] = \"application/json\";\n try {\n const response = await fetch(`${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/update`, { method, mode: \"cors\", credentials: \"omit\", headers, body });\n return await response.json();\n } catch {\n // Unreachable or unreadable: the editor treats it as \"nothing to offer\", or as an unknown outcome.\n return null;\n }\n}\n";
|
|
20
|
+
export declare const REFINO_CLIENT = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\n/** The owner's entry point, the OAuth callback, and the handoff screen: one route. */\nexport const CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n // Browsers delete tab, newline and carriage return from a URL before they\n // resolve it, so \"/<tab>/example.test\" arrives at the address bar as\n // \"//example.test\": another origin, out of something that passed every\n // test above. A control character anywhere means the value is refused,\n // never repaired.\n for (let index = 0; index < value.length; index += 1) {\n const code = value.charCodeAt(index);\n if (code < 0x20 || code === 0x7f) return \"/\";\n }\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n\n/**\n * The three states /edit can be in. The page draws them; this module\n * decides which one it is, so restyling the page can never change where a\n * successful sign-in lands.\n */\nexport type EditorPhase =\n /** Leaving for Refino, or coming back from it: the browser is navigating. */\n | { readonly kind: \"authorizing\" }\n /** The handoff: this browser holds an editor session for this site. */\n | { readonly kind: \"ready\"; readonly openHref: string }\n | { readonly kind: \"error\"; readonly message: string };\n\n/** Where \"Open the site\" goes: the page the owner asked for, or the site's home. Never /edit, never another origin. */\nfunction openSiteHref(params: URLSearchParams): string {\n const requested = safeReturnTo(params.get(\"return_to\"));\n return requested === CALLBACK_PATH || requested.startsWith(CALLBACK_PATH + \"?\") ? \"/\" : requested;\n}\n\nlet inFlight: { readonly search: string; readonly phase: Promise<EditorPhase> } | null = null;\n\n/**\n * The one decision /edit makes, in one place and in one order:\n *\n * ?code&state finish the authorization Refino sent back\n * editor session already authorized: the handoff\n * ?error Refino refused; say so and offer another go\n * otherwise start the authorization\n *\n * A successful sign-in lands here, on the handoff, and /edit is safe to\n * revisit: it shows the same screen rather than starting anything. The one\n * exception is deliberate and comes from the URL the owner arrived on --\n * /edit?return_to=/pricing sends them back to /pricing when they are\n * through, which is how the editor asks for a fresh session mid-edit.\n * Nothing else decides the destination.\n */\nexport function resolveEditorPhase(search: string = window.location.search): Promise<EditorPhase> {\n // One resolution per URL. React runs an effect twice in development and the\n // authorization code is single-use, so a second call would consume nothing\n // and report a failed sign-in that did not happen.\n if (!inFlight || inFlight.search !== search) inFlight = { search, phase: resolvePhase(search) };\n return inFlight.phase;\n}\n\nasync function resolvePhase(search: string): Promise<EditorPhase> {\n const params = new URLSearchParams(search);\n if (params.has(\"code\") || params.has(\"state\")) {\n const result = await completeAuthorization(search);\n if (!result.ok) return { kind: \"error\", message: result.error };\n if (result.returnTo !== CALLBACK_PATH) {\n window.location.assign(result.returnTo);\n return { kind: \"authorizing\" };\n }\n return { kind: \"ready\", openHref: openSiteHref(params) };\n }\n if (readEditorSession()) return { kind: \"ready\", openHref: openSiteHref(params) };\n // Refino never sends a failure back to the site -- a refused authorization\n // stays on the Refino dashboard, where it can say what to do about it -- so\n // ?error here is somebody else's text and is not repeated back to the owner.\n if (params.get(\"error\")) return { kind: \"error\", message: \"Refino did not authorize this browser to edit the site.\" };\n await beginAuthorization(params.get(\"return_to\") ?? CALLBACK_PATH);\n return { kind: \"authorizing\" };\n}\n\n/**\n * End the session, in the only order that leaves no live credential behind:\n * this site's editor token lives in this browser and is cleared here, and\n * the Refino account session lives in a cookie on Refino's own origin and\n * can only be ended there -- so the browser is handed to Refino, which\n * confirms and signs the owner out. Signing out of Refino is not signing\n * out of GitHub, and it changes nothing about this site.\n */\nexport function signOut(): void {\n clearEditorSession();\n window.location.assign(`${REFINO_APP_URL}/signout`);\n}\n\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n const surface = event.properties.edit_surface;\n const payload: Record<string, string> = { event: name };\n if (scope === \"one\" || scope === \"all\") payload.scope = scope;\n // Which surface an edit started on: inline copy, an article's metadata, or\n // an article's body. An enum of three, never an id and never a word of the\n // text. Refino refuses anything it does not recognize.\n if (typeof surface === \"string\") payload.surface = surface;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(payload),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n\n/**\n * Updates to this site's Refino integration, offered in the editor\n * toolbar. Asking is read-only. Nothing changes unless the owner presses\n * \"Update Refino\": Refino then commits the new Refino version to this\n * repository, and the site's normal deployment takes it from there. The\n * editor checks every answer itself, so this stays a plain pipe.\n */\nexport const refinoUpdates = {\n check: (): Promise<unknown> => updateRequest(\"GET\"),\n apply: (version: string): Promise<unknown> => updateRequest(\"POST\", JSON.stringify({ version })),\n};\n\nasync function updateRequest(method: \"GET\" | \"POST\", body?: string): Promise<unknown> {\n const session = readEditorSession();\n if (!session) return null;\n const headers: Record<string, string> = { accept: \"application/json\", authorization: `Bearer ${session.token}` };\n if (body) headers[\"content-type\"] = \"application/json\";\n try {\n const response = await fetch(`${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/update`, { method, mode: \"cors\", credentials: \"omit\", headers, body });\n return await response.json();\n } catch {\n // Unreachable or unreadable: the editor treats it as \"nothing to offer\", or as an unknown outcome.\n return null;\n }\n}\n";
|
|
21
21
|
/** Provider wrapper: edit mode is decided in the browser from the stored session. */
|
|
22
22
|
export declare const COPY_EDITING_HOSTED = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession, refinoUpdates, reportEditorEvent } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit} onEvent={reportEditorEvent} updates={refinoUpdates}>\n {children}\n </RefinoProvider>\n );\n}\n";
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The /edit page: the owner's entry point and, once they are through, the
|
|
25
|
+
* handoff screen. Presentation only -- every decision is in refino-client.
|
|
26
|
+
*/
|
|
27
|
+
export declare const EDIT_PAGE_HOSTED = "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { beginAuthorization, CALLBACK_PATH, resolveEditorPhase, signOut } from \"./refino-client\";\nimport type { EditorPhase } from \"./refino-client\";\n\n/**\n * Owner entry point for Refino-hosted editing. Visiting /edit sends the\n * owner to Refino to sign in and authorize this site; Refino sends them\n * back here with a one-time code, which is exchanged for an editor session\n * and scrubbed from the URL. The page then stays here and says so: /edit\n * with an editor session is the handoff, and it is safe to revisit.\n *\n * This file is yours. Restyle it, rewrite the markup, drop the styles below\n * and use your own -- keep the three phases, the link to `phase.openHref`\n * and the sign-out button, and the flow keeps working.\n */\nconst STYLES = `\n.refino-edit {\n --refino-ink: #0f172a;\n --refino-ink-soft: #334155;\n --refino-muted: #64748b;\n --refino-line: #e2e8f0;\n --refino-accent: #0f766e;\n --refino-accent-strong: #115e59;\n --refino-accent-soft: #ccfbf1;\n display: flex;\n min-height: 100vh;\n align-items: center;\n justify-content: center;\n margin: 0;\n padding: 24px 16px 56px;\n background: #f8fafc;\n color: var(--refino-ink);\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n line-height: 1.55;\n -webkit-font-smoothing: antialiased;\n}\n.refino-edit *,\n.refino-edit *::before,\n.refino-edit *::after { box-sizing: border-box; }\n.refino-edit__card {\n width: 100%;\n /* Roughly 45 characters a line: long enough to read, short enough to scan. */\n max-width: 26rem;\n padding: 28px 28px 20px;\n border: 1px solid var(--refino-line);\n border-radius: 12px;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 10px 28px rgba(15, 23, 42, 0.06);\n}\n.refino-edit__eyebrow {\n margin: 0 0 20px;\n font-size: 13px;\n font-weight: 700;\n letter-spacing: 0.01em;\n color: var(--refino-muted);\n}\n.refino-edit__title {\n display: flex;\n align-items: center;\n gap: 10px;\n margin: 0 0 8px;\n font-size: 22px;\n font-weight: 700;\n line-height: 1.25;\n letter-spacing: -0.01em;\n}\n.refino-edit__check {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n border-radius: 999px;\n background: var(--refino-accent-soft);\n color: var(--refino-accent-strong);\n font-size: 15px;\n}\n.refino-edit__body {\n margin: 0 0 20px;\n color: var(--refino-ink-soft);\n}\n.refino-edit__cta {\n display: block;\n width: 100%;\n padding: 11px 16px;\n border: 1px solid var(--refino-accent);\n border-radius: 8px;\n background: var(--refino-accent);\n color: #ffffff;\n font: inherit;\n font-weight: 600;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n}\n.refino-edit__cta:hover { background: var(--refino-accent-strong); border-color: var(--refino-accent-strong); }\n.refino-edit__footer {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 2px 10px;\n margin-top: 20px;\n padding-top: 16px;\n border-top: 1px solid var(--refino-line);\n}\n.refino-edit__signout {\n padding: 0;\n border: 0;\n border-radius: 4px;\n background: none;\n color: var(--refino-accent-strong);\n font: inherit;\n font-weight: 600;\n text-decoration: underline;\n text-underline-offset: 3px;\n cursor: pointer;\n}\n.refino-edit__signout:hover { color: var(--refino-ink); }\n.refino-edit__hint {\n color: var(--refino-muted);\n font-size: 13px;\n}\n.refino-edit__cta:focus-visible,\n.refino-edit__signout:focus-visible {\n outline: 2px solid var(--refino-accent-strong);\n outline-offset: 2px;\n}\n@media (max-width: 420px) {\n .refino-edit__card { padding: 22px 20px 16px; }\n .refino-edit__title { font-size: 20px; }\n}\n`;\n\nexport default function EditPage() {\n const [phase, setPhase] = useState<EditorPhase>({ kind: \"authorizing\" });\n\n useEffect(() => {\n let cancelled = false;\n void resolveEditorPhase().then((next) => {\n if (!cancelled) setPhase(next);\n });\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <main className=\"refino-edit\">\n <style>{STYLES}</style>\n <div className=\"refino-edit__card\" aria-live=\"polite\">\n <p className=\"refino-edit__eyebrow\">Refino</p>\n\n {phase.kind === \"authorizing\" && (\n <>\n <h1 className=\"refino-edit__title\">Signing you in\u2026</h1>\n <p className=\"refino-edit__body\">Refino is checking that you may edit this site.</p>\n </>\n )}\n\n {phase.kind === \"ready\" && (\n <>\n <h1 className=\"refino-edit__title\">\n <span className=\"refino-edit__check\" aria-hidden=\"true\">\u2713</span>\n Edit mode is on\n </h1>\n <p className=\"refino-edit__body\">Open your site, click any text you want to change, and press Save when you are done.</p>\n <a className=\"refino-edit__cta\" href={phase.openHref}>\n Open the site\n </a>\n </>\n )}\n\n {phase.kind === \"error\" && (\n <>\n <h1 className=\"refino-edit__title\">Could not start editing</h1>\n <p className=\"refino-edit__body\" role=\"alert\">\n {phase.message}\n </p>\n <button className=\"refino-edit__cta\" type=\"button\" onClick={() => void beginAuthorization(CALLBACK_PATH)}>\n Try again\n </button>\n </>\n )}\n\n {phase.kind !== \"authorizing\" && (\n <p className=\"refino-edit__footer\">\n <button className=\"refino-edit__signout\" type=\"button\" onClick={signOut}>\n Sign out\n </button>\n <span className=\"refino-edit__hint\">Ends edit mode here and your Refino session.</span>\n </p>\n )}\n </div>\n </main>\n );\n}\n";
|
|
25
28
|
/** Next.js App Router route file for /edit: re-exports the generated client page. */
|
|
26
29
|
export declare const EDIT_ROUTE_NEXT_HOSTED: (pageImport: string) => string;
|
|
27
30
|
/** Next.js Pages Router page for /edit. */
|
package/dist/templates-hosted.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { isInstallMethod } from "./install-method.js";
|
|
13
13
|
import { TOOL_VERSION } from "./constants.js";
|
|
14
14
|
import { createTemplateFactory } from "./template-file.js";
|
|
15
|
-
import { COPY_EDITING_HOSTED_RC1, COPY_EDITING_HOSTED_RC4, RC1, RC4, REFINO_CLIENT_RC1, REFINO_CLIENT_RC4, REFINO_SITE_RC1 } from "./templates-legacy.js";
|
|
15
|
+
import { COPY_EDITING_HOSTED_RC1, COPY_EDITING_HOSTED_RC4, EDIT_PAGE_HOSTED_RC5, RC1, RC4, RC5, REFINO_CLIENT_RC1, REFINO_CLIENT_RC4, REFINO_CLIENT_RC5, REFINO_SITE_RC1 } from "./templates-legacy.js";
|
|
16
16
|
export const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
|
|
17
17
|
export const REFINO_SITE_ID_PATTERN = /^site_[a-f0-9]{32}$/;
|
|
18
18
|
/** Public configuration; safe to ship to the browser and to commit. */
|
|
@@ -53,7 +53,8 @@ import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
|
|
|
53
53
|
|
|
54
54
|
const PKCE_KEY = "refino.pkce";
|
|
55
55
|
const SESSION_KEY = "refino.editor";
|
|
56
|
-
|
|
56
|
+
/** The owner's entry point, the OAuth callback, and the handoff screen: one route. */
|
|
57
|
+
export const CALLBACK_PATH = "/edit";
|
|
57
58
|
|
|
58
59
|
export interface EditorSession {
|
|
59
60
|
readonly token: string;
|
|
@@ -106,6 +107,15 @@ function readJson<T>(key: string): T | null {
|
|
|
106
107
|
/** Only a path on this site, never another origin. */
|
|
107
108
|
export function safeReturnTo(value: string | null | undefined): string {
|
|
108
109
|
if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
|
|
110
|
+
// Browsers delete tab, newline and carriage return from a URL before they
|
|
111
|
+
// resolve it, so "/<tab>/example.test" arrives at the address bar as
|
|
112
|
+
// "//example.test": another origin, out of something that passed every
|
|
113
|
+
// test above. A control character anywhere means the value is refused,
|
|
114
|
+
// never repaired.
|
|
115
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
116
|
+
const code = value.charCodeAt(index);
|
|
117
|
+
if (code < 0x20 || code === 0x7f) return "/";
|
|
118
|
+
}
|
|
109
119
|
return value;
|
|
110
120
|
}
|
|
111
121
|
|
|
@@ -197,6 +207,82 @@ export async function completeAuthorization(search: string = window.location.sea
|
|
|
197
207
|
return { ok: true, returnTo: pending.returnTo };
|
|
198
208
|
}
|
|
199
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The three states /edit can be in. The page draws them; this module
|
|
212
|
+
* decides which one it is, so restyling the page can never change where a
|
|
213
|
+
* successful sign-in lands.
|
|
214
|
+
*/
|
|
215
|
+
export type EditorPhase =
|
|
216
|
+
/** Leaving for Refino, or coming back from it: the browser is navigating. */
|
|
217
|
+
| { readonly kind: "authorizing" }
|
|
218
|
+
/** The handoff: this browser holds an editor session for this site. */
|
|
219
|
+
| { readonly kind: "ready"; readonly openHref: string }
|
|
220
|
+
| { readonly kind: "error"; readonly message: string };
|
|
221
|
+
|
|
222
|
+
/** Where "Open the site" goes: the page the owner asked for, or the site's home. Never /edit, never another origin. */
|
|
223
|
+
function openSiteHref(params: URLSearchParams): string {
|
|
224
|
+
const requested = safeReturnTo(params.get("return_to"));
|
|
225
|
+
return requested === CALLBACK_PATH || requested.startsWith(CALLBACK_PATH + "?") ? "/" : requested;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let inFlight: { readonly search: string; readonly phase: Promise<EditorPhase> } | null = null;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The one decision /edit makes, in one place and in one order:
|
|
232
|
+
*
|
|
233
|
+
* ?code&state finish the authorization Refino sent back
|
|
234
|
+
* editor session already authorized: the handoff
|
|
235
|
+
* ?error Refino refused; say so and offer another go
|
|
236
|
+
* otherwise start the authorization
|
|
237
|
+
*
|
|
238
|
+
* A successful sign-in lands here, on the handoff, and /edit is safe to
|
|
239
|
+
* revisit: it shows the same screen rather than starting anything. The one
|
|
240
|
+
* exception is deliberate and comes from the URL the owner arrived on --
|
|
241
|
+
* /edit?return_to=/pricing sends them back to /pricing when they are
|
|
242
|
+
* through, which is how the editor asks for a fresh session mid-edit.
|
|
243
|
+
* Nothing else decides the destination.
|
|
244
|
+
*/
|
|
245
|
+
export function resolveEditorPhase(search: string = window.location.search): Promise<EditorPhase> {
|
|
246
|
+
// One resolution per URL. React runs an effect twice in development and the
|
|
247
|
+
// authorization code is single-use, so a second call would consume nothing
|
|
248
|
+
// and report a failed sign-in that did not happen.
|
|
249
|
+
if (!inFlight || inFlight.search !== search) inFlight = { search, phase: resolvePhase(search) };
|
|
250
|
+
return inFlight.phase;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function resolvePhase(search: string): Promise<EditorPhase> {
|
|
254
|
+
const params = new URLSearchParams(search);
|
|
255
|
+
if (params.has("code") || params.has("state")) {
|
|
256
|
+
const result = await completeAuthorization(search);
|
|
257
|
+
if (!result.ok) return { kind: "error", message: result.error };
|
|
258
|
+
if (result.returnTo !== CALLBACK_PATH) {
|
|
259
|
+
window.location.assign(result.returnTo);
|
|
260
|
+
return { kind: "authorizing" };
|
|
261
|
+
}
|
|
262
|
+
return { kind: "ready", openHref: openSiteHref(params) };
|
|
263
|
+
}
|
|
264
|
+
if (readEditorSession()) return { kind: "ready", openHref: openSiteHref(params) };
|
|
265
|
+
// Refino never sends a failure back to the site -- a refused authorization
|
|
266
|
+
// stays on the Refino dashboard, where it can say what to do about it -- so
|
|
267
|
+
// ?error here is somebody else's text and is not repeated back to the owner.
|
|
268
|
+
if (params.get("error")) return { kind: "error", message: "Refino did not authorize this browser to edit the site." };
|
|
269
|
+
await beginAuthorization(params.get("return_to") ?? CALLBACK_PATH);
|
|
270
|
+
return { kind: "authorizing" };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* End the session, in the only order that leaves no live credential behind:
|
|
275
|
+
* this site's editor token lives in this browser and is cleared here, and
|
|
276
|
+
* the Refino account session lives in a cookie on Refino's own origin and
|
|
277
|
+
* can only be ended there -- so the browser is handed to Refino, which
|
|
278
|
+
* confirms and signs the owner out. Signing out of Refino is not signing
|
|
279
|
+
* out of GitHub, and it changes nothing about this site.
|
|
280
|
+
*/
|
|
281
|
+
export function signOut(): void {
|
|
282
|
+
clearEditorSession();
|
|
283
|
+
window.location.assign(\`\${REFINO_APP_URL}/signout\`);
|
|
284
|
+
}
|
|
285
|
+
|
|
200
286
|
/**
|
|
201
287
|
* The three editor events this site reports, and the only ones it ever
|
|
202
288
|
* sends. There is no analytics SDK on this site: no autocapture, no session
|
|
@@ -226,6 +312,13 @@ export function reportEditorEvent(event: EditorEventLike): void {
|
|
|
226
312
|
if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
|
|
227
313
|
const name = event.name as ReportableEvent;
|
|
228
314
|
const scope = event.properties.scope;
|
|
315
|
+
const surface = event.properties.edit_surface;
|
|
316
|
+
const payload: Record<string, string> = { event: name };
|
|
317
|
+
if (scope === "one" || scope === "all") payload.scope = scope;
|
|
318
|
+
// Which surface an edit started on: inline copy, an article's metadata, or
|
|
319
|
+
// an article's body. An enum of three, never an id and never a word of the
|
|
320
|
+
// text. Refino refuses anything it does not recognize.
|
|
321
|
+
if (typeof surface === "string") payload.surface = surface;
|
|
229
322
|
try {
|
|
230
323
|
void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
|
|
231
324
|
method: "POST",
|
|
@@ -233,7 +326,7 @@ export function reportEditorEvent(event: EditorEventLike): void {
|
|
|
233
326
|
credentials: "omit",
|
|
234
327
|
keepalive: true,
|
|
235
328
|
headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
|
|
236
|
-
body: JSON.stringify(
|
|
329
|
+
body: JSON.stringify(payload),
|
|
237
330
|
}).catch(() => {});
|
|
238
331
|
} catch {
|
|
239
332
|
// Never let reporting affect editing.
|
|
@@ -316,76 +409,207 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
|
|
|
316
409
|
);
|
|
317
410
|
}
|
|
318
411
|
`;
|
|
319
|
-
/**
|
|
412
|
+
/**
|
|
413
|
+
* The /edit page: the owner's entry point and, once they are through, the
|
|
414
|
+
* handoff screen. Presentation only -- every decision is in refino-client.
|
|
415
|
+
*/
|
|
320
416
|
export const EDIT_PAGE_HOSTED = `"use client";
|
|
321
417
|
|
|
322
418
|
import { useEffect, useState } from "react";
|
|
323
419
|
|
|
324
|
-
import { beginAuthorization,
|
|
325
|
-
|
|
326
|
-
type Phase = { kind: "loading" } | { kind: "signed-in" } | { kind: "error"; message: string };
|
|
420
|
+
import { beginAuthorization, CALLBACK_PATH, resolveEditorPhase, signOut } from "./refino-client";
|
|
421
|
+
import type { EditorPhase } from "./refino-client";
|
|
327
422
|
|
|
328
423
|
/**
|
|
329
424
|
* Owner entry point for Refino-hosted editing. Visiting /edit sends the
|
|
330
425
|
* owner to Refino to sign in and authorize this site; Refino sends them
|
|
331
426
|
* back here with a one-time code, which is exchanged for an editor session
|
|
332
|
-
* and scrubbed from the URL.
|
|
427
|
+
* and scrubbed from the URL. The page then stays here and says so: /edit
|
|
428
|
+
* with an editor session is the handoff, and it is safe to revisit.
|
|
429
|
+
*
|
|
430
|
+
* This file is yours. Restyle it, rewrite the markup, drop the styles below
|
|
431
|
+
* and use your own -- keep the three phases, the link to \`phase.openHref\`
|
|
432
|
+
* and the sign-out button, and the flow keeps working.
|
|
333
433
|
*/
|
|
434
|
+
const STYLES = \`
|
|
435
|
+
.refino-edit {
|
|
436
|
+
--refino-ink: #0f172a;
|
|
437
|
+
--refino-ink-soft: #334155;
|
|
438
|
+
--refino-muted: #64748b;
|
|
439
|
+
--refino-line: #e2e8f0;
|
|
440
|
+
--refino-accent: #0f766e;
|
|
441
|
+
--refino-accent-strong: #115e59;
|
|
442
|
+
--refino-accent-soft: #ccfbf1;
|
|
443
|
+
display: flex;
|
|
444
|
+
min-height: 100vh;
|
|
445
|
+
align-items: center;
|
|
446
|
+
justify-content: center;
|
|
447
|
+
margin: 0;
|
|
448
|
+
padding: 24px 16px 56px;
|
|
449
|
+
background: #f8fafc;
|
|
450
|
+
color: var(--refino-ink);
|
|
451
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
452
|
+
line-height: 1.55;
|
|
453
|
+
-webkit-font-smoothing: antialiased;
|
|
454
|
+
}
|
|
455
|
+
.refino-edit *,
|
|
456
|
+
.refino-edit *::before,
|
|
457
|
+
.refino-edit *::after { box-sizing: border-box; }
|
|
458
|
+
.refino-edit__card {
|
|
459
|
+
width: 100%;
|
|
460
|
+
/* Roughly 45 characters a line: long enough to read, short enough to scan. */
|
|
461
|
+
max-width: 26rem;
|
|
462
|
+
padding: 28px 28px 20px;
|
|
463
|
+
border: 1px solid var(--refino-line);
|
|
464
|
+
border-radius: 12px;
|
|
465
|
+
background: #ffffff;
|
|
466
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 10px 28px rgba(15, 23, 42, 0.06);
|
|
467
|
+
}
|
|
468
|
+
.refino-edit__eyebrow {
|
|
469
|
+
margin: 0 0 20px;
|
|
470
|
+
font-size: 13px;
|
|
471
|
+
font-weight: 700;
|
|
472
|
+
letter-spacing: 0.01em;
|
|
473
|
+
color: var(--refino-muted);
|
|
474
|
+
}
|
|
475
|
+
.refino-edit__title {
|
|
476
|
+
display: flex;
|
|
477
|
+
align-items: center;
|
|
478
|
+
gap: 10px;
|
|
479
|
+
margin: 0 0 8px;
|
|
480
|
+
font-size: 22px;
|
|
481
|
+
font-weight: 700;
|
|
482
|
+
line-height: 1.25;
|
|
483
|
+
letter-spacing: -0.01em;
|
|
484
|
+
}
|
|
485
|
+
.refino-edit__check {
|
|
486
|
+
display: inline-flex;
|
|
487
|
+
flex: none;
|
|
488
|
+
align-items: center;
|
|
489
|
+
justify-content: center;
|
|
490
|
+
width: 26px;
|
|
491
|
+
height: 26px;
|
|
492
|
+
border-radius: 999px;
|
|
493
|
+
background: var(--refino-accent-soft);
|
|
494
|
+
color: var(--refino-accent-strong);
|
|
495
|
+
font-size: 15px;
|
|
496
|
+
}
|
|
497
|
+
.refino-edit__body {
|
|
498
|
+
margin: 0 0 20px;
|
|
499
|
+
color: var(--refino-ink-soft);
|
|
500
|
+
}
|
|
501
|
+
.refino-edit__cta {
|
|
502
|
+
display: block;
|
|
503
|
+
width: 100%;
|
|
504
|
+
padding: 11px 16px;
|
|
505
|
+
border: 1px solid var(--refino-accent);
|
|
506
|
+
border-radius: 8px;
|
|
507
|
+
background: var(--refino-accent);
|
|
508
|
+
color: #ffffff;
|
|
509
|
+
font: inherit;
|
|
510
|
+
font-weight: 600;
|
|
511
|
+
text-align: center;
|
|
512
|
+
text-decoration: none;
|
|
513
|
+
cursor: pointer;
|
|
514
|
+
}
|
|
515
|
+
.refino-edit__cta:hover { background: var(--refino-accent-strong); border-color: var(--refino-accent-strong); }
|
|
516
|
+
.refino-edit__footer {
|
|
517
|
+
display: flex;
|
|
518
|
+
flex-wrap: wrap;
|
|
519
|
+
align-items: baseline;
|
|
520
|
+
gap: 2px 10px;
|
|
521
|
+
margin-top: 20px;
|
|
522
|
+
padding-top: 16px;
|
|
523
|
+
border-top: 1px solid var(--refino-line);
|
|
524
|
+
}
|
|
525
|
+
.refino-edit__signout {
|
|
526
|
+
padding: 0;
|
|
527
|
+
border: 0;
|
|
528
|
+
border-radius: 4px;
|
|
529
|
+
background: none;
|
|
530
|
+
color: var(--refino-accent-strong);
|
|
531
|
+
font: inherit;
|
|
532
|
+
font-weight: 600;
|
|
533
|
+
text-decoration: underline;
|
|
534
|
+
text-underline-offset: 3px;
|
|
535
|
+
cursor: pointer;
|
|
536
|
+
}
|
|
537
|
+
.refino-edit__signout:hover { color: var(--refino-ink); }
|
|
538
|
+
.refino-edit__hint {
|
|
539
|
+
color: var(--refino-muted);
|
|
540
|
+
font-size: 13px;
|
|
541
|
+
}
|
|
542
|
+
.refino-edit__cta:focus-visible,
|
|
543
|
+
.refino-edit__signout:focus-visible {
|
|
544
|
+
outline: 2px solid var(--refino-accent-strong);
|
|
545
|
+
outline-offset: 2px;
|
|
546
|
+
}
|
|
547
|
+
@media (max-width: 420px) {
|
|
548
|
+
.refino-edit__card { padding: 22px 20px 16px; }
|
|
549
|
+
.refino-edit__title { font-size: 20px; }
|
|
550
|
+
}
|
|
551
|
+
\`;
|
|
552
|
+
|
|
334
553
|
export default function EditPage() {
|
|
335
|
-
const [phase, setPhase] = useState<
|
|
554
|
+
const [phase, setPhase] = useState<EditorPhase>({ kind: "authorizing" });
|
|
336
555
|
|
|
337
556
|
useEffect(() => {
|
|
338
557
|
let cancelled = false;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (cancelled) return;
|
|
343
|
-
if (result.ok) window.location.assign(result.returnTo);
|
|
344
|
-
else setPhase({ kind: "error", message: result.error });
|
|
345
|
-
});
|
|
346
|
-
} else if (readEditorSession()) {
|
|
347
|
-
setPhase({ kind: "signed-in" });
|
|
348
|
-
} else if (params.get("error")) {
|
|
349
|
-
setPhase({ kind: "error", message: params.get("error") ?? "Sign-in failed." });
|
|
350
|
-
} else {
|
|
351
|
-
void beginAuthorization(safeReturnTo(params.get("return_to")));
|
|
352
|
-
}
|
|
558
|
+
void resolveEditorPhase().then((next) => {
|
|
559
|
+
if (!cancelled) setPhase(next);
|
|
560
|
+
});
|
|
353
561
|
return () => {
|
|
354
562
|
cancelled = true;
|
|
355
563
|
};
|
|
356
564
|
}, []);
|
|
357
565
|
|
|
358
566
|
return (
|
|
359
|
-
<main
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
<
|
|
567
|
+
<main className="refino-edit">
|
|
568
|
+
<style>{STYLES}</style>
|
|
569
|
+
<div className="refino-edit__card" aria-live="polite">
|
|
570
|
+
<p className="refino-edit__eyebrow">Refino</p>
|
|
571
|
+
|
|
572
|
+
{phase.kind === "authorizing" && (
|
|
573
|
+
<>
|
|
574
|
+
<h1 className="refino-edit__title">Signing you in…</h1>
|
|
575
|
+
<p className="refino-edit__body">Refino is checking that you may edit this site.</p>
|
|
576
|
+
</>
|
|
577
|
+
)}
|
|
578
|
+
|
|
579
|
+
{phase.kind === "ready" && (
|
|
580
|
+
<>
|
|
581
|
+
<h1 className="refino-edit__title">
|
|
582
|
+
<span className="refino-edit__check" aria-hidden="true">✓</span>
|
|
583
|
+
Edit mode is on
|
|
584
|
+
</h1>
|
|
585
|
+
<p className="refino-edit__body">Open your site, click any text you want to change, and press Save when you are done.</p>
|
|
586
|
+
<a className="refino-edit__cta" href={phase.openHref}>
|
|
587
|
+
Open the site
|
|
588
|
+
</a>
|
|
589
|
+
</>
|
|
590
|
+
)}
|
|
591
|
+
|
|
592
|
+
{phase.kind === "error" && (
|
|
593
|
+
<>
|
|
594
|
+
<h1 className="refino-edit__title">Could not start editing</h1>
|
|
595
|
+
<p className="refino-edit__body" role="alert">
|
|
596
|
+
{phase.message}
|
|
597
|
+
</p>
|
|
598
|
+
<button className="refino-edit__cta" type="button" onClick={() => void beginAuthorization(CALLBACK_PATH)}>
|
|
599
|
+
Try again
|
|
600
|
+
</button>
|
|
601
|
+
</>
|
|
602
|
+
)}
|
|
603
|
+
|
|
604
|
+
{phase.kind !== "authorizing" && (
|
|
605
|
+
<p className="refino-edit__footer">
|
|
606
|
+
<button className="refino-edit__signout" type="button" onClick={signOut}>
|
|
607
|
+
Sign out
|
|
608
|
+
</button>
|
|
609
|
+
<span className="refino-edit__hint">Ends edit mode here and your Refino session.</span>
|
|
367
610
|
</p>
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
onClick={() => {
|
|
371
|
-
clearEditorSession();
|
|
372
|
-
setPhase({ kind: "loading" });
|
|
373
|
-
void beginAuthorization("/");
|
|
374
|
-
}}
|
|
375
|
-
>
|
|
376
|
-
Sign in as someone else
|
|
377
|
-
</button>
|
|
378
|
-
</>
|
|
379
|
-
)}
|
|
380
|
-
{phase.kind === "error" && (
|
|
381
|
-
<>
|
|
382
|
-
<h1>Could not start editing</h1>
|
|
383
|
-
<p role="alert">{phase.message}</p>
|
|
384
|
-
<button type="button" onClick={() => void beginAuthorization("/")}>
|
|
385
|
-
Try again
|
|
386
|
-
</button>
|
|
387
|
-
</>
|
|
388
|
-
)}
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
389
613
|
</main>
|
|
390
614
|
);
|
|
391
615
|
}
|
|
@@ -478,6 +702,14 @@ const RC1_COPY_EDITING = { version: RC1, content: COPY_EDITING_HOSTED_RC1 };
|
|
|
478
702
|
/** What 0.1.0-rc.2 through 0.1.0-rc.4 wrote, before the toolbar could offer integration updates. */
|
|
479
703
|
const RC4_CLIENT = { version: RC4, content: REFINO_CLIENT_RC4 };
|
|
480
704
|
const RC4_COPY_EDITING = { version: RC4, content: COPY_EDITING_HOSTED_RC4 };
|
|
705
|
+
/**
|
|
706
|
+
* What 0.1.0-rc.5 wrote, before /edit became the canonical handoff and could
|
|
707
|
+
* sign the owner out. The page body is byte-identical in every release from
|
|
708
|
+
* rc.1 on, so this one rendering recognises every existing installation; the
|
|
709
|
+
* label only names the `from` version in upgrade reports.
|
|
710
|
+
*/
|
|
711
|
+
const RC5_CLIENT = { version: RC5, content: REFINO_CLIENT_RC5 };
|
|
712
|
+
const RC5_EDIT_PAGE = { version: RC5, content: EDIT_PAGE_HOSTED_RC5 };
|
|
481
713
|
/** Files for a Refino-connected site. No server code, no secrets, in any framework. */
|
|
482
714
|
export function hostedFiles(plan, factory) {
|
|
483
715
|
const refino = plan.refino;
|
|
@@ -492,9 +724,9 @@ export function hostedFiles(plan, factory) {
|
|
|
492
724
|
hosting: analyticsHosting(plan.repository.hosting),
|
|
493
725
|
packageVersion: TOOL_VERSION,
|
|
494
726
|
}), { recognize: recognizeSiteModule }),
|
|
495
|
-
make.machine(`${dir}/refino-client.ts`, REFINO_CLIENT, { previous: [RC4_CLIENT, RC1_CLIENT] }),
|
|
727
|
+
make.machine(`${dir}/refino-client.ts`, REFINO_CLIENT, { previous: [RC5_CLIENT, RC4_CLIENT, RC1_CLIENT] }),
|
|
496
728
|
make.machine(`${dir}/copy-editing.tsx`, COPY_EDITING_HOSTED, { previous: [RC4_COPY_EDITING, RC1_COPY_EDITING] }),
|
|
497
|
-
make.scaffold(`${dir}/edit-page.tsx`, EDIT_PAGE_HOSTED),
|
|
729
|
+
make.scaffold(`${dir}/edit-page.tsx`, EDIT_PAGE_HOSTED, { previous: [RC5_EDIT_PAGE] }),
|
|
498
730
|
];
|
|
499
731
|
const editPage = plan.files.find((file) => /(^|\/)edit(\/page)?\.\w+$/.test(file.path) && !file.path.startsWith(`${dir}/`))?.path;
|
|
500
732
|
if (plan.repository.router === "next-app") {
|
|
@@ -23,13 +23,28 @@ export declare const RC1 = "0.1.0-rc.1";
|
|
|
23
23
|
* is still harmless: it only names the `from` version in upgrade reports.
|
|
24
24
|
*/
|
|
25
25
|
export declare const RC4 = "0.1.0-rc.4";
|
|
26
|
+
/** The release that wrote the rc.5 bodies below: the published 0.1.0-rc.5. */
|
|
27
|
+
export declare const RC5 = "0.1.0-rc.5";
|
|
26
28
|
/** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
|
|
27
29
|
export declare const REFINO_SITE_RC1: (config: LegacySiteConfig) => string;
|
|
28
30
|
/** 0.1.0-rc.1 refino/refino-client.ts: before reportEditorEvent and the install facts. */
|
|
29
31
|
export declare const REFINO_CLIENT_RC1 = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({ grant_type: \"authorization_code\", code, code_verifier: pending.verifier, site_id: REFINO_SITE_ID, redirect_uri: callbackUrl() }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n";
|
|
30
32
|
/** 0.1.0-rc.1 refino/copy-editing.tsx: before onEvent={reportEditorEvent}. */
|
|
31
33
|
export declare const COPY_EDITING_HOSTED_RC1 = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit}>\n {children}\n </RefinoProvider>\n );\n}\n";
|
|
32
|
-
/** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates. */
|
|
34
|
+
/** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates, and before reportEditorEvent carried the editing surface. */
|
|
33
35
|
export declare const REFINO_CLIENT_RC4 = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(scope === \"one\" || scope === \"all\" ? { event: name, scope } : { event: name }),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n";
|
|
34
36
|
/** 0.1.0-rc.4 refino/copy-editing.tsx: before updates={refinoUpdates}. */
|
|
35
37
|
export declare const COPY_EDITING_HOSTED_RC4 = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit} onEvent={reportEditorEvent}>\n {children}\n </RefinoProvider>\n );\n}\n";
|
|
38
|
+
/**
|
|
39
|
+
* 0.1.0-rc.5 refino/refino-client.ts: before `resolveEditorPhase`, `signOut`
|
|
40
|
+
* and the hardened `safeReturnTo`. This is what every rc.5 site has on disk.
|
|
41
|
+
*/
|
|
42
|
+
export declare const REFINO_CLIENT_RC5 = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(scope === \"one\" || scope === \"all\" ? { event: name, scope } : { event: name }),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n\n/**\n * Updates to this site's Refino integration, offered in the editor\n * toolbar. Asking is read-only. Nothing changes unless the owner presses\n * \"Update Refino\": Refino then commits the new Refino version to this\n * repository, and the site's normal deployment takes it from there. The\n * editor checks every answer itself, so this stays a plain pipe.\n */\nexport const refinoUpdates = {\n check: (): Promise<unknown> => updateRequest(\"GET\"),\n apply: (version: string): Promise<unknown> => updateRequest(\"POST\", JSON.stringify({ version })),\n};\n\nasync function updateRequest(method: \"GET\" | \"POST\", body?: string): Promise<unknown> {\n const session = readEditorSession();\n if (!session) return null;\n const headers: Record<string, string> = { accept: \"application/json\", authorization: `Bearer ${session.token}` };\n if (body) headers[\"content-type\"] = \"application/json\";\n try {\n const response = await fetch(`${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/update`, { method, mode: \"cors\", credentials: \"omit\", headers, body });\n return await response.json();\n } catch {\n // Unreachable or unreadable: the editor treats it as \"nothing to offer\", or as an unknown outcome.\n return null;\n }\n}\n";
|
|
43
|
+
/**
|
|
44
|
+
* refino/edit-page.tsx as every release up to and including 0.1.0-rc.5 wrote
|
|
45
|
+
* it (the body never changed after rc.1): the unstyled handoff screen, which
|
|
46
|
+
* a successful sign-in never landed on, and its "Sign in as someone else"
|
|
47
|
+
* button, which cleared the site's editor session but not the Refino one and
|
|
48
|
+
* so signed the same account straight back in.
|
|
49
|
+
*/
|
|
50
|
+
export declare const EDIT_PAGE_HOSTED_RC5 = "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { beginAuthorization, clearEditorSession, completeAuthorization, readEditorSession, safeReturnTo } from \"./refino-client\";\n\ntype Phase = { kind: \"loading\" } | { kind: \"signed-in\" } | { kind: \"error\"; message: string };\n\n/**\n * Owner entry point for Refino-hosted editing. Visiting /edit sends the\n * owner to Refino to sign in and authorize this site; Refino sends them\n * back here with a one-time code, which is exchanged for an editor session\n * and scrubbed from the URL. Restyle freely; keep the behaviour.\n */\nexport default function EditPage() {\n const [phase, setPhase] = useState<Phase>({ kind: \"loading\" });\n\n useEffect(() => {\n let cancelled = false;\n const params = new URLSearchParams(window.location.search);\n if (params.has(\"code\") || params.has(\"state\")) {\n void completeAuthorization().then((result) => {\n if (cancelled) return;\n if (result.ok) window.location.assign(result.returnTo);\n else setPhase({ kind: \"error\", message: result.error });\n });\n } else if (readEditorSession()) {\n setPhase({ kind: \"signed-in\" });\n } else if (params.get(\"error\")) {\n setPhase({ kind: \"error\", message: params.get(\"error\") ?? \"Sign-in failed.\" });\n } else {\n void beginAuthorization(safeReturnTo(params.get(\"return_to\")));\n }\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <main style={{ maxWidth: 420, margin: \"10vh auto\", padding: 24, fontFamily: \"system-ui, sans-serif\" }}>\n {phase.kind === \"loading\" && <p>Signing you in with Refino\u2026</p>}\n {phase.kind === \"signed-in\" && (\n <>\n <h1>Edit mode is on</h1>\n <p>Go to the site, click any text, and press Save when you are done.</p>\n <p>\n <a href=\"/\">Open the site</a>\n </p>\n <button\n type=\"button\"\n onClick={() => {\n clearEditorSession();\n setPhase({ kind: \"loading\" });\n void beginAuthorization(\"/\");\n }}\n >\n Sign in as someone else\n </button>\n </>\n )}\n {phase.kind === \"error\" && (\n <>\n <h1>Could not start editing</h1>\n <p role=\"alert\">{phase.message}</p>\n <button type=\"button\" onClick={() => void beginAuthorization(\"/\")}>\n Try again\n </button>\n </>\n )}\n </main>\n );\n}\n";
|
package/dist/templates-legacy.js
CHANGED
|
@@ -19,6 +19,8 @@ export const RC1 = "0.1.0-rc.1";
|
|
|
19
19
|
* is still harmless: it only names the `from` version in upgrade reports.
|
|
20
20
|
*/
|
|
21
21
|
export const RC4 = "0.1.0-rc.4";
|
|
22
|
+
/** The release that wrote the rc.5 bodies below: the published 0.1.0-rc.5. */
|
|
23
|
+
export const RC5 = "0.1.0-rc.5";
|
|
22
24
|
/** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
|
|
23
25
|
export const REFINO_SITE_RC1 = (config) => `/**
|
|
24
26
|
* Public Refino configuration for this site. Neither value is a secret:
|
|
@@ -225,7 +227,7 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
|
|
|
225
227
|
);
|
|
226
228
|
}
|
|
227
229
|
`;
|
|
228
|
-
/** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates. */
|
|
230
|
+
/** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates, and before reportEditorEvent carried the editing surface. */
|
|
229
231
|
export const REFINO_CLIENT_RC4 = `/**
|
|
230
232
|
* Browser-side Refino editor authorization for this site. No secrets here:
|
|
231
233
|
* the site is a public client (OAuth 2.1 with PKCE). The editor token lives
|
|
@@ -474,3 +476,311 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
|
|
|
474
476
|
);
|
|
475
477
|
}
|
|
476
478
|
`;
|
|
479
|
+
/**
|
|
480
|
+
* 0.1.0-rc.5 refino/refino-client.ts: before `resolveEditorPhase`, `signOut`
|
|
481
|
+
* and the hardened `safeReturnTo`. This is what every rc.5 site has on disk.
|
|
482
|
+
*/
|
|
483
|
+
export const REFINO_CLIENT_RC5 = `/**
|
|
484
|
+
* Browser-side Refino editor authorization for this site. No secrets here:
|
|
485
|
+
* the site is a public client (OAuth 2.1 with PKCE). The editor token lives
|
|
486
|
+
* in sessionStorage only (never localStorage, never a URL), is scoped to
|
|
487
|
+
* this site, and Refino re-checks the account's entitlement on every load
|
|
488
|
+
* and save. Never import this from server code.
|
|
489
|
+
*/
|
|
490
|
+
import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
|
|
491
|
+
|
|
492
|
+
const PKCE_KEY = "refino.pkce";
|
|
493
|
+
const SESSION_KEY = "refino.editor";
|
|
494
|
+
const CALLBACK_PATH = "/edit";
|
|
495
|
+
|
|
496
|
+
export interface EditorSession {
|
|
497
|
+
readonly token: string;
|
|
498
|
+
/** Unix seconds. */
|
|
499
|
+
readonly expiresAt: number;
|
|
500
|
+
readonly siteId: string;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
interface PendingAuthorization {
|
|
504
|
+
readonly verifier: string;
|
|
505
|
+
readonly state: string;
|
|
506
|
+
readonly returnTo: string;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function base64url(bytes: Uint8Array): string {
|
|
510
|
+
let binary = "";
|
|
511
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
512
|
+
return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function randomToken(bytes: number): string {
|
|
516
|
+
const buffer = new Uint8Array(bytes);
|
|
517
|
+
crypto.getRandomValues(buffer);
|
|
518
|
+
return base64url(buffer);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function sha256(text: string): Promise<string> {
|
|
522
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
523
|
+
return base64url(new Uint8Array(digest));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function storage(): Storage | null {
|
|
527
|
+
try {
|
|
528
|
+
return typeof window === "undefined" ? null : window.sessionStorage;
|
|
529
|
+
} catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function readJson<T>(key: string): T | null {
|
|
535
|
+
const raw = storage()?.getItem(key);
|
|
536
|
+
if (!raw) return null;
|
|
537
|
+
try {
|
|
538
|
+
return JSON.parse(raw) as T;
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Only a path on this site, never another origin. */
|
|
545
|
+
export function safeReturnTo(value: string | null | undefined): string {
|
|
546
|
+
if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function editorEndpoint(): string {
|
|
551
|
+
return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function callbackUrl(): string {
|
|
555
|
+
return \`\${window.location.origin}\${CALLBACK_PATH}\`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The current editor session, or null when there is none or it expired. */
|
|
559
|
+
export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
|
|
560
|
+
const session = readJson<EditorSession>(SESSION_KEY);
|
|
561
|
+
if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
|
|
562
|
+
if (session.expiresAt <= nowSeconds) {
|
|
563
|
+
storage()?.removeItem(SESSION_KEY);
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return session;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function clearEditorSession(): void {
|
|
570
|
+
storage()?.removeItem(SESSION_KEY);
|
|
571
|
+
storage()?.removeItem(PKCE_KEY);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Start the flow: remember the PKCE verifier and state, then go to Refino. */
|
|
575
|
+
export async function beginAuthorization(returnTo: string): Promise<void> {
|
|
576
|
+
const verifier = randomToken(32);
|
|
577
|
+
const state = randomToken(16);
|
|
578
|
+
const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
|
|
579
|
+
storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
|
|
580
|
+
const params = new URLSearchParams({
|
|
581
|
+
site_id: REFINO_SITE_ID,
|
|
582
|
+
redirect_uri: callbackUrl(),
|
|
583
|
+
code_challenge: await sha256(verifier),
|
|
584
|
+
code_challenge_method: "S256",
|
|
585
|
+
state,
|
|
586
|
+
});
|
|
587
|
+
window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Finish the flow on /edit?code&state: check the state, exchange the code
|
|
594
|
+
* with the verifier this browser kept, store the session, and scrub the
|
|
595
|
+
* code from the URL. The token is never placed in a URL.
|
|
596
|
+
*/
|
|
597
|
+
export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
|
|
598
|
+
const params = new URLSearchParams(search);
|
|
599
|
+
const code = params.get("code");
|
|
600
|
+
const state = params.get("state");
|
|
601
|
+
const pending = readJson<PendingAuthorization>(PKCE_KEY);
|
|
602
|
+
storage()?.removeItem(PKCE_KEY);
|
|
603
|
+
if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
|
|
604
|
+
if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
|
|
605
|
+
if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
|
|
606
|
+
|
|
607
|
+
let response: Response;
|
|
608
|
+
try {
|
|
609
|
+
response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
mode: "cors",
|
|
612
|
+
credentials: "omit",
|
|
613
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
grant_type: "authorization_code",
|
|
616
|
+
code,
|
|
617
|
+
code_verifier: pending.verifier,
|
|
618
|
+
site_id: REFINO_SITE_ID,
|
|
619
|
+
redirect_uri: callbackUrl(),
|
|
620
|
+
// The public install facts from refino-site.ts, sent once per
|
|
621
|
+
// sign-in. Refino records them the first time and ignores them
|
|
622
|
+
// afterwards. Remove this line and everything still works.
|
|
623
|
+
install: REFINO_INSTALL,
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
} catch {
|
|
627
|
+
return { ok: false, error: "Could not reach Refino to finish signing in." };
|
|
628
|
+
}
|
|
629
|
+
const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
|
|
630
|
+
if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
|
|
631
|
+
return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
|
|
632
|
+
}
|
|
633
|
+
const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
|
|
634
|
+
storage()?.setItem(SESSION_KEY, JSON.stringify(session));
|
|
635
|
+
return { ok: true, returnTo: pending.returnTo };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The three editor events this site reports, and the only ones it ever
|
|
640
|
+
* sends. There is no analytics SDK on this site: no autocapture, no session
|
|
641
|
+
* recording, no third-party script, no cookie. Each event is one small POST
|
|
642
|
+
* to Refino carrying an event name and, for a revert, whether it was one
|
|
643
|
+
* word or all of them — never your page, your copy, your visitors or your
|
|
644
|
+
* repository. \`edit_saved\` is not in this list on purpose: a save is a
|
|
645
|
+
* commit, and Refino records that on its own side where it cannot be faked
|
|
646
|
+
* or lost.
|
|
647
|
+
*/
|
|
648
|
+
const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
|
|
649
|
+
|
|
650
|
+
type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
|
|
651
|
+
|
|
652
|
+
interface EditorEventLike {
|
|
653
|
+
readonly name: string;
|
|
654
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Fire and forget. A failure here is invisible and harmless: nothing in the
|
|
659
|
+
* editor waits for it, retries it, or behaves differently when it fails.
|
|
660
|
+
*/
|
|
661
|
+
export function reportEditorEvent(event: EditorEventLike): void {
|
|
662
|
+
const session = readEditorSession();
|
|
663
|
+
if (!session) return;
|
|
664
|
+
if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
|
|
665
|
+
const name = event.name as ReportableEvent;
|
|
666
|
+
const scope = event.properties.scope;
|
|
667
|
+
try {
|
|
668
|
+
void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
|
|
669
|
+
method: "POST",
|
|
670
|
+
mode: "cors",
|
|
671
|
+
credentials: "omit",
|
|
672
|
+
keepalive: true,
|
|
673
|
+
headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
|
|
674
|
+
body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
|
|
675
|
+
}).catch(() => {});
|
|
676
|
+
} catch {
|
|
677
|
+
// Never let reporting affect editing.
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Updates to this site's Refino integration, offered in the editor
|
|
683
|
+
* toolbar. Asking is read-only. Nothing changes unless the owner presses
|
|
684
|
+
* "Update Refino": Refino then commits the new Refino version to this
|
|
685
|
+
* repository, and the site's normal deployment takes it from there. The
|
|
686
|
+
* editor checks every answer itself, so this stays a plain pipe.
|
|
687
|
+
*/
|
|
688
|
+
export const refinoUpdates = {
|
|
689
|
+
check: (): Promise<unknown> => updateRequest("GET"),
|
|
690
|
+
apply: (version: string): Promise<unknown> => updateRequest("POST", JSON.stringify({ version })),
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
async function updateRequest(method: "GET" | "POST", body?: string): Promise<unknown> {
|
|
694
|
+
const session = readEditorSession();
|
|
695
|
+
if (!session) return null;
|
|
696
|
+
const headers: Record<string, string> = { accept: "application/json", authorization: \`Bearer \${session.token}\` };
|
|
697
|
+
if (body) headers["content-type"] = "application/json";
|
|
698
|
+
try {
|
|
699
|
+
const response = await fetch(\`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/update\`, { method, mode: "cors", credentials: "omit", headers, body });
|
|
700
|
+
return await response.json();
|
|
701
|
+
} catch {
|
|
702
|
+
// Unreachable or unreadable: the editor treats it as "nothing to offer", or as an unknown outcome.
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
`;
|
|
707
|
+
/**
|
|
708
|
+
* refino/edit-page.tsx as every release up to and including 0.1.0-rc.5 wrote
|
|
709
|
+
* it (the body never changed after rc.1): the unstyled handoff screen, which
|
|
710
|
+
* a successful sign-in never landed on, and its "Sign in as someone else"
|
|
711
|
+
* button, which cleared the site's editor session but not the Refino one and
|
|
712
|
+
* so signed the same account straight back in.
|
|
713
|
+
*/
|
|
714
|
+
export const EDIT_PAGE_HOSTED_RC5 = `"use client";
|
|
715
|
+
|
|
716
|
+
import { useEffect, useState } from "react";
|
|
717
|
+
|
|
718
|
+
import { beginAuthorization, clearEditorSession, completeAuthorization, readEditorSession, safeReturnTo } from "./refino-client";
|
|
719
|
+
|
|
720
|
+
type Phase = { kind: "loading" } | { kind: "signed-in" } | { kind: "error"; message: string };
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Owner entry point for Refino-hosted editing. Visiting /edit sends the
|
|
724
|
+
* owner to Refino to sign in and authorize this site; Refino sends them
|
|
725
|
+
* back here with a one-time code, which is exchanged for an editor session
|
|
726
|
+
* and scrubbed from the URL. Restyle freely; keep the behaviour.
|
|
727
|
+
*/
|
|
728
|
+
export default function EditPage() {
|
|
729
|
+
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
|
|
730
|
+
|
|
731
|
+
useEffect(() => {
|
|
732
|
+
let cancelled = false;
|
|
733
|
+
const params = new URLSearchParams(window.location.search);
|
|
734
|
+
if (params.has("code") || params.has("state")) {
|
|
735
|
+
void completeAuthorization().then((result) => {
|
|
736
|
+
if (cancelled) return;
|
|
737
|
+
if (result.ok) window.location.assign(result.returnTo);
|
|
738
|
+
else setPhase({ kind: "error", message: result.error });
|
|
739
|
+
});
|
|
740
|
+
} else if (readEditorSession()) {
|
|
741
|
+
setPhase({ kind: "signed-in" });
|
|
742
|
+
} else if (params.get("error")) {
|
|
743
|
+
setPhase({ kind: "error", message: params.get("error") ?? "Sign-in failed." });
|
|
744
|
+
} else {
|
|
745
|
+
void beginAuthorization(safeReturnTo(params.get("return_to")));
|
|
746
|
+
}
|
|
747
|
+
return () => {
|
|
748
|
+
cancelled = true;
|
|
749
|
+
};
|
|
750
|
+
}, []);
|
|
751
|
+
|
|
752
|
+
return (
|
|
753
|
+
<main style={{ maxWidth: 420, margin: "10vh auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
|
|
754
|
+
{phase.kind === "loading" && <p>Signing you in with Refino…</p>}
|
|
755
|
+
{phase.kind === "signed-in" && (
|
|
756
|
+
<>
|
|
757
|
+
<h1>Edit mode is on</h1>
|
|
758
|
+
<p>Go to the site, click any text, and press Save when you are done.</p>
|
|
759
|
+
<p>
|
|
760
|
+
<a href="/">Open the site</a>
|
|
761
|
+
</p>
|
|
762
|
+
<button
|
|
763
|
+
type="button"
|
|
764
|
+
onClick={() => {
|
|
765
|
+
clearEditorSession();
|
|
766
|
+
setPhase({ kind: "loading" });
|
|
767
|
+
void beginAuthorization("/");
|
|
768
|
+
}}
|
|
769
|
+
>
|
|
770
|
+
Sign in as someone else
|
|
771
|
+
</button>
|
|
772
|
+
</>
|
|
773
|
+
)}
|
|
774
|
+
{phase.kind === "error" && (
|
|
775
|
+
<>
|
|
776
|
+
<h1>Could not start editing</h1>
|
|
777
|
+
<p role="alert">{phase.message}</p>
|
|
778
|
+
<button type="button" onClick={() => void beginAuthorization("/")}>
|
|
779
|
+
Try again
|
|
780
|
+
</button>
|
|
781
|
+
</>
|
|
782
|
+
)}
|
|
783
|
+
</main>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
`;
|
package/dist/verify.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { isContentId, parseCopy } from "@getrefino/core";
|
|
9
|
+
import { parseContentConfig } from "@getrefino/core/documents";
|
|
9
10
|
import ts from "typescript";
|
|
10
11
|
import { exists, isFile, isTestOrStoryFile, readJson, readText, rel, walkFiles } from "./fs.js";
|
|
11
12
|
import { COMPARISON_SUFFIX, classifyGeneratedFiles, readGeneratedManifest } from "./generated.js";
|
|
@@ -389,6 +390,35 @@ export function verifyIntegration(rootPath, options = {}) {
|
|
|
389
390
|
add({ id: "content-ids", status: "pass", message: "All ids are valid." });
|
|
390
391
|
}
|
|
391
392
|
}
|
|
393
|
+
// Content sources (article editing). Absent is the normal case and not a
|
|
394
|
+
// finding: a site that edits only copy has nothing to declare.
|
|
395
|
+
if (config?.content !== undefined) {
|
|
396
|
+
try {
|
|
397
|
+
const content = parseContentConfig(config.content);
|
|
398
|
+
const missing = content.sources.filter((source) => !exists(join(appDir, source.dir)));
|
|
399
|
+
if (content.sources.length === 0) {
|
|
400
|
+
add({ id: "content-sources", status: "warn", message: 'refino.config.json has a "content" block with no sources.', fix: 'Add a source, or remove the "content" block.' });
|
|
401
|
+
}
|
|
402
|
+
else if (missing.length > 0) {
|
|
403
|
+
add({
|
|
404
|
+
id: "content-sources",
|
|
405
|
+
status: "fail",
|
|
406
|
+
message: `Content source directories do not exist: ${missing.map((source) => `${source.id} (${source.dir})`).join(", ")}.`,
|
|
407
|
+
fix: "Point each source at the directory that actually holds the articles.",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
add({
|
|
412
|
+
id: "content-sources",
|
|
413
|
+
status: "pass",
|
|
414
|
+
message: `Article sources: ${content.sources.map((source) => `${source.id} (${source.dir}/*${source.extensions.join(",*")})`).join(", ")}.`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
add({ id: "content-sources", status: "fail", message: error.message, fix: 'Fix the "content" block, or remove it to turn article editing off.' });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
392
422
|
// Source scan
|
|
393
423
|
const sourceFiles = walkFiles(appDir).filter((file) => !isTestOrStoryFile(rel(appDir, file)));
|
|
394
424
|
const uses = collectEditableTextUses(appDir, sourceFiles);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getrefino/onboarding",
|
|
3
|
-
"version": "0.1.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The engine behind `npx @getrefino/cli init`: deterministic repository inspection, copy discovery, migration planning, agent instructions and verification for adding Refino to an existing React site. Development tooling only; never part of a site's runtime.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"refino",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"typescript": ">=5.5 <7",
|
|
41
|
-
"@getrefino/core": "0.1.0
|
|
41
|
+
"@getrefino/core": "0.1.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "22.20.2",
|