@getrefino/onboarding 0.1.0-rc.1 → 0.1.0-rc.3
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/README.md +17 -1
- package/dist/agent.d.ts +0 -4
- package/dist/agent.js +2 -1
- package/dist/apply.js +92 -16
- package/dist/generated.d.ts +30 -0
- package/dist/generated.js +168 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/install-method.d.ts +26 -0
- package/dist/install-method.js +32 -0
- package/dist/plan.js +1 -1
- package/dist/template-file.d.ts +38 -0
- package/dist/template-file.js +44 -0
- package/dist/templates-hosted.d.ts +19 -15
- package/dist/templates-hosted.js +165 -16
- package/dist/templates-legacy.d.ts +25 -0
- package/dist/templates-legacy.js +221 -0
- package/dist/templates.d.ts +2 -4
- package/dist/templates.js +32 -41
- package/dist/types.d.ts +83 -1
- package/dist/verify.js +88 -2
- package/package.json +2 -2
|
@@ -1,28 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* in the browser: the site holds two public constants and no secrets, no
|
|
4
|
-
* copy endpoint, no session endpoint and no repository credential. Refino
|
|
5
|
-
* authenticates the owner, authorizes the site, and loads and saves the
|
|
6
|
-
* copy file in the repository.
|
|
7
|
-
*
|
|
8
|
-
* /edit → Refino /editor/authorize (PKCE) → back to /edit?code&state
|
|
9
|
-
* → POST Refino /api/editor/token → 12 h editor token in sessionStorage
|
|
10
|
-
* → <RefinoProvider endpoint={Refino copy API} headers={bearer}>
|
|
11
|
-
*/
|
|
1
|
+
import type { InstallMethod } from "./install-method.js";
|
|
2
|
+
import type { TemplateFactory, TemplateFile } from "./template-file.js";
|
|
12
3
|
import type { MigrationPlan } from "./types.js";
|
|
13
|
-
import type { TemplateFile } from "./templates.js";
|
|
14
4
|
export declare const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
|
|
15
5
|
export interface RefinoSiteConfig {
|
|
16
6
|
readonly siteId: string;
|
|
17
7
|
readonly appUrl: string;
|
|
8
|
+
/** What `refino init --via <method>` was told. "unknown" when it was told nothing. */
|
|
9
|
+
readonly installMethod: InstallMethod;
|
|
10
|
+
/** Framework, as the analytics vocabulary names it. */
|
|
11
|
+
readonly framework: string;
|
|
12
|
+
/** Hosting provider, as the analytics vocabulary names it. */
|
|
13
|
+
readonly hosting: string;
|
|
14
|
+
readonly packageVersion: string;
|
|
18
15
|
}
|
|
19
16
|
export declare const REFINO_SITE_ID_PATTERN: RegExp;
|
|
20
17
|
/** Public configuration; safe to ship to the browser and to commit. */
|
|
21
18
|
export declare const REFINO_SITE: (config: RefinoSiteConfig) => string;
|
|
22
19
|
/** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
|
|
23
|
-
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_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({
|
|
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";
|
|
24
21
|
/** Provider wrapper: edit mode is decided in the browser from the stored session. */
|
|
25
|
-
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 } 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";
|
|
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, 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";
|
|
26
23
|
/** The /edit page: starts the flow, finishes it on return, shows state. Works as a static export. */
|
|
27
24
|
export declare const EDIT_PAGE_HOSTED = "\"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";
|
|
28
25
|
/** Next.js App Router route file for /edit: re-exports the generated client page. */
|
|
@@ -30,5 +27,12 @@ export declare const EDIT_ROUTE_NEXT_HOSTED: (pageImport: string) => string;
|
|
|
30
27
|
/** Next.js Pages Router page for /edit. */
|
|
31
28
|
export declare const EDIT_PAGE_NEXT_PAGES_HOSTED: (pageImport: string) => string;
|
|
32
29
|
export declare const EDIT_LAYOUT_HOSTED = "import type { Metadata } from \"next\";\nimport type { ReactNode } from \"react\";\n\n/** /edit is the owner's entry point, not a public page; the page itself is a client component and cannot export metadata. */\nexport const metadata: Metadata = { title: \"Edit this site\", robots: { index: false, follow: false } };\n\nexport default function EditLayout({ children }: { children: ReactNode }) {\n return children;\n}\n";
|
|
30
|
+
/**
|
|
31
|
+
* The framework and hosting names Refino's analytics vocabulary uses. A
|
|
32
|
+
* provider the inspector did not recognise is "other" and an absent one is
|
|
33
|
+
* "unknown"; neither is ever guessed from something else.
|
|
34
|
+
*/
|
|
35
|
+
export declare function analyticsFramework(framework: MigrationPlan["repository"]["framework"]): string;
|
|
36
|
+
export declare function analyticsHosting(hosting: string | null): string;
|
|
33
37
|
/** Files for a Refino-connected site. No server code, no secrets, in any framework. */
|
|
34
|
-
export declare function hostedFiles(plan: MigrationPlan): TemplateFile[];
|
|
38
|
+
export declare function hostedFiles(plan: MigrationPlan, factory?: TemplateFactory): TemplateFile[];
|
package/dist/templates-hosted.js
CHANGED
|
@@ -1,14 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boilerplate for a site connected to Refino (hosted mode). Everything runs
|
|
3
|
+
* in the browser: the site holds two public constants and no secrets, no
|
|
4
|
+
* copy endpoint, no session endpoint and no repository credential. Refino
|
|
5
|
+
* authenticates the owner, authorizes the site, and loads and saves the
|
|
6
|
+
* copy file in the repository.
|
|
7
|
+
*
|
|
8
|
+
* /edit → Refino /editor/authorize (PKCE) → back to /edit?code&state
|
|
9
|
+
* → POST Refino /api/editor/token → 12 h editor token in sessionStorage
|
|
10
|
+
* → <RefinoProvider endpoint={Refino copy API} headers={bearer}>
|
|
11
|
+
*/
|
|
12
|
+
import { isInstallMethod } from "./install-method.js";
|
|
13
|
+
import { TOOL_VERSION } from "./plan.js";
|
|
14
|
+
import { createTemplateFactory } from "./template-file.js";
|
|
15
|
+
import { COPY_EDITING_HOSTED_RC1, RC1, REFINO_CLIENT_RC1, REFINO_SITE_RC1 } from "./templates-legacy.js";
|
|
1
16
|
export const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
|
|
2
17
|
export const REFINO_SITE_ID_PATTERN = /^site_[a-f0-9]{32}$/;
|
|
3
18
|
/** Public configuration; safe to ship to the browser and to commit. */
|
|
4
19
|
export const REFINO_SITE = (config) => `/**
|
|
5
|
-
* Public Refino configuration for this site.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
20
|
+
* Public Refino configuration for this site. Nothing here is a secret: the
|
|
21
|
+
* site id appears in editor URLs and the app URL is where owners sign in.
|
|
22
|
+
* Generated by Refino; change it only when the site is reconnected to
|
|
23
|
+
* Refino.
|
|
9
24
|
*/
|
|
10
25
|
export const REFINO_SITE_ID = ${JSON.stringify(config.siteId)};
|
|
11
26
|
export const REFINO_APP_URL = ${JSON.stringify(config.appUrl)};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How this site was set up, recorded once at install time and sent to
|
|
30
|
+
* Refino when the owner signs in to edit. It tells Refino which
|
|
31
|
+
* installation routes work, nothing more: there is no personal data here,
|
|
32
|
+
* no repository content, and no page content. \`method\` is whatever
|
|
33
|
+
* \`refino init --via <method>\` was told; it is "unknown" when it was told
|
|
34
|
+
* nothing, and it is never guessed. Edit or delete these values freely —
|
|
35
|
+
* the editor works exactly the same either way.
|
|
36
|
+
*/
|
|
37
|
+
export const REFINO_INSTALL = {
|
|
38
|
+
method: ${JSON.stringify(config.installMethod)},
|
|
39
|
+
framework: ${JSON.stringify(config.framework)},
|
|
40
|
+
hosting: ${JSON.stringify(config.hosting)},
|
|
41
|
+
packageVersion: ${JSON.stringify(config.packageVersion)},
|
|
42
|
+
} as const;
|
|
12
43
|
`;
|
|
13
44
|
/** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
|
|
14
45
|
export const REFINO_CLIENT = `/**
|
|
@@ -18,7 +49,7 @@ export const REFINO_CLIENT = `/**
|
|
|
18
49
|
* this site, and Refino re-checks the account's entitlement on every load
|
|
19
50
|
* and save. Never import this from server code.
|
|
20
51
|
*/
|
|
21
|
-
import { REFINO_APP_URL, REFINO_SITE_ID } from "./refino-site";
|
|
52
|
+
import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
|
|
22
53
|
|
|
23
54
|
const PKCE_KEY = "refino.pkce";
|
|
24
55
|
const SESSION_KEY = "refino.editor";
|
|
@@ -142,7 +173,17 @@ export async function completeAuthorization(search: string = window.location.sea
|
|
|
142
173
|
mode: "cors",
|
|
143
174
|
credentials: "omit",
|
|
144
175
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
145
|
-
body: JSON.stringify({
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
grant_type: "authorization_code",
|
|
178
|
+
code,
|
|
179
|
+
code_verifier: pending.verifier,
|
|
180
|
+
site_id: REFINO_SITE_ID,
|
|
181
|
+
redirect_uri: callbackUrl(),
|
|
182
|
+
// The public install facts from refino-site.ts, sent once per
|
|
183
|
+
// sign-in. Refino records them the first time and ignores them
|
|
184
|
+
// afterwards. Remove this line and everything still works.
|
|
185
|
+
install: REFINO_INSTALL,
|
|
186
|
+
}),
|
|
146
187
|
});
|
|
147
188
|
} catch {
|
|
148
189
|
return { ok: false, error: "Could not reach Refino to finish signing in." };
|
|
@@ -155,6 +196,49 @@ export async function completeAuthorization(search: string = window.location.sea
|
|
|
155
196
|
storage()?.setItem(SESSION_KEY, JSON.stringify(session));
|
|
156
197
|
return { ok: true, returnTo: pending.returnTo };
|
|
157
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The three editor events this site reports, and the only ones it ever
|
|
202
|
+
* sends. There is no analytics SDK on this site: no autocapture, no session
|
|
203
|
+
* recording, no third-party script, no cookie. Each event is one small POST
|
|
204
|
+
* to Refino carrying an event name and, for a revert, whether it was one
|
|
205
|
+
* word or all of them — never your page, your copy, your visitors or your
|
|
206
|
+
* repository. \`edit_saved\` is not in this list on purpose: a save is a
|
|
207
|
+
* commit, and Refino records that on its own side where it cannot be faked
|
|
208
|
+
* or lost.
|
|
209
|
+
*/
|
|
210
|
+
const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
|
|
211
|
+
|
|
212
|
+
type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
|
|
213
|
+
|
|
214
|
+
interface EditorEventLike {
|
|
215
|
+
readonly name: string;
|
|
216
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Fire and forget. A failure here is invisible and harmless: nothing in the
|
|
221
|
+
* editor waits for it, retries it, or behaves differently when it fails.
|
|
222
|
+
*/
|
|
223
|
+
export function reportEditorEvent(event: EditorEventLike): void {
|
|
224
|
+
const session = readEditorSession();
|
|
225
|
+
if (!session) return;
|
|
226
|
+
if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
|
|
227
|
+
const name = event.name as ReportableEvent;
|
|
228
|
+
const scope = event.properties.scope;
|
|
229
|
+
try {
|
|
230
|
+
void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
mode: "cors",
|
|
233
|
+
credentials: "omit",
|
|
234
|
+
keepalive: true,
|
|
235
|
+
headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
|
|
236
|
+
body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
|
|
237
|
+
}).catch(() => {});
|
|
238
|
+
} catch {
|
|
239
|
+
// Never let reporting affect editing.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
158
242
|
`;
|
|
159
243
|
/** Provider wrapper: edit mode is decided in the browser from the stored session. */
|
|
160
244
|
export const COPY_EDITING_HOSTED = `"use client";
|
|
@@ -164,7 +248,7 @@ import { RefinoProvider } from "@getrefino/react";
|
|
|
164
248
|
import type { ReactNode } from "react";
|
|
165
249
|
import { useCallback, useEffect, useState } from "react";
|
|
166
250
|
|
|
167
|
-
import { clearEditorSession, editorEndpoint, readEditorSession } from "./refino-client";
|
|
251
|
+
import { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from "./refino-client";
|
|
168
252
|
import type { EditorSession } from "./refino-client";
|
|
169
253
|
|
|
170
254
|
interface CopyEditingProps {
|
|
@@ -200,7 +284,7 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
|
|
|
200
284
|
}, []);
|
|
201
285
|
|
|
202
286
|
return (
|
|
203
|
-
<RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit}>
|
|
287
|
+
<RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit} onEvent={reportEditorEvent}>
|
|
204
288
|
{children}
|
|
205
289
|
</RefinoProvider>
|
|
206
290
|
);
|
|
@@ -306,27 +390,92 @@ function relativeImport(fromFile, toFile) {
|
|
|
306
390
|
const up = fromParts.length === 0 ? "." : fromParts.map(() => "..").join("/");
|
|
307
391
|
return `${up}/${toParts.join("/")}`.replace(/\.(tsx?|jsx?)$/, "");
|
|
308
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* The framework and hosting names Refino's analytics vocabulary uses. A
|
|
395
|
+
* provider the inspector did not recognise is "other" and an absent one is
|
|
396
|
+
* "unknown"; neither is ever guessed from something else.
|
|
397
|
+
*/
|
|
398
|
+
export function analyticsFramework(framework) {
|
|
399
|
+
switch (framework) {
|
|
400
|
+
case "next":
|
|
401
|
+
return "next";
|
|
402
|
+
case "vite-react":
|
|
403
|
+
return "vite";
|
|
404
|
+
case "react":
|
|
405
|
+
return "react";
|
|
406
|
+
default:
|
|
407
|
+
return "other";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
export function analyticsHosting(hosting) {
|
|
411
|
+
if (!hosting)
|
|
412
|
+
return "unknown";
|
|
413
|
+
return ["cloudflare", "vercel", "netlify"].includes(hosting) ? hosting : "other";
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Recognise an earlier Refino's `refino/refino-site` module.
|
|
417
|
+
*
|
|
418
|
+
* This is the one generated file whose bytes depend on the site, so "is it
|
|
419
|
+
* untouched?" cannot be a lookup: the site id, the app URL and the install
|
|
420
|
+
* facts are read back out of the file, each released shape is re-rendered
|
|
421
|
+
* from exactly those values, and the result is compared byte for byte. A file
|
|
422
|
+
* that round-trips is that release's output and nothing else; a file that
|
|
423
|
+
* does not is the owner's and is never overwritten.
|
|
424
|
+
*
|
|
425
|
+
* It also answers the question the manifest cannot for a reconnected site:
|
|
426
|
+
* after `--refino-site <new id>` the generated bytes name a different site, so
|
|
427
|
+
* only re-rendering with the *old* id can show the file was untouched.
|
|
428
|
+
*/
|
|
429
|
+
const recognizeSiteModule = (current, render) => {
|
|
430
|
+
const siteId = /REFINO_SITE_ID\s*=\s*"(site_[a-f0-9]{32})"/.exec(current)?.[1];
|
|
431
|
+
const appUrl = /REFINO_APP_URL\s*=\s*"([^"]+)"/.exec(current)?.[1];
|
|
432
|
+
if (!siteId || !appUrl)
|
|
433
|
+
return null;
|
|
434
|
+
// Before the install facts existed there was nothing else to read.
|
|
435
|
+
if (render(REFINO_SITE_RC1({ siteId, appUrl })) === current)
|
|
436
|
+
return { version: RC1, content: current };
|
|
437
|
+
const read = (key) => new RegExp(`${key}:\\s*"([^"]*)"`).exec(current)?.[1];
|
|
438
|
+
const method = read("method");
|
|
439
|
+
const framework = read("framework");
|
|
440
|
+
const hosting = read("hosting");
|
|
441
|
+
const packageVersion = read("packageVersion");
|
|
442
|
+
if (method === undefined || framework === undefined || hosting === undefined || packageVersion === undefined)
|
|
443
|
+
return null;
|
|
444
|
+
if (!isInstallMethod(method))
|
|
445
|
+
return null;
|
|
446
|
+
const rendered = render(REFINO_SITE({ siteId, appUrl, installMethod: method, framework, hosting, packageVersion }));
|
|
447
|
+
return rendered === current ? { version: packageVersion, content: current } : null;
|
|
448
|
+
};
|
|
449
|
+
/** What 0.1.0-rc.1 wrote for the two hosted modules whose bodies have changed since. */
|
|
450
|
+
const RC1_CLIENT = { version: RC1, content: REFINO_CLIENT_RC1 };
|
|
451
|
+
const RC1_COPY_EDITING = { version: RC1, content: COPY_EDITING_HOSTED_RC1 };
|
|
309
452
|
/** Files for a Refino-connected site. No server code, no secrets, in any framework. */
|
|
310
|
-
export function hostedFiles(plan) {
|
|
453
|
+
export function hostedFiles(plan, factory) {
|
|
311
454
|
const refino = plan.refino;
|
|
312
455
|
if (!refino)
|
|
313
456
|
return [];
|
|
457
|
+
const make = factory ?? createTemplateFactory(plan.repository.language);
|
|
314
458
|
const dir = plan.integration.boilerplateDir;
|
|
315
459
|
const files = [
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
460
|
+
make.machine(`${dir}/refino-site.ts`, REFINO_SITE({
|
|
461
|
+
...refino,
|
|
462
|
+
framework: analyticsFramework(plan.repository.framework),
|
|
463
|
+
hosting: analyticsHosting(plan.repository.hosting),
|
|
464
|
+
packageVersion: TOOL_VERSION,
|
|
465
|
+
}), { recognize: recognizeSiteModule }),
|
|
466
|
+
make.machine(`${dir}/refino-client.ts`, REFINO_CLIENT, { previous: [RC1_CLIENT] }),
|
|
467
|
+
make.machine(`${dir}/copy-editing.tsx`, COPY_EDITING_HOSTED, { previous: [RC1_COPY_EDITING] }),
|
|
468
|
+
make.scaffold(`${dir}/edit-page.tsx`, EDIT_PAGE_HOSTED),
|
|
320
469
|
];
|
|
321
470
|
const editPage = plan.files.find((file) => /(^|\/)edit(\/page)?\.\w+$/.test(file.path) && !file.path.startsWith(`${dir}/`))?.path;
|
|
322
471
|
if (plan.repository.router === "next-app") {
|
|
323
472
|
const routesDir = editPage?.replace(/\/edit\/page\.\w+$/, "") ?? "app";
|
|
324
|
-
files.push(
|
|
325
|
-
files.push(
|
|
473
|
+
files.push(make.machine(`${routesDir}/edit/layout.tsx`, EDIT_LAYOUT_HOSTED));
|
|
474
|
+
files.push(make.machine(`${routesDir}/edit/page.tsx`, EDIT_ROUTE_NEXT_HOSTED(relativeImport(`${routesDir}/edit/page.tsx`, `${dir}/edit-page.tsx`))));
|
|
326
475
|
}
|
|
327
476
|
else if (plan.repository.router === "next-pages") {
|
|
328
477
|
const routesDir = editPage?.replace(/\/edit\.\w+$/, "") ?? "pages";
|
|
329
|
-
files.push(
|
|
478
|
+
files.push(make.machine(`${routesDir}/edit.tsx`, EDIT_PAGE_NEXT_PAGES_HOSTED(relativeImport(`${routesDir}/edit.tsx`, `${dir}/edit-page.tsx`))));
|
|
330
479
|
}
|
|
331
480
|
return files;
|
|
332
481
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-exact renderings of generated files as earlier Refino releases wrote
|
|
3
|
+
* them. They exist for one reason: a site onboarded before .refino/generated.json
|
|
4
|
+
* existed carries no record of what Refino wrote, so the only way to prove a
|
|
5
|
+
* generated file is untouched is to reproduce the old bytes and compare.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here is ever written to a repository. It is read-only evidence.
|
|
8
|
+
*
|
|
9
|
+
* Maintenance: when a template body in templates.ts or templates-hosted.ts
|
|
10
|
+
* changes, copy the previous body here and list it in that file's `previous`
|
|
11
|
+
* renderings. `generated-templates.test.ts` fails until you do, because it
|
|
12
|
+
* pins the hash of every generated file.
|
|
13
|
+
*/
|
|
14
|
+
export interface LegacySiteConfig {
|
|
15
|
+
readonly siteId: string;
|
|
16
|
+
readonly appUrl: string;
|
|
17
|
+
}
|
|
18
|
+
/** The last release that wrote the bodies below. */
|
|
19
|
+
export declare const RC1 = "0.1.0-rc.1";
|
|
20
|
+
/** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
|
|
21
|
+
export declare const REFINO_SITE_RC1: (config: LegacySiteConfig) => string;
|
|
22
|
+
/** 0.1.0-rc.1 refino/refino-client.ts: before reportEditorEvent and the install facts. */
|
|
23
|
+
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";
|
|
24
|
+
/** 0.1.0-rc.1 refino/copy-editing.tsx: before onEvent={reportEditorEvent}. */
|
|
25
|
+
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";
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-exact renderings of generated files as earlier Refino releases wrote
|
|
3
|
+
* them. They exist for one reason: a site onboarded before .refino/generated.json
|
|
4
|
+
* existed carries no record of what Refino wrote, so the only way to prove a
|
|
5
|
+
* generated file is untouched is to reproduce the old bytes and compare.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here is ever written to a repository. It is read-only evidence.
|
|
8
|
+
*
|
|
9
|
+
* Maintenance: when a template body in templates.ts or templates-hosted.ts
|
|
10
|
+
* changes, copy the previous body here and list it in that file's `previous`
|
|
11
|
+
* renderings. `generated-templates.test.ts` fails until you do, because it
|
|
12
|
+
* pins the hash of every generated file.
|
|
13
|
+
*/
|
|
14
|
+
/** The last release that wrote the bodies below. */
|
|
15
|
+
export const RC1 = "0.1.0-rc.1";
|
|
16
|
+
/** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
|
|
17
|
+
export const REFINO_SITE_RC1 = (config) => `/**
|
|
18
|
+
* Public Refino configuration for this site. Neither value is a secret:
|
|
19
|
+
* the site id appears in editor URLs and the app URL is where owners sign
|
|
20
|
+
* in. Generated by Refino; change it only when the site is
|
|
21
|
+
* reconnected to Refino.
|
|
22
|
+
*/
|
|
23
|
+
export const REFINO_SITE_ID = ${JSON.stringify(config.siteId)};
|
|
24
|
+
export const REFINO_APP_URL = ${JSON.stringify(config.appUrl)};
|
|
25
|
+
`;
|
|
26
|
+
/** 0.1.0-rc.1 refino/refino-client.ts: before reportEditorEvent and the install facts. */
|
|
27
|
+
export const REFINO_CLIENT_RC1 = `/**
|
|
28
|
+
* Browser-side Refino editor authorization for this site. No secrets here:
|
|
29
|
+
* the site is a public client (OAuth 2.1 with PKCE). The editor token lives
|
|
30
|
+
* in sessionStorage only (never localStorage, never a URL), is scoped to
|
|
31
|
+
* this site, and Refino re-checks the account's entitlement on every load
|
|
32
|
+
* and save. Never import this from server code.
|
|
33
|
+
*/
|
|
34
|
+
import { REFINO_APP_URL, REFINO_SITE_ID } from "./refino-site";
|
|
35
|
+
|
|
36
|
+
const PKCE_KEY = "refino.pkce";
|
|
37
|
+
const SESSION_KEY = "refino.editor";
|
|
38
|
+
const CALLBACK_PATH = "/edit";
|
|
39
|
+
|
|
40
|
+
export interface EditorSession {
|
|
41
|
+
readonly token: string;
|
|
42
|
+
/** Unix seconds. */
|
|
43
|
+
readonly expiresAt: number;
|
|
44
|
+
readonly siteId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PendingAuthorization {
|
|
48
|
+
readonly verifier: string;
|
|
49
|
+
readonly state: string;
|
|
50
|
+
readonly returnTo: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function base64url(bytes: Uint8Array): string {
|
|
54
|
+
let binary = "";
|
|
55
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
56
|
+
return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function randomToken(bytes: number): string {
|
|
60
|
+
const buffer = new Uint8Array(bytes);
|
|
61
|
+
crypto.getRandomValues(buffer);
|
|
62
|
+
return base64url(buffer);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function sha256(text: string): Promise<string> {
|
|
66
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
67
|
+
return base64url(new Uint8Array(digest));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function storage(): Storage | null {
|
|
71
|
+
try {
|
|
72
|
+
return typeof window === "undefined" ? null : window.sessionStorage;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readJson<T>(key: string): T | null {
|
|
79
|
+
const raw = storage()?.getItem(key);
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(raw) as T;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Only a path on this site, never another origin. */
|
|
89
|
+
export function safeReturnTo(value: string | null | undefined): string {
|
|
90
|
+
if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function editorEndpoint(): string {
|
|
95
|
+
return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function callbackUrl(): string {
|
|
99
|
+
return \`\${window.location.origin}\${CALLBACK_PATH}\`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The current editor session, or null when there is none or it expired. */
|
|
103
|
+
export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
|
|
104
|
+
const session = readJson<EditorSession>(SESSION_KEY);
|
|
105
|
+
if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
|
|
106
|
+
if (session.expiresAt <= nowSeconds) {
|
|
107
|
+
storage()?.removeItem(SESSION_KEY);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return session;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function clearEditorSession(): void {
|
|
114
|
+
storage()?.removeItem(SESSION_KEY);
|
|
115
|
+
storage()?.removeItem(PKCE_KEY);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Start the flow: remember the PKCE verifier and state, then go to Refino. */
|
|
119
|
+
export async function beginAuthorization(returnTo: string): Promise<void> {
|
|
120
|
+
const verifier = randomToken(32);
|
|
121
|
+
const state = randomToken(16);
|
|
122
|
+
const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
|
|
123
|
+
storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
|
|
124
|
+
const params = new URLSearchParams({
|
|
125
|
+
site_id: REFINO_SITE_ID,
|
|
126
|
+
redirect_uri: callbackUrl(),
|
|
127
|
+
code_challenge: await sha256(verifier),
|
|
128
|
+
code_challenge_method: "S256",
|
|
129
|
+
state,
|
|
130
|
+
});
|
|
131
|
+
window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Finish the flow on /edit?code&state: check the state, exchange the code
|
|
138
|
+
* with the verifier this browser kept, store the session, and scrub the
|
|
139
|
+
* code from the URL. The token is never placed in a URL.
|
|
140
|
+
*/
|
|
141
|
+
export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
|
|
142
|
+
const params = new URLSearchParams(search);
|
|
143
|
+
const code = params.get("code");
|
|
144
|
+
const state = params.get("state");
|
|
145
|
+
const pending = readJson<PendingAuthorization>(PKCE_KEY);
|
|
146
|
+
storage()?.removeItem(PKCE_KEY);
|
|
147
|
+
if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
|
|
148
|
+
if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
|
|
149
|
+
if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
|
|
150
|
+
|
|
151
|
+
let response: Response;
|
|
152
|
+
try {
|
|
153
|
+
response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
mode: "cors",
|
|
156
|
+
credentials: "omit",
|
|
157
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
158
|
+
body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: pending.verifier, site_id: REFINO_SITE_ID, redirect_uri: callbackUrl() }),
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
return { ok: false, error: "Could not reach Refino to finish signing in." };
|
|
162
|
+
}
|
|
163
|
+
const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
|
|
164
|
+
if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
|
|
165
|
+
return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
|
|
166
|
+
}
|
|
167
|
+
const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
|
|
168
|
+
storage()?.setItem(SESSION_KEY, JSON.stringify(session));
|
|
169
|
+
return { ok: true, returnTo: pending.returnTo };
|
|
170
|
+
}
|
|
171
|
+
`;
|
|
172
|
+
/** 0.1.0-rc.1 refino/copy-editing.tsx: before onEvent={reportEditorEvent}. */
|
|
173
|
+
export const COPY_EDITING_HOSTED_RC1 = `"use client";
|
|
174
|
+
|
|
175
|
+
import type { CopyContent } from "@getrefino/core";
|
|
176
|
+
import { RefinoProvider } from "@getrefino/react";
|
|
177
|
+
import type { ReactNode } from "react";
|
|
178
|
+
import { useCallback, useEffect, useState } from "react";
|
|
179
|
+
|
|
180
|
+
import { clearEditorSession, editorEndpoint, readEditorSession } from "./refino-client";
|
|
181
|
+
import type { EditorSession } from "./refino-client";
|
|
182
|
+
|
|
183
|
+
interface CopyEditingProps {
|
|
184
|
+
content: CopyContent;
|
|
185
|
+
children: ReactNode;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Hosted mode (Refino). Visitors get the plain site: the server never
|
|
190
|
+
* decides edit mode, so pages can stay static. In the browser, an editor
|
|
191
|
+
* session stored by /edit turns edit mode on; loads and saves go to Refino
|
|
192
|
+
* with the session's bearer token, and Refino checks the site's
|
|
193
|
+
* entitlement on every request.
|
|
194
|
+
*/
|
|
195
|
+
export function CopyEditing({ content, children }: CopyEditingProps) {
|
|
196
|
+
const [session, setSession] = useState<EditorSession | null>(null);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
// Read the session only after mount so server-rendered HTML never differs from the visitor's.
|
|
200
|
+
setSession(readEditorSession());
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
// Read at request time (the provider keeps its options from the first render), so the
|
|
204
|
+
// token is always the one currently stored and never sent once the session is cleared.
|
|
205
|
+
const headers = useCallback((): Record<string, string> => {
|
|
206
|
+
const current = readEditorSession();
|
|
207
|
+
return current ? { authorization: \`Bearer \${current.token}\` } : {};
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
const onExit = useCallback(async () => {
|
|
211
|
+
clearEditorSession();
|
|
212
|
+
window.location.assign("/");
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit}>
|
|
217
|
+
{children}
|
|
218
|
+
</RefinoProvider>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
`;
|
package/dist/templates.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
+
import type { TemplateFile } from "./template-file.js";
|
|
1
2
|
import type { MigrationPlan } from "./types.js";
|
|
2
|
-
export
|
|
3
|
-
readonly path: string;
|
|
4
|
-
readonly content: string;
|
|
5
|
-
}
|
|
3
|
+
export type { TemplateFile, TemplateRendering } from "./template-file.js";
|
|
6
4
|
export declare const ENV_EXAMPLE_BLOCK = "\n# --- Refino (server-side only; never expose with NEXT_PUBLIC_/VITE_) ---\n# Edit-mode login. Both required in production; development falls back to password \"edit\".\nEDITOR_PASSWORD=\nEDITOR_SESSION_SECRET=\n# Persistence: \"local\" writes the copy file on disk (development), \"github\" commits it.\nCOPY_ADAPTER=local\n# GitHub persistence (COPY_ADAPTER=github): fine-grained token, Contents read/write, one repo.\nCOPY_GITHUB_TOKEN=\nCOPY_GITHUB_REPO=owner/name\nCOPY_GITHUB_BRANCH=main\nCOPY_FILE_PATH=\n";
|
|
7
5
|
/** Files the apply step may create for a plan, in TS or JS as the project requires. */
|
|
8
6
|
export declare function boilerplateFiles(plan: MigrationPlan): TemplateFile[];
|