@backmatter/core 0.0.18 → 0.0.20

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.
@@ -2,11 +2,15 @@
2
2
  import { signPayload, verifyPayload } from "./signed-token";
3
3
 
4
4
  export const PREVIEW_COOKIE = "backmatter_preview";
5
+ export const PREVIEW_SESSION_COOKIE = "backmatter_preview_session";
6
+ export const PREVIEW_SESSION_QUERY = "backmatter_session";
7
+ export const PREVIEW_SESSION_STORAGE_KEY = "backmatter_preview_session";
5
8
  export const PREVIEW_QUERY = "backmatter_preview";
6
9
  export const PREVIEW_CHECKOUT_QUERY = "backmatter_checkout";
7
10
  export const PREVIEW_ENABLE_PATH = "/api/backmatter/preview";
8
11
  export const PREVIEW_TICKET_TTL_MS = 10 * 60 * 1000;
9
12
  export const PREVIEW_COOKIE_TTL_MS = 12 * 60 * 60 * 1000;
13
+ export const PREVIEW_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
10
14
 
11
15
  export type PreviewCheckout = {
12
16
  branch?: string;
@@ -19,6 +23,21 @@ export type PreviewTicket = {
19
23
  redirect: string;
20
24
  branch?: string;
21
25
  tree?: string;
26
+ /** Preview session token. The host stores it and drops it from the URL. */
27
+ session?: string;
28
+ exp: number;
29
+ };
30
+
31
+ /** One user, one workspace, one branch, one working tree. */
32
+ export type PreviewSessionClaims = {
33
+ v: 1;
34
+ kind: "preview-session";
35
+ userId: string;
36
+ workspaceId: string;
37
+ branch: string;
38
+ tree: string;
39
+ role: string;
40
+ epoch: number;
22
41
  exp: number;
23
42
  };
24
43
 
@@ -42,6 +61,7 @@ export async function mintPreviewTicket(
42
61
  redirect: string;
43
62
  branch?: string;
44
63
  tree?: string;
64
+ session?: string;
45
65
  exp: number;
46
66
  },
47
67
  ): Promise<string> {
@@ -55,10 +75,25 @@ export async function mintPreviewTicket(
55
75
  exp: input.exp,
56
76
  ...(input.branch ? { branch: input.branch } : {}),
57
77
  ...(input.tree ? { tree: input.tree } : {}),
78
+ ...(input.session ? { session: input.session } : {}),
58
79
  };
59
80
  return signPayload(secret, payload);
60
81
  }
61
82
 
83
+ export async function mintPreviewSession(
84
+ secret: string,
85
+ input: Omit<PreviewSessionClaims, "v" | "kind">,
86
+ ): Promise<string> {
87
+ if (input.tree !== input.userId) {
88
+ throw new Error("preview session tree must be the user");
89
+ }
90
+ if (!input.branch || !input.workspaceId || !input.userId) {
91
+ throw new Error("preview session is missing scope");
92
+ }
93
+ const payload: PreviewSessionClaims = { v: 1, kind: "preview-session", ...input };
94
+ return signPayload(secret, payload);
95
+ }
96
+
62
97
  function asTicket(value: unknown): PreviewTicket | null {
63
98
  if (!value || typeof value !== "object") return null;
64
99
  const v = value as Record<string, unknown>;
@@ -74,13 +109,85 @@ function asTicket(value: unknown): PreviewTicket | null {
74
109
  exp: v.exp,
75
110
  ...(typeof v.branch === "string" && v.branch ? { branch: v.branch } : {}),
76
111
  ...(typeof v.tree === "string" && v.tree ? { tree: v.tree } : {}),
112
+ ...(typeof v.session === "string" && v.session ? { session: v.session } : {}),
113
+ };
114
+ }
115
+
116
+ function asSession(value: unknown): PreviewSessionClaims | null {
117
+ if (!value || typeof value !== "object") return null;
118
+ const v = value as Record<string, unknown>;
119
+ if (v.v !== 1 || v.kind !== "preview-session") return null;
120
+ if (typeof v.userId !== "string" || typeof v.workspaceId !== "string") return null;
121
+ if (typeof v.branch !== "string" || typeof v.tree !== "string" || typeof v.role !== "string") {
122
+ return null;
123
+ }
124
+ if (v.tree !== v.userId) return null;
125
+ if (typeof v.epoch !== "number" || typeof v.exp !== "number" || v.exp <= Date.now()) return null;
126
+ return {
127
+ v: 1,
128
+ kind: "preview-session",
129
+ userId: v.userId,
130
+ workspaceId: v.workspaceId,
131
+ branch: v.branch,
132
+ tree: v.tree,
133
+ role: v.role,
134
+ epoch: v.epoch,
135
+ exp: v.exp,
77
136
  };
78
137
  }
79
138
 
139
+ /** Workspace id from an unverified body, so the server can load the signing secret. */
140
+ export function peekPreviewSessionWorkspace(token: string): string | null {
141
+ const dot = token.lastIndexOf(".");
142
+ if (dot <= 0) return null;
143
+ try {
144
+ const padded = token.slice(0, dot).replace(/-/g, "+").replace(/_/g, "/");
145
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
146
+ const json = JSON.parse(atob(padded + pad)) as Record<string, unknown>;
147
+ return json.kind === "preview-session" && typeof json.workspaceId === "string"
148
+ ? json.workspaceId
149
+ : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
80
155
  export async function readPreviewTicket(secret: string, token: string): Promise<PreviewTicket | null> {
81
156
  return asTicket(await verifyPayload(secret, token));
82
157
  }
83
158
 
159
+ export async function readPreviewSession(
160
+ secret: string,
161
+ token: string | undefined,
162
+ ): Promise<PreviewSessionClaims | null> {
163
+ if (!token) return null;
164
+ return asSession(await verifyPayload(secret, token));
165
+ }
166
+
167
+ /**
168
+ * A session token may read and write only its own tree. A workspace preview
169
+ * token keeps the caller's query.
170
+ */
171
+ export function scopePreviewAccess(
172
+ auth: { mode: "workspace" } | { mode: "session"; branch: string; tree: string },
173
+ query: { branch: string; tree: string | null },
174
+ ): { branch: string; tree: string | null } | null {
175
+ if (auth.mode === "workspace") return query;
176
+ if (query.tree && query.tree !== auth.tree) return null;
177
+ if (query.branch && query.branch !== auth.branch) return null;
178
+ return { branch: auth.branch, tree: auth.tree };
179
+ }
180
+
181
+ /** Pull a session token out of the URL. The returned search no longer contains it. */
182
+ export function takePreviewSessionFromSearch(search: string): { token: string | null; search: string } {
183
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
184
+ const token = params.get(PREVIEW_SESSION_QUERY)?.trim() || null;
185
+ if (!token) return { token: null, search };
186
+ params.delete(PREVIEW_SESSION_QUERY);
187
+ const next = params.toString();
188
+ return { token, search: next ? `?${next}` : "" };
189
+ }
190
+
84
191
  export function previewLocationWithCheckout(
85
192
  origin: string,
86
193
  redirect: string,
package/src/preview.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // packages/core/src/preview.ts
2
- import type { BackmatterDoc } from "./doc";
2
+ import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
3
3
 
4
4
  export type PreviewDocRef = {
5
5
  locale: string;
@@ -164,6 +164,14 @@ export function previewStreamUrl(
164
164
  return `${docPath(options, ref)}/stream?${previewParams(options, ref)}`;
165
165
  }
166
166
 
167
+ /** Tree-wide stream: added, modified, removed, and HEAD moves for one checkout. */
168
+ export function previewTreeStreamUrl(
169
+ options: PreviewClientOptions,
170
+ checkout: { branch?: string; tree?: string },
171
+ ): string {
172
+ return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/tree/stream?${previewParams(options, { locale: "", type: "", slug: "", ...checkout })}`;
173
+ }
174
+
167
175
  export async function fetchPreviewDoc(
168
176
  options: PreviewClientOptions,
169
177
  ref: PreviewDocRef,
@@ -176,6 +184,38 @@ export async function fetchPreviewDoc(
176
184
  return data.doc;
177
185
  }
178
186
 
187
+ /**
188
+ * HEAD overlaid with the checkout's working tree. Follows cursors so a list
189
+ * page sees every document of the type, not just the first page.
190
+ */
191
+ export async function fetchPreviewDocs(
192
+ options: PreviewClientOptions,
193
+ query: { branch?: string; tree?: string; type?: string; locale?: string },
194
+ ): Promise<BackmatterDocSummary[]> {
195
+ const docs: BackmatterDocSummary[] = [];
196
+ let cursor: string | undefined;
197
+ for (let page = 0; page < 100; page++) {
198
+ const params = previewParams(options, {
199
+ locale: query.locale ?? "",
200
+ type: "",
201
+ slug: "",
202
+ branch: query.branch,
203
+ tree: query.tree,
204
+ });
205
+ if (query.type) params.set("type", query.type);
206
+ if (query.locale) params.set("locale", query.locale);
207
+ if (cursor) params.set("cursor", cursor);
208
+ const url = `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs?${params}`;
209
+ const res = await fetch(url, { cache: "no-store" });
210
+ if (!res.ok) throw new Error(`preview listDocs failed: ${res.status}`);
211
+ const data = (await res.json()) as { docs: BackmatterDocSummary[]; nextCursor: string | null };
212
+ docs.push(...data.docs);
213
+ if (!data.nextCursor) break;
214
+ cursor = data.nextCursor;
215
+ }
216
+ return docs;
217
+ }
218
+
179
219
  /** Browser EventSource subscribe to one working tree's saves; returns unsubscribe. */
180
220
  export function subscribePreview(
181
221
  options: PreviewClientOptions,
@@ -203,3 +243,38 @@ export function subscribePreview(
203
243
  source.close();
204
244
  };
205
245
  }
246
+
247
+ export {
248
+ createLocalPreviewTransport,
249
+ createPostMessageTransport,
250
+ createWindowPreviewTransport,
251
+ isAllowedPreviewOrigin,
252
+ normalizePreviewOrigin,
253
+ parsePreviewMessage,
254
+ PREVIEW_CHANNEL_VERSION,
255
+ samePreviewRef,
256
+ } from "./preview-channel";
257
+ export type {
258
+ PostMessageTransportOptions,
259
+ PreviewChannelMessage,
260
+ PreviewChannelRef,
261
+ PreviewDocMessage,
262
+ PreviewHelloMessage,
263
+ PreviewMessageEvent,
264
+ PreviewNavigateMessage,
265
+ PreviewRemovedMessage,
266
+ PreviewStatusMessage,
267
+ PreviewTransport,
268
+ PreviewWindowLike,
269
+ } from "./preview-channel";
270
+ export { createPreviewEditor, createPreviewPage } from "./preview-client";
271
+ export type {
272
+ PreviewEditorSession,
273
+ PreviewPageHandlers,
274
+ PreviewPageSession,
275
+ PreviewRenderDoc,
276
+ PreviewRenderResult,
277
+ PreviewRenderer,
278
+ PreviewSchedule,
279
+ PreviewSseEvent,
280
+ } from "./preview-client";
package/src/source.ts ADDED
@@ -0,0 +1,24 @@
1
+ // packages/core/src/source.ts
2
+ // A source resolves the outside record behind an `external` model's route.
3
+ // Host code only (`sources/{type}.ts`); never serialized into the schema.
4
+
5
+ export type SourceRef = { locale: string; slug: string };
6
+
7
+ export type SourceDef<T = unknown> = {
8
+ /** The outside record for a route hit. `null` means the record does not exist (404). */
9
+ load: (ref: SourceRef) => Promise<T | null>;
10
+ /** Slugs to prerender, per locale. Omit to prerender only slugs that have a CMS file. */
11
+ list?: (locale: string) => Promise<readonly string[]>;
12
+ /** One representative record for template preview. Omit to preview the template with `data: null`. */
13
+ sample?: (locale: string) => Promise<T | null>;
14
+ /** Metadata when the document has no `title`. */
15
+ metadata?: (record: T) => { title?: string };
16
+ };
17
+
18
+ /** Identity helper that validates the definition and keeps the record type. */
19
+ export function defineSource<T>(def: SourceDef<T>): SourceDef<T> {
20
+ if (!def || typeof def !== "object" || typeof def.load !== "function") {
21
+ throw new Error("source.load is required and must be a function");
22
+ }
23
+ return def;
24
+ }