@getrefino/onboarding 0.1.0-rc.4 → 0.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +16 -0
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +19 -0
- package/dist/generated.d.ts +8 -0
- package/dist/generated.js +25 -5
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -1
- package/dist/integration-update.d.ts +83 -0
- package/dist/integration-update.js +281 -0
- package/dist/lockfiles.d.ts +73 -0
- package/dist/lockfiles.js +443 -0
- package/dist/plan.d.ts +1 -12
- package/dist/plan.js +2 -13
- package/dist/registry.d.ts +42 -0
- package/dist/registry.js +111 -0
- package/dist/semver.d.ts +14 -0
- package/dist/semver.js +64 -0
- package/dist/template-file.js +1 -1
- package/dist/templates-hosted.d.ts +7 -4
- package/dist/templates-hosted.js +319 -58
- package/dist/templates-legacy.d.ts +26 -1
- package/dist/templates-legacy.js +566 -1
- package/dist/templates.js +1 -1
- package/dist/verify.js +30 -0
- package/package.json +2 -2
package/dist/templates-legacy.js
CHANGED
|
@@ -11,8 +11,16 @@
|
|
|
11
11
|
* renderings. `generated-templates.test.ts` fails until you do, because it
|
|
12
12
|
* pins the hash of every generated file.
|
|
13
13
|
*/
|
|
14
|
-
/** The last release that wrote the bodies below. */
|
|
14
|
+
/** The last release that wrote the rc.1 bodies below. */
|
|
15
15
|
export const RC1 = "0.1.0-rc.1";
|
|
16
|
+
/**
|
|
17
|
+
* The last release that wrote the rc.4 bodies below (unchanged since rc.2).
|
|
18
|
+
* If rc.4 is ever published with the update-aware bodies instead, this label
|
|
19
|
+
* is still harmless: it only names the `from` version in upgrade reports.
|
|
20
|
+
*/
|
|
21
|
+
export const RC4 = "0.1.0-rc.4";
|
|
22
|
+
/** The release that wrote the rc.5 bodies below: the published 0.1.0-rc.5. */
|
|
23
|
+
export const RC5 = "0.1.0-rc.5";
|
|
16
24
|
/** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
|
|
17
25
|
export const REFINO_SITE_RC1 = (config) => `/**
|
|
18
26
|
* Public Refino configuration for this site. Neither value is a secret:
|
|
@@ -219,3 +227,560 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
|
|
|
219
227
|
);
|
|
220
228
|
}
|
|
221
229
|
`;
|
|
230
|
+
/** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates, and before reportEditorEvent carried the editing surface. */
|
|
231
|
+
export const REFINO_CLIENT_RC4 = `/**
|
|
232
|
+
* Browser-side Refino editor authorization for this site. No secrets here:
|
|
233
|
+
* the site is a public client (OAuth 2.1 with PKCE). The editor token lives
|
|
234
|
+
* in sessionStorage only (never localStorage, never a URL), is scoped to
|
|
235
|
+
* this site, and Refino re-checks the account's entitlement on every load
|
|
236
|
+
* and save. Never import this from server code.
|
|
237
|
+
*/
|
|
238
|
+
import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
|
|
239
|
+
|
|
240
|
+
const PKCE_KEY = "refino.pkce";
|
|
241
|
+
const SESSION_KEY = "refino.editor";
|
|
242
|
+
const CALLBACK_PATH = "/edit";
|
|
243
|
+
|
|
244
|
+
export interface EditorSession {
|
|
245
|
+
readonly token: string;
|
|
246
|
+
/** Unix seconds. */
|
|
247
|
+
readonly expiresAt: number;
|
|
248
|
+
readonly siteId: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface PendingAuthorization {
|
|
252
|
+
readonly verifier: string;
|
|
253
|
+
readonly state: string;
|
|
254
|
+
readonly returnTo: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function base64url(bytes: Uint8Array): string {
|
|
258
|
+
let binary = "";
|
|
259
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
260
|
+
return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function randomToken(bytes: number): string {
|
|
264
|
+
const buffer = new Uint8Array(bytes);
|
|
265
|
+
crypto.getRandomValues(buffer);
|
|
266
|
+
return base64url(buffer);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function sha256(text: string): Promise<string> {
|
|
270
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
271
|
+
return base64url(new Uint8Array(digest));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function storage(): Storage | null {
|
|
275
|
+
try {
|
|
276
|
+
return typeof window === "undefined" ? null : window.sessionStorage;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readJson<T>(key: string): T | null {
|
|
283
|
+
const raw = storage()?.getItem(key);
|
|
284
|
+
if (!raw) return null;
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(raw) as T;
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Only a path on this site, never another origin. */
|
|
293
|
+
export function safeReturnTo(value: string | null | undefined): string {
|
|
294
|
+
if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function editorEndpoint(): string {
|
|
299
|
+
return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function callbackUrl(): string {
|
|
303
|
+
return \`\${window.location.origin}\${CALLBACK_PATH}\`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The current editor session, or null when there is none or it expired. */
|
|
307
|
+
export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
|
|
308
|
+
const session = readJson<EditorSession>(SESSION_KEY);
|
|
309
|
+
if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
|
|
310
|
+
if (session.expiresAt <= nowSeconds) {
|
|
311
|
+
storage()?.removeItem(SESSION_KEY);
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
return session;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function clearEditorSession(): void {
|
|
318
|
+
storage()?.removeItem(SESSION_KEY);
|
|
319
|
+
storage()?.removeItem(PKCE_KEY);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Start the flow: remember the PKCE verifier and state, then go to Refino. */
|
|
323
|
+
export async function beginAuthorization(returnTo: string): Promise<void> {
|
|
324
|
+
const verifier = randomToken(32);
|
|
325
|
+
const state = randomToken(16);
|
|
326
|
+
const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
|
|
327
|
+
storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
|
|
328
|
+
const params = new URLSearchParams({
|
|
329
|
+
site_id: REFINO_SITE_ID,
|
|
330
|
+
redirect_uri: callbackUrl(),
|
|
331
|
+
code_challenge: await sha256(verifier),
|
|
332
|
+
code_challenge_method: "S256",
|
|
333
|
+
state,
|
|
334
|
+
});
|
|
335
|
+
window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Finish the flow on /edit?code&state: check the state, exchange the code
|
|
342
|
+
* with the verifier this browser kept, store the session, and scrub the
|
|
343
|
+
* code from the URL. The token is never placed in a URL.
|
|
344
|
+
*/
|
|
345
|
+
export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
|
|
346
|
+
const params = new URLSearchParams(search);
|
|
347
|
+
const code = params.get("code");
|
|
348
|
+
const state = params.get("state");
|
|
349
|
+
const pending = readJson<PendingAuthorization>(PKCE_KEY);
|
|
350
|
+
storage()?.removeItem(PKCE_KEY);
|
|
351
|
+
if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
|
|
352
|
+
if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
|
|
353
|
+
if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
|
|
354
|
+
|
|
355
|
+
let response: Response;
|
|
356
|
+
try {
|
|
357
|
+
response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
mode: "cors",
|
|
360
|
+
credentials: "omit",
|
|
361
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
grant_type: "authorization_code",
|
|
364
|
+
code,
|
|
365
|
+
code_verifier: pending.verifier,
|
|
366
|
+
site_id: REFINO_SITE_ID,
|
|
367
|
+
redirect_uri: callbackUrl(),
|
|
368
|
+
// The public install facts from refino-site.ts, sent once per
|
|
369
|
+
// sign-in. Refino records them the first time and ignores them
|
|
370
|
+
// afterwards. Remove this line and everything still works.
|
|
371
|
+
install: REFINO_INSTALL,
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
} catch {
|
|
375
|
+
return { ok: false, error: "Could not reach Refino to finish signing in." };
|
|
376
|
+
}
|
|
377
|
+
const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
|
|
378
|
+
if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
|
|
379
|
+
return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
|
|
380
|
+
}
|
|
381
|
+
const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
|
|
382
|
+
storage()?.setItem(SESSION_KEY, JSON.stringify(session));
|
|
383
|
+
return { ok: true, returnTo: pending.returnTo };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The three editor events this site reports, and the only ones it ever
|
|
388
|
+
* sends. There is no analytics SDK on this site: no autocapture, no session
|
|
389
|
+
* recording, no third-party script, no cookie. Each event is one small POST
|
|
390
|
+
* to Refino carrying an event name and, for a revert, whether it was one
|
|
391
|
+
* word or all of them — never your page, your copy, your visitors or your
|
|
392
|
+
* repository. \`edit_saved\` is not in this list on purpose: a save is a
|
|
393
|
+
* commit, and Refino records that on its own side where it cannot be faked
|
|
394
|
+
* or lost.
|
|
395
|
+
*/
|
|
396
|
+
const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
|
|
397
|
+
|
|
398
|
+
type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
|
|
399
|
+
|
|
400
|
+
interface EditorEventLike {
|
|
401
|
+
readonly name: string;
|
|
402
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Fire and forget. A failure here is invisible and harmless: nothing in the
|
|
407
|
+
* editor waits for it, retries it, or behaves differently when it fails.
|
|
408
|
+
*/
|
|
409
|
+
export function reportEditorEvent(event: EditorEventLike): void {
|
|
410
|
+
const session = readEditorSession();
|
|
411
|
+
if (!session) return;
|
|
412
|
+
if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
|
|
413
|
+
const name = event.name as ReportableEvent;
|
|
414
|
+
const scope = event.properties.scope;
|
|
415
|
+
try {
|
|
416
|
+
void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
|
|
417
|
+
method: "POST",
|
|
418
|
+
mode: "cors",
|
|
419
|
+
credentials: "omit",
|
|
420
|
+
keepalive: true,
|
|
421
|
+
headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
|
|
422
|
+
body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
|
|
423
|
+
}).catch(() => {});
|
|
424
|
+
} catch {
|
|
425
|
+
// Never let reporting affect editing.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
`;
|
|
429
|
+
/** 0.1.0-rc.4 refino/copy-editing.tsx: before updates={refinoUpdates}. */
|
|
430
|
+
export const COPY_EDITING_HOSTED_RC4 = `"use client";
|
|
431
|
+
|
|
432
|
+
import type { CopyContent } from "@getrefino/core";
|
|
433
|
+
import { RefinoProvider } from "@getrefino/react";
|
|
434
|
+
import type { ReactNode } from "react";
|
|
435
|
+
import { useCallback, useEffect, useState } from "react";
|
|
436
|
+
|
|
437
|
+
import { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from "./refino-client";
|
|
438
|
+
import type { EditorSession } from "./refino-client";
|
|
439
|
+
|
|
440
|
+
interface CopyEditingProps {
|
|
441
|
+
content: CopyContent;
|
|
442
|
+
children: ReactNode;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Hosted mode (Refino). Visitors get the plain site: the server never
|
|
447
|
+
* decides edit mode, so pages can stay static. In the browser, an editor
|
|
448
|
+
* session stored by /edit turns edit mode on; loads and saves go to Refino
|
|
449
|
+
* with the session's bearer token, and Refino checks the site's
|
|
450
|
+
* entitlement on every request.
|
|
451
|
+
*/
|
|
452
|
+
export function CopyEditing({ content, children }: CopyEditingProps) {
|
|
453
|
+
const [session, setSession] = useState<EditorSession | null>(null);
|
|
454
|
+
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
// Read the session only after mount so server-rendered HTML never differs from the visitor's.
|
|
457
|
+
setSession(readEditorSession());
|
|
458
|
+
}, []);
|
|
459
|
+
|
|
460
|
+
// Read at request time (the provider keeps its options from the first render), so the
|
|
461
|
+
// token is always the one currently stored and never sent once the session is cleared.
|
|
462
|
+
const headers = useCallback((): Record<string, string> => {
|
|
463
|
+
const current = readEditorSession();
|
|
464
|
+
return current ? { authorization: \`Bearer \${current.token}\` } : {};
|
|
465
|
+
}, []);
|
|
466
|
+
|
|
467
|
+
const onExit = useCallback(async () => {
|
|
468
|
+
clearEditorSession();
|
|
469
|
+
window.location.assign("/");
|
|
470
|
+
}, []);
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit} onEvent={reportEditorEvent}>
|
|
474
|
+
{children}
|
|
475
|
+
</RefinoProvider>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
`;
|
|
479
|
+
/**
|
|
480
|
+
* 0.1.0-rc.5 refino/refino-client.ts: before `resolveEditorPhase`, `signOut`
|
|
481
|
+
* and the hardened `safeReturnTo`. This is what every rc.5 site has on disk.
|
|
482
|
+
*/
|
|
483
|
+
export const REFINO_CLIENT_RC5 = `/**
|
|
484
|
+
* Browser-side Refino editor authorization for this site. No secrets here:
|
|
485
|
+
* the site is a public client (OAuth 2.1 with PKCE). The editor token lives
|
|
486
|
+
* in sessionStorage only (never localStorage, never a URL), is scoped to
|
|
487
|
+
* this site, and Refino re-checks the account's entitlement on every load
|
|
488
|
+
* and save. Never import this from server code.
|
|
489
|
+
*/
|
|
490
|
+
import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
|
|
491
|
+
|
|
492
|
+
const PKCE_KEY = "refino.pkce";
|
|
493
|
+
const SESSION_KEY = "refino.editor";
|
|
494
|
+
const CALLBACK_PATH = "/edit";
|
|
495
|
+
|
|
496
|
+
export interface EditorSession {
|
|
497
|
+
readonly token: string;
|
|
498
|
+
/** Unix seconds. */
|
|
499
|
+
readonly expiresAt: number;
|
|
500
|
+
readonly siteId: string;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
interface PendingAuthorization {
|
|
504
|
+
readonly verifier: string;
|
|
505
|
+
readonly state: string;
|
|
506
|
+
readonly returnTo: string;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function base64url(bytes: Uint8Array): string {
|
|
510
|
+
let binary = "";
|
|
511
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
512
|
+
return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function randomToken(bytes: number): string {
|
|
516
|
+
const buffer = new Uint8Array(bytes);
|
|
517
|
+
crypto.getRandomValues(buffer);
|
|
518
|
+
return base64url(buffer);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function sha256(text: string): Promise<string> {
|
|
522
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
523
|
+
return base64url(new Uint8Array(digest));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function storage(): Storage | null {
|
|
527
|
+
try {
|
|
528
|
+
return typeof window === "undefined" ? null : window.sessionStorage;
|
|
529
|
+
} catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function readJson<T>(key: string): T | null {
|
|
535
|
+
const raw = storage()?.getItem(key);
|
|
536
|
+
if (!raw) return null;
|
|
537
|
+
try {
|
|
538
|
+
return JSON.parse(raw) as T;
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Only a path on this site, never another origin. */
|
|
545
|
+
export function safeReturnTo(value: string | null | undefined): string {
|
|
546
|
+
if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function editorEndpoint(): string {
|
|
551
|
+
return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function callbackUrl(): string {
|
|
555
|
+
return \`\${window.location.origin}\${CALLBACK_PATH}\`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The current editor session, or null when there is none or it expired. */
|
|
559
|
+
export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
|
|
560
|
+
const session = readJson<EditorSession>(SESSION_KEY);
|
|
561
|
+
if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
|
|
562
|
+
if (session.expiresAt <= nowSeconds) {
|
|
563
|
+
storage()?.removeItem(SESSION_KEY);
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return session;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function clearEditorSession(): void {
|
|
570
|
+
storage()?.removeItem(SESSION_KEY);
|
|
571
|
+
storage()?.removeItem(PKCE_KEY);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Start the flow: remember the PKCE verifier and state, then go to Refino. */
|
|
575
|
+
export async function beginAuthorization(returnTo: string): Promise<void> {
|
|
576
|
+
const verifier = randomToken(32);
|
|
577
|
+
const state = randomToken(16);
|
|
578
|
+
const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
|
|
579
|
+
storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
|
|
580
|
+
const params = new URLSearchParams({
|
|
581
|
+
site_id: REFINO_SITE_ID,
|
|
582
|
+
redirect_uri: callbackUrl(),
|
|
583
|
+
code_challenge: await sha256(verifier),
|
|
584
|
+
code_challenge_method: "S256",
|
|
585
|
+
state,
|
|
586
|
+
});
|
|
587
|
+
window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Finish the flow on /edit?code&state: check the state, exchange the code
|
|
594
|
+
* with the verifier this browser kept, store the session, and scrub the
|
|
595
|
+
* code from the URL. The token is never placed in a URL.
|
|
596
|
+
*/
|
|
597
|
+
export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
|
|
598
|
+
const params = new URLSearchParams(search);
|
|
599
|
+
const code = params.get("code");
|
|
600
|
+
const state = params.get("state");
|
|
601
|
+
const pending = readJson<PendingAuthorization>(PKCE_KEY);
|
|
602
|
+
storage()?.removeItem(PKCE_KEY);
|
|
603
|
+
if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
|
|
604
|
+
if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
|
|
605
|
+
if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
|
|
606
|
+
|
|
607
|
+
let response: Response;
|
|
608
|
+
try {
|
|
609
|
+
response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
mode: "cors",
|
|
612
|
+
credentials: "omit",
|
|
613
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
grant_type: "authorization_code",
|
|
616
|
+
code,
|
|
617
|
+
code_verifier: pending.verifier,
|
|
618
|
+
site_id: REFINO_SITE_ID,
|
|
619
|
+
redirect_uri: callbackUrl(),
|
|
620
|
+
// The public install facts from refino-site.ts, sent once per
|
|
621
|
+
// sign-in. Refino records them the first time and ignores them
|
|
622
|
+
// afterwards. Remove this line and everything still works.
|
|
623
|
+
install: REFINO_INSTALL,
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
} catch {
|
|
627
|
+
return { ok: false, error: "Could not reach Refino to finish signing in." };
|
|
628
|
+
}
|
|
629
|
+
const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
|
|
630
|
+
if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
|
|
631
|
+
return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
|
|
632
|
+
}
|
|
633
|
+
const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
|
|
634
|
+
storage()?.setItem(SESSION_KEY, JSON.stringify(session));
|
|
635
|
+
return { ok: true, returnTo: pending.returnTo };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The three editor events this site reports, and the only ones it ever
|
|
640
|
+
* sends. There is no analytics SDK on this site: no autocapture, no session
|
|
641
|
+
* recording, no third-party script, no cookie. Each event is one small POST
|
|
642
|
+
* to Refino carrying an event name and, for a revert, whether it was one
|
|
643
|
+
* word or all of them — never your page, your copy, your visitors or your
|
|
644
|
+
* repository. \`edit_saved\` is not in this list on purpose: a save is a
|
|
645
|
+
* commit, and Refino records that on its own side where it cannot be faked
|
|
646
|
+
* or lost.
|
|
647
|
+
*/
|
|
648
|
+
const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
|
|
649
|
+
|
|
650
|
+
type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
|
|
651
|
+
|
|
652
|
+
interface EditorEventLike {
|
|
653
|
+
readonly name: string;
|
|
654
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Fire and forget. A failure here is invisible and harmless: nothing in the
|
|
659
|
+
* editor waits for it, retries it, or behaves differently when it fails.
|
|
660
|
+
*/
|
|
661
|
+
export function reportEditorEvent(event: EditorEventLike): void {
|
|
662
|
+
const session = readEditorSession();
|
|
663
|
+
if (!session) return;
|
|
664
|
+
if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
|
|
665
|
+
const name = event.name as ReportableEvent;
|
|
666
|
+
const scope = event.properties.scope;
|
|
667
|
+
try {
|
|
668
|
+
void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
|
|
669
|
+
method: "POST",
|
|
670
|
+
mode: "cors",
|
|
671
|
+
credentials: "omit",
|
|
672
|
+
keepalive: true,
|
|
673
|
+
headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
|
|
674
|
+
body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
|
|
675
|
+
}).catch(() => {});
|
|
676
|
+
} catch {
|
|
677
|
+
// Never let reporting affect editing.
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Updates to this site's Refino integration, offered in the editor
|
|
683
|
+
* toolbar. Asking is read-only. Nothing changes unless the owner presses
|
|
684
|
+
* "Update Refino": Refino then commits the new Refino version to this
|
|
685
|
+
* repository, and the site's normal deployment takes it from there. The
|
|
686
|
+
* editor checks every answer itself, so this stays a plain pipe.
|
|
687
|
+
*/
|
|
688
|
+
export const refinoUpdates = {
|
|
689
|
+
check: (): Promise<unknown> => updateRequest("GET"),
|
|
690
|
+
apply: (version: string): Promise<unknown> => updateRequest("POST", JSON.stringify({ version })),
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
async function updateRequest(method: "GET" | "POST", body?: string): Promise<unknown> {
|
|
694
|
+
const session = readEditorSession();
|
|
695
|
+
if (!session) return null;
|
|
696
|
+
const headers: Record<string, string> = { accept: "application/json", authorization: \`Bearer \${session.token}\` };
|
|
697
|
+
if (body) headers["content-type"] = "application/json";
|
|
698
|
+
try {
|
|
699
|
+
const response = await fetch(\`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/update\`, { method, mode: "cors", credentials: "omit", headers, body });
|
|
700
|
+
return await response.json();
|
|
701
|
+
} catch {
|
|
702
|
+
// Unreachable or unreadable: the editor treats it as "nothing to offer", or as an unknown outcome.
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
`;
|
|
707
|
+
/**
|
|
708
|
+
* refino/edit-page.tsx as every release up to and including 0.1.0-rc.5 wrote
|
|
709
|
+
* it (the body never changed after rc.1): the unstyled handoff screen, which
|
|
710
|
+
* a successful sign-in never landed on, and its "Sign in as someone else"
|
|
711
|
+
* button, which cleared the site's editor session but not the Refino one and
|
|
712
|
+
* so signed the same account straight back in.
|
|
713
|
+
*/
|
|
714
|
+
export const EDIT_PAGE_HOSTED_RC5 = `"use client";
|
|
715
|
+
|
|
716
|
+
import { useEffect, useState } from "react";
|
|
717
|
+
|
|
718
|
+
import { beginAuthorization, clearEditorSession, completeAuthorization, readEditorSession, safeReturnTo } from "./refino-client";
|
|
719
|
+
|
|
720
|
+
type Phase = { kind: "loading" } | { kind: "signed-in" } | { kind: "error"; message: string };
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Owner entry point for Refino-hosted editing. Visiting /edit sends the
|
|
724
|
+
* owner to Refino to sign in and authorize this site; Refino sends them
|
|
725
|
+
* back here with a one-time code, which is exchanged for an editor session
|
|
726
|
+
* and scrubbed from the URL. Restyle freely; keep the behaviour.
|
|
727
|
+
*/
|
|
728
|
+
export default function EditPage() {
|
|
729
|
+
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
|
|
730
|
+
|
|
731
|
+
useEffect(() => {
|
|
732
|
+
let cancelled = false;
|
|
733
|
+
const params = new URLSearchParams(window.location.search);
|
|
734
|
+
if (params.has("code") || params.has("state")) {
|
|
735
|
+
void completeAuthorization().then((result) => {
|
|
736
|
+
if (cancelled) return;
|
|
737
|
+
if (result.ok) window.location.assign(result.returnTo);
|
|
738
|
+
else setPhase({ kind: "error", message: result.error });
|
|
739
|
+
});
|
|
740
|
+
} else if (readEditorSession()) {
|
|
741
|
+
setPhase({ kind: "signed-in" });
|
|
742
|
+
} else if (params.get("error")) {
|
|
743
|
+
setPhase({ kind: "error", message: params.get("error") ?? "Sign-in failed." });
|
|
744
|
+
} else {
|
|
745
|
+
void beginAuthorization(safeReturnTo(params.get("return_to")));
|
|
746
|
+
}
|
|
747
|
+
return () => {
|
|
748
|
+
cancelled = true;
|
|
749
|
+
};
|
|
750
|
+
}, []);
|
|
751
|
+
|
|
752
|
+
return (
|
|
753
|
+
<main style={{ maxWidth: 420, margin: "10vh auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
|
|
754
|
+
{phase.kind === "loading" && <p>Signing you in with Refino…</p>}
|
|
755
|
+
{phase.kind === "signed-in" && (
|
|
756
|
+
<>
|
|
757
|
+
<h1>Edit mode is on</h1>
|
|
758
|
+
<p>Go to the site, click any text, and press Save when you are done.</p>
|
|
759
|
+
<p>
|
|
760
|
+
<a href="/">Open the site</a>
|
|
761
|
+
</p>
|
|
762
|
+
<button
|
|
763
|
+
type="button"
|
|
764
|
+
onClick={() => {
|
|
765
|
+
clearEditorSession();
|
|
766
|
+
setPhase({ kind: "loading" });
|
|
767
|
+
void beginAuthorization("/");
|
|
768
|
+
}}
|
|
769
|
+
>
|
|
770
|
+
Sign in as someone else
|
|
771
|
+
</button>
|
|
772
|
+
</>
|
|
773
|
+
)}
|
|
774
|
+
{phase.kind === "error" && (
|
|
775
|
+
<>
|
|
776
|
+
<h1>Could not start editing</h1>
|
|
777
|
+
<p role="alert">{phase.message}</p>
|
|
778
|
+
<button type="button" onClick={() => void beginAuthorization("/")}>
|
|
779
|
+
Try again
|
|
780
|
+
</button>
|
|
781
|
+
</>
|
|
782
|
+
)}
|
|
783
|
+
</main>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
`;
|
package/dist/templates.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* is invited to restyle -- and, when its body has changed since a released
|
|
11
11
|
* version, the byte-exact body that version wrote.
|
|
12
12
|
*/
|
|
13
|
-
import { BOILERPLATE_DIR } from "./
|
|
13
|
+
import { BOILERPLATE_DIR } from "./constants.js";
|
|
14
14
|
import { createTemplateFactory } from "./template-file.js";
|
|
15
15
|
import { hostedFiles } from "./templates-hosted.js";
|
|
16
16
|
const BOILERPLATE_DIR_NAME = BOILERPLATE_DIR;
|
package/dist/verify.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { isContentId, parseCopy } from "@getrefino/core";
|
|
9
|
+
import { parseContentConfig } from "@getrefino/core/documents";
|
|
9
10
|
import ts from "typescript";
|
|
10
11
|
import { exists, isFile, isTestOrStoryFile, readJson, readText, rel, walkFiles } from "./fs.js";
|
|
11
12
|
import { COMPARISON_SUFFIX, classifyGeneratedFiles, readGeneratedManifest } from "./generated.js";
|
|
@@ -389,6 +390,35 @@ export function verifyIntegration(rootPath, options = {}) {
|
|
|
389
390
|
add({ id: "content-ids", status: "pass", message: "All ids are valid." });
|
|
390
391
|
}
|
|
391
392
|
}
|
|
393
|
+
// Content sources (article editing). Absent is the normal case and not a
|
|
394
|
+
// finding: a site that edits only copy has nothing to declare.
|
|
395
|
+
if (config?.content !== undefined) {
|
|
396
|
+
try {
|
|
397
|
+
const content = parseContentConfig(config.content);
|
|
398
|
+
const missing = content.sources.filter((source) => !exists(join(appDir, source.dir)));
|
|
399
|
+
if (content.sources.length === 0) {
|
|
400
|
+
add({ id: "content-sources", status: "warn", message: 'refino.config.json has a "content" block with no sources.', fix: 'Add a source, or remove the "content" block.' });
|
|
401
|
+
}
|
|
402
|
+
else if (missing.length > 0) {
|
|
403
|
+
add({
|
|
404
|
+
id: "content-sources",
|
|
405
|
+
status: "fail",
|
|
406
|
+
message: `Content source directories do not exist: ${missing.map((source) => `${source.id} (${source.dir})`).join(", ")}.`,
|
|
407
|
+
fix: "Point each source at the directory that actually holds the articles.",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
add({
|
|
412
|
+
id: "content-sources",
|
|
413
|
+
status: "pass",
|
|
414
|
+
message: `Article sources: ${content.sources.map((source) => `${source.id} (${source.dir}/*${source.extensions.join(",*")})`).join(", ")}.`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
add({ id: "content-sources", status: "fail", message: error.message, fix: 'Fix the "content" block, or remove it to turn article editing off.' });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
392
422
|
// Source scan
|
|
393
423
|
const sourceFiles = walkFiles(appDir).filter((file) => !isTestOrStoryFile(rel(appDir, file)));
|
|
394
424
|
const uses = collectEditableTextUses(appDir, sourceFiles);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getrefino/onboarding",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.6",
|
|
4
4
|
"description": "The engine behind `npx @getrefino/cli init`: deterministic repository inspection, copy discovery, migration planning, agent instructions and verification for adding Refino to an existing React site. Development tooling only; never part of a site's runtime.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"refino",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"typescript": ">=5.5 <7",
|
|
41
|
-
"@getrefino/core": "0.1.0-rc.
|
|
41
|
+
"@getrefino/core": "0.1.0-rc.6"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "22.20.2",
|