@getrefino/onboarding 0.1.0-rc.2 → 0.1.0-rc.4
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 +11 -3
- package/dist/agent.d.ts +0 -4
- package/dist/agent.js +2 -1
- package/dist/apply.js +71 -34
- package/dist/generated.d.ts +30 -0
- package/dist/generated.js +168 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/inspect.d.ts +2 -0
- package/dist/inspect.js +14 -0
- package/dist/template-file.d.ts +38 -0
- package/dist/template-file.js +44 -0
- package/dist/templates-hosted.d.ts +2 -13
- package/dist/templates-hosted.js +65 -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 +75 -0
- package/dist/verify.js +143 -3
- package/package.json +2 -2
package/dist/templates-hosted.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
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";
|
|
1
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";
|
|
2
16
|
export const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
|
|
3
17
|
export const REFINO_SITE_ID_PATTERN = /^site_[a-f0-9]{32}$/;
|
|
4
18
|
/** Public configuration; safe to ship to the browser and to commit. */
|
|
@@ -398,35 +412,70 @@ export function analyticsHosting(hosting) {
|
|
|
398
412
|
return "unknown";
|
|
399
413
|
return ["cloudflare", "vercel", "netlify"].includes(hosting) ? hosting : "other";
|
|
400
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 };
|
|
401
452
|
/** Files for a Refino-connected site. No server code, no secrets, in any framework. */
|
|
402
|
-
export function hostedFiles(plan) {
|
|
453
|
+
export function hostedFiles(plan, factory) {
|
|
403
454
|
const refino = plan.refino;
|
|
404
455
|
if (!refino)
|
|
405
456
|
return [];
|
|
457
|
+
const make = factory ?? createTemplateFactory(plan.repository.language);
|
|
406
458
|
const dir = plan.integration.boilerplateDir;
|
|
407
459
|
const files = [
|
|
408
|
-
{
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
},
|
|
417
|
-
{ path: `${dir}/refino-client.ts`, content: REFINO_CLIENT },
|
|
418
|
-
{ path: `${dir}/copy-editing.tsx`, content: COPY_EDITING_HOSTED },
|
|
419
|
-
{ path: `${dir}/edit-page.tsx`, content: EDIT_PAGE_HOSTED },
|
|
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),
|
|
420
469
|
];
|
|
421
470
|
const editPage = plan.files.find((file) => /(^|\/)edit(\/page)?\.\w+$/.test(file.path) && !file.path.startsWith(`${dir}/`))?.path;
|
|
422
471
|
if (plan.repository.router === "next-app") {
|
|
423
472
|
const routesDir = editPage?.replace(/\/edit\/page\.\w+$/, "") ?? "app";
|
|
424
|
-
files.push(
|
|
425
|
-
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`))));
|
|
426
475
|
}
|
|
427
476
|
else if (plan.repository.router === "next-pages") {
|
|
428
477
|
const routesDir = editPage?.replace(/\/edit\.\w+$/, "") ?? "pages";
|
|
429
|
-
files.push(
|
|
478
|
+
files.push(make.machine(`${routesDir}/edit.tsx`, EDIT_PAGE_NEXT_PAGES_HOSTED(relativeImport(`${routesDir}/edit.tsx`, `${dir}/edit-page.tsx`))));
|
|
430
479
|
}
|
|
431
480
|
return files;
|
|
432
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[];
|
package/dist/templates.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Boilerplate written by `refino init --
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Boilerplate written by `refino init --agent`. Each file is isolated under
|
|
3
|
+
* `refino/` (or is a new route file). Authored in TypeScript; JavaScript
|
|
4
|
+
* projects get a transpiled copy.
|
|
5
|
+
*
|
|
6
|
+
* A file that already exists is refreshed only when Refino can prove the
|
|
7
|
+
* bytes on disk are still the ones it wrote (generated.ts); otherwise it is
|
|
8
|
+
* left exactly as it is and reported. Each entry therefore declares who owns
|
|
9
|
+
* it -- `machine` for Refino's runtime code, `scaffold` for a page the owner
|
|
10
|
+
* is invited to restyle -- and, when its body has changed since a released
|
|
11
|
+
* version, the byte-exact body that version wrote.
|
|
6
12
|
*/
|
|
7
|
-
import ts from "typescript";
|
|
8
13
|
import { BOILERPLATE_DIR } from "./plan.js";
|
|
14
|
+
import { createTemplateFactory } from "./template-file.js";
|
|
9
15
|
import { hostedFiles } from "./templates-hosted.js";
|
|
10
16
|
const BOILERPLATE_DIR_NAME = BOILERPLATE_DIR;
|
|
11
17
|
function relativeImport(fromFile, toFile) {
|
|
@@ -601,71 +607,56 @@ COPY_GITHUB_REPO=owner/name
|
|
|
601
607
|
COPY_GITHUB_BRANCH=main
|
|
602
608
|
COPY_FILE_PATH=
|
|
603
609
|
`;
|
|
604
|
-
function transpileToJs(path, source) {
|
|
605
|
-
const output = ts.transpileModule(source, {
|
|
606
|
-
compilerOptions: {
|
|
607
|
-
target: ts.ScriptTarget.ES2022,
|
|
608
|
-
module: ts.ModuleKind.ESNext,
|
|
609
|
-
jsx: ts.JsxEmit.Preserve,
|
|
610
|
-
removeComments: false,
|
|
611
|
-
verbatimModuleSyntax: false,
|
|
612
|
-
},
|
|
613
|
-
fileName: path,
|
|
614
|
-
});
|
|
615
|
-
return { path: path.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js"), content: output.outputText.replace(/^export \{\};\s*$/m, "").replace(/\n{3,}/g, "\n\n") };
|
|
616
|
-
}
|
|
617
610
|
/** Files the apply step may create for a plan, in TS or JS as the project requires. */
|
|
618
611
|
export function boilerplateFiles(plan) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
return plan
|
|
622
|
-
}
|
|
612
|
+
const make = createTemplateFactory(plan.repository.language);
|
|
613
|
+
if (plan.refino)
|
|
614
|
+
return hostedFiles(plan, make);
|
|
623
615
|
const dir = plan.integration.boilerplateDir;
|
|
624
616
|
const strategy = plan.integration.provider.strategy;
|
|
625
617
|
const host = plan.integration.host;
|
|
626
618
|
// Server-rendered hosts pass `editing` from a cookie check; everything else probes the session in the browser.
|
|
627
619
|
const serverRenderedEditing = strategy === "next-app-root-layout" || strategy === "next-pages-app";
|
|
628
620
|
const handlers = `${dir}/request-handlers.ts`;
|
|
621
|
+
// The self-hosted templates below are byte-identical to every release so
|
|
622
|
+
// far, so none of them carries a `previous` rendering yet.
|
|
629
623
|
const files = [
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
624
|
+
make.machine(`${dir}/editor-auth.ts`, EDITOR_AUTH),
|
|
625
|
+
make.machine(`${dir}/content-adapter.ts`, CONTENT_ADAPTER(plan.contentFile.path)),
|
|
626
|
+
make.machine(handlers, REQUEST_HANDLERS),
|
|
627
|
+
make.machine(`${dir}/copy-editing.tsx`, serverRenderedEditing ? COPY_EDITING : COPY_EDITING_CLIENT_SESSION),
|
|
634
628
|
];
|
|
635
629
|
if (strategy === "next-app-root-layout-client-session") {
|
|
636
630
|
const routesDir = plan.files.find((file) => /\/edit\/page\.\w+$/.test(file.path))?.path.replace(/\/edit\/page\.\w+$/, "") ?? "app";
|
|
637
|
-
files.push(
|
|
638
|
-
files.push(
|
|
631
|
+
files.push(make.scaffold(`${routesDir}/edit/layout.tsx`, EDIT_LAYOUT_STATIC));
|
|
632
|
+
files.push(make.scaffold(`${routesDir}/edit/page.tsx`, EDIT_PAGE_STATIC));
|
|
639
633
|
}
|
|
640
634
|
const copyFile = host.copyEndpoint.replace(/\.\w+$/, ".ts");
|
|
641
635
|
const sessionFile = host.sessionEndpoint.replace(/\.\w+$/, ".ts");
|
|
642
636
|
switch (host.kind) {
|
|
643
637
|
case "next-route-handlers": {
|
|
644
638
|
const editPage = `${copyFile.replace(/\/api\/copy\/route\.ts$/, "")}/edit/page.tsx`;
|
|
645
|
-
files.push(
|
|
646
|
-
files.push(
|
|
647
|
-
files.push(
|
|
648
|
-
files.push(
|
|
639
|
+
files.push(make.machine(`${dir}/editor-session.ts`, EDITOR_SESSION_NEXT));
|
|
640
|
+
files.push(make.machine(copyFile, COPY_ROUTE_NEXT(relativeImport(copyFile, handlers))));
|
|
641
|
+
files.push(make.machine(sessionFile, SESSION_ROUTE_NEXT(relativeImport(sessionFile, handlers))));
|
|
642
|
+
files.push(make.scaffold(editPage, EDIT_PAGE_NEXT(relativeImport(editPage, `${dir}/editor-session.ts`))));
|
|
649
643
|
break;
|
|
650
644
|
}
|
|
651
645
|
case "cloudflare-pages-functions":
|
|
652
|
-
files.push(
|
|
653
|
-
files.push(
|
|
646
|
+
files.push(make.machine(copyFile, CLOUDFLARE_FUNCTION("copy", relativeImport(copyFile, handlers))));
|
|
647
|
+
files.push(make.machine(sessionFile, CLOUDFLARE_FUNCTION("edit-session", relativeImport(sessionFile, handlers))));
|
|
654
648
|
break;
|
|
655
649
|
case "netlify-functions":
|
|
656
|
-
files.push(
|
|
657
|
-
files.push(
|
|
650
|
+
files.push(make.machine(copyFile, NETLIFY_FUNCTION("copy", relativeImport(copyFile, handlers))));
|
|
651
|
+
files.push(make.machine(sessionFile, NETLIFY_FUNCTION("edit-session", relativeImport(sessionFile, handlers))));
|
|
658
652
|
break;
|
|
659
653
|
case "vercel-functions":
|
|
660
|
-
files.push(
|
|
661
|
-
files.push(
|
|
654
|
+
files.push(make.machine(copyFile, VERCEL_FUNCTION("copy", relativeImport(copyFile, handlers))));
|
|
655
|
+
files.push(make.machine(sessionFile, VERCEL_FUNCTION("edit-session", relativeImport(sessionFile, handlers))));
|
|
662
656
|
break;
|
|
663
657
|
default:
|
|
664
658
|
// next-api-routes, custom-server, unknown: the agent adapts request-handlers (see the instructions).
|
|
665
659
|
break;
|
|
666
660
|
}
|
|
667
|
-
if (plan.repository.language === "javascript") {
|
|
668
|
-
return files.map((file) => transpileToJs(file.path, file.content));
|
|
669
|
-
}
|
|
670
661
|
return files;
|
|
671
662
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -143,6 +143,68 @@ export interface HostIntegration {
|
|
|
143
143
|
/** Runtime facts the agent and the owner need (filesystem, compatibility flags, variables). */
|
|
144
144
|
readonly notes: readonly string[];
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Who owns a file Refino generated.
|
|
148
|
+
*
|
|
149
|
+
* `machine` Refino's own runtime code. It carries behaviour the editor and
|
|
150
|
+
* the hosted service depend on, holds nothing a site is meant to
|
|
151
|
+
* hand-write, and Refino refreshes it whenever it can prove the
|
|
152
|
+
* bytes on disk are still the ones it wrote.
|
|
153
|
+
* `scaffold` A generated starting point the owner is invited to restyle (the
|
|
154
|
+
* /edit pages). Refino refreshes an untouched one and never
|
|
155
|
+
* argues with a restyled one.
|
|
156
|
+
*
|
|
157
|
+
* Everything Refino merges rather than owns -- the copy file, package.json,
|
|
158
|
+
* .env.example, refino.config.json -- is customer-owned and is not in this
|
|
159
|
+
* vocabulary at all: it is never overwritten, only merged.
|
|
160
|
+
*/
|
|
161
|
+
export type GeneratedOwnership = "machine" | "scaffold";
|
|
162
|
+
/**
|
|
163
|
+
* What became of one generated file on an init run, or what verification
|
|
164
|
+
* found there.
|
|
165
|
+
*
|
|
166
|
+
* `create` it did not exist; Refino wrote it.
|
|
167
|
+
* `current` the bytes on disk are exactly what this version generates.
|
|
168
|
+
* `update` stale, and provably untouched since Refino wrote it, so it was
|
|
169
|
+
* (or can be) refreshed in place.
|
|
170
|
+
* `customized` changed locally, and Refino's own version of it has not moved
|
|
171
|
+
* since: nothing to upgrade, nothing to warn about.
|
|
172
|
+
* `review` changed locally *and* stale, or impossible to prove untouched.
|
|
173
|
+
* Never overwritten; always reported.
|
|
174
|
+
*/
|
|
175
|
+
export type GeneratedFileStatus = "create" | "current" | "update" | "customized" | "review";
|
|
176
|
+
/** One generated file, its ownership, and where its bytes came from. */
|
|
177
|
+
export interface GeneratedFileState {
|
|
178
|
+
readonly path: string;
|
|
179
|
+
readonly ownership: GeneratedOwnership;
|
|
180
|
+
readonly status: GeneratedFileStatus;
|
|
181
|
+
/** Refino version whose output the bytes on disk match, when that is knowable. */
|
|
182
|
+
readonly from: string | null;
|
|
183
|
+
/** Refino version generating the desired bytes. */
|
|
184
|
+
readonly to: string;
|
|
185
|
+
/** Why this status, in one sentence. Always set for `review`. */
|
|
186
|
+
readonly reason: string;
|
|
187
|
+
/**
|
|
188
|
+
* For `review`: the repository-relative file Refino wrote the current
|
|
189
|
+
* template to, beside the original, so the difference can be read and
|
|
190
|
+
* merged by hand. The original is never touched.
|
|
191
|
+
*/
|
|
192
|
+
readonly comparisonPath?: string;
|
|
193
|
+
}
|
|
194
|
+
/** `.refino/generated.json`: what Refino wrote, so a later run can tell. */
|
|
195
|
+
export interface GeneratedManifest {
|
|
196
|
+
readonly version: 1;
|
|
197
|
+
/** Refino version that last wrote this manifest. */
|
|
198
|
+
readonly tool: string;
|
|
199
|
+
readonly files: Readonly<Record<string, GeneratedManifestEntry>>;
|
|
200
|
+
}
|
|
201
|
+
export interface GeneratedManifestEntry {
|
|
202
|
+
readonly ownership: GeneratedOwnership;
|
|
203
|
+
/** Refino version that wrote these bytes. */
|
|
204
|
+
readonly toolVersion: string;
|
|
205
|
+
/** sha256, hex, of the exact bytes Refino wrote. */
|
|
206
|
+
readonly sha256: string;
|
|
207
|
+
}
|
|
146
208
|
export interface PlannedFile {
|
|
147
209
|
readonly path: string;
|
|
148
210
|
readonly action: "create" | "merge" | "append" | "modify";
|
|
@@ -272,6 +334,19 @@ export interface ApplyResult {
|
|
|
272
334
|
readonly reason: string;
|
|
273
335
|
}[];
|
|
274
336
|
readonly installCommand: string | null;
|
|
337
|
+
/**
|
|
338
|
+
* Every file Refino generates, and what this run did with it. `written`
|
|
339
|
+
* and `skipped` stay what they always were (the files this run touched or
|
|
340
|
+
* left alone); this is the upgrade story, and the only place a stale or
|
|
341
|
+
* locally modified generated file is visible.
|
|
342
|
+
*/
|
|
343
|
+
readonly generated: readonly GeneratedFileState[];
|
|
344
|
+
/**
|
|
345
|
+
* True when a generated file was left in place although Refino's version
|
|
346
|
+
* of it has moved on. An installation with this set is not current, and no
|
|
347
|
+
* caller may report it as one.
|
|
348
|
+
*/
|
|
349
|
+
readonly needsReview: boolean;
|
|
275
350
|
}
|
|
276
351
|
export type CheckStatus = "pass" | "fail" | "warn" | "skip";
|
|
277
352
|
export interface VerificationCheck {
|