import type { Kysely } from 'kysely'; import type { Database } from '../db/schema.js'; import { type ContentItem, type SeoData } from './items.js'; /** * Preview links that survive a cross-origin split. * * `?preview=1` used to work because the site and the CMS shared an origin, so an editor's session * cookie was sent with the request and the route checked the *session*, never the parameter. That * distinction was the whole security property, and it stops being available the moment the site is * a separate deployment: the cookie is not sent, and there is nothing left to check. * * So the capability moves into a token. A row rather than a signed value, following * `login_challenges`: it must be short-lived and revocable, and a self-contained signed token stays * valid however the account changes underneath it. It also avoids inventing a signing secret, which * would need a working default for `npm run dev` — and a default signing secret is not a secret. * * **One mechanism covers a draft and a release's staged version.** Phase 3.5 added a second thing * worth previewing, and giving it its own token is how two nearly-identical paths drift until one * of them stops checking something. */ /** Long enough to open the link and read the page; short enough that a shared URL goes stale. */ export declare const PREVIEW_TOKEN_TTL_MS: number; /** * Re-exported from `pure.ts`, which is where it is declared. * * That file is the only entry a consumer can import — this one needs Kysely — so the constant lives * there and is surfaced here so `@taprootcms/core` still exposes it to the server. One string, two * doors. */ export { PREVIEW_PARAM, PREVIEW_MESSAGE } from '../pure.js'; /** * Where the site that reads this content lives, or the reason nobody knows. * * One function rather than a `process.env` read at each call site, because there are now three — * the redirect that mints a link, the JSON mint the split-view pane uses, and the pane's own empty * state — and an operator who has not set this should not be told three different things about it. * * Unset is a configuration gap with no sensible guess: redirecting to a 404 on the CMS's own origin * would be the same failure, spelled confusingly. */ export declare const NO_SITE_URL: string; export declare function previewSiteUrl(env: { TAPROOT_SITE_URL?: string; }): string | undefined; export interface GeneratedPreviewToken { /** The raw token. Exists here and in the link, never stored. */ token: string; expiresAt: Date; } export declare function createPreviewToken(db: Kysely, input: { contentItemId: string; releaseId?: string | null; userId?: string | null; }): Promise; export interface ResolvedPreview { /** * The item as the preview should show it. * * For a release preview this is the live row with the staged title, slug, data, and SEO merged * over it — which is what the page will look like *after* the release publishes, and therefore * the only useful thing to preview. Merged rather than fabricated so everything not staged * (status, path, parent) stays true. * * With a draft snapshot present, the editor's unsaved state is merged over that in turn. */ item: ContentItem; releaseId: string | null; /** * Whether unsaved editor state was merged in. * * Not on the wire — `buildItemPayload` never sees it. It exists so tests can assert the merge * happened rather than inferring it from a value that might have matched by chance. */ draft: boolean; } /** * The content a preview token names, if the token is still good. * * Returns `undefined` for absent, malformed, unknown, and expired alike — the caller must answer * identically to all four, because distinguishing them tells whoever is guessing which guess was * once real. * * Deliberately **not** single-use. A preview link is opened, and then the page is reloaded, and * links inside it are followed back — burning the token on first read would make the feature work * exactly once per click, which is how a security measure becomes something people route around. * The short expiry is what bounds it instead. */ export declare function resolvePreviewToken(db: Kysely, token: string | null | undefined): Promise; /** The editor's unsaved form state, as the pane sends it. */ export interface PreviewDraft { title: string; slug: string; data: Record; seo: SeoData; } export type PreviewDraftOutcome = /** Written. `expiresAt` is the slid expiry the client should trust from here. */ { ok: true; expiresAt: Date; } /** * Unknown, expired, or not this person's. * * One outcome for all three, following `resolvePreviewToken` — telling them apart tells whoever * is guessing which guess was once real. */ | { ok: false; reason: 'unknown'; } /** * The draft breaks a rule that is not about being unfinished — a value past a `maxLength`, most * likely, which the editor warns about but does not block. * * The caller keeps the previous snapshot rather than clearing it, so the pane goes on showing the * last good state instead of going blank while somebody is briefly over a limit. */ | { ok: false; reason: 'invalid'; errors: Record; }; /** * Store an unsaved editor snapshot against a preview token. * * The ownership rule lives here rather than in the route, for the same reason `deleteItem` enforces * its own blockers: the REST API must not be able to do what the admin declines. * * **The client never names the item.** `content_item_id` is on the row, so there is no request that * can aim a snapshot at a different content item — which is what makes a plain role check at the * boundary sufficient, with no per-item permission to invent. * * Sanitising is not this function's own idea: the draft goes through `validateItemData`, which is * where richtext sanitising lives for every write path in the system. `requireComplete: false` * relaxes three completeness rules and nothing else — see that option for which three and why * sanitising is not among them. * * The write slides `expires_at` by a full TTL. The thirty minutes exist so a *shared link* goes * stale, and somebody with the editor open and typing is not a stale link; without this the pane * dies mid-sentence on any page worth spending half an hour on. It costs one column in an `UPDATE` * that is happening anyway, and it makes the bound an idle timeout, which is the honest reading. */ export declare function writePreviewDraft(db: Kysely, input: { token: string; userId: string; draft: PreviewDraft; }): Promise; /** Drop expired tokens. Safe to call on a schedule. */ export declare function purgeExpiredPreviewTokens(db: Kysely): Promise;