From 530a11710547e92d8050574680debc8c95e2ec73 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 01:50:49 +1000 Subject: [PATCH 1/4] feat(examples): admin-editor list/save stub and send-test BFF Upgrade the admin-editor example into a consumer reference host with filesystem list/load/save, PR-reminder save stub, and a server-only PostKitClient send-test handler. Refresh stale editor lifecycle docs and link epic #7 scenario 4 to this pattern. Closes #134 Co-authored-by: Cursor --- docs/architecture/template-lifecycle.md | 6 +- docs/guides/editor-integration.md | 6 +- docs/onboarding/tenant-onboarding.md | 5 +- examples/admin-editor/App.tsx | 124 +++++++++++---- examples/admin-editor/README.md | 111 ++++++++++---- .../auth.password-reset/metadata.json | 8 + .../auth.password-reset/preview.json | 4 + .../auth.password-reset/template.json | 36 +++++ .../demo.welcome/metadata.json | 7 + .../email-templates/demo.welcome/preview.json | 3 + .../demo.welcome/template.json | 24 +++ examples/admin-editor/package.json | 5 +- .../src/create-client-from-env.spec.ts | 21 +++ .../src/create-client-from-env.ts | 22 +++ examples/admin-editor/src/save-stub.spec.ts | 41 +++++ examples/admin-editor/src/save-stub.ts | 31 ++++ .../src/send-test-handler.spec.ts | 96 ++++++++++++ .../admin-editor/src/send-test-handler.ts | 142 ++++++++++++++++++ .../admin-editor/src/template-store.spec.ts | 56 +++++++ examples/admin-editor/src/template-store.ts | 138 +++++++++++++++++ pnpm-lock.yaml | 3 + 21 files changed, 827 insertions(+), 62 deletions(-) create mode 100644 examples/admin-editor/content/email-templates/auth.password-reset/metadata.json create mode 100644 examples/admin-editor/content/email-templates/auth.password-reset/preview.json create mode 100644 examples/admin-editor/content/email-templates/auth.password-reset/template.json create mode 100644 examples/admin-editor/content/email-templates/demo.welcome/metadata.json create mode 100644 examples/admin-editor/content/email-templates/demo.welcome/preview.json create mode 100644 examples/admin-editor/content/email-templates/demo.welcome/template.json create mode 100644 examples/admin-editor/src/create-client-from-env.spec.ts create mode 100644 examples/admin-editor/src/create-client-from-env.ts create mode 100644 examples/admin-editor/src/save-stub.spec.ts create mode 100644 examples/admin-editor/src/save-stub.ts create mode 100644 examples/admin-editor/src/send-test-handler.spec.ts create mode 100644 examples/admin-editor/src/send-test-handler.ts create mode 100644 examples/admin-editor/src/template-store.spec.ts create mode 100644 examples/admin-editor/src/template-store.ts diff --git a/docs/architecture/template-lifecycle.md b/docs/architecture/template-lifecycle.md index 4e95822..19d8b4c 100644 --- a/docs/architecture/template-lifecycle.md +++ b/docs/architecture/template-lifecycle.md @@ -28,7 +28,7 @@ the body. Consumer repository content/email-templates//{template.json, metadata.json, preview.json} | - | edit (by hand today; the editor package is not implemented — see #5) + | edit (by hand or via @singleton-sd/post-kit-editor in a consumer admin) v Pull request in the consumer repository | @@ -158,4 +158,6 @@ surfaces as `404 TEMPLATE_NOT_FOUND`. See Template source may be authored by hand or with [`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor/README.md) -(published on npmjs; epic [#5](https://github.com/singleton-sd/post-kit/issues/5)). +(published on npmjs). Embed pattern: +[`examples/admin-editor`](../../examples/admin-editor/) and +[`guides/editor-integration.md`](../guides/editor-integration.md). diff --git a/docs/guides/editor-integration.md b/docs/guides/editor-integration.md index a5626ea..2b2dc17 100644 --- a/docs/guides/editor-integration.md +++ b/docs/guides/editor-integration.md @@ -182,8 +182,10 @@ Some teams stage drafts outside the publish branch: Keep staging credentials and PostKit send credentials on the server. The browser only talks to your admin API with the user’s session. -See [`examples/admin-editor/`](../../examples/admin-editor/) for an -in-memory load/save adapter that mirrors the callback contract without I/O. +See [`examples/admin-editor/`](../../examples/admin-editor/) for list/load from +`content/email-templates/`, a filesystem save stub (with PR reminder), an +in-memory UI adapter, and a server-only Send-test BFF +(`handleSendTest` + `POSTKIT_API_KEY`). ## Preview vs send-time Handlebars diff --git a/docs/onboarding/tenant-onboarding.md b/docs/onboarding/tenant-onboarding.md index 9a0323b..8f09d74 100644 --- a/docs/onboarding/tenant-onboarding.md +++ b/docs/onboarding/tenant-onboarding.md @@ -376,5 +376,6 @@ Deeper operational triage is being written under | Onboarding automation (token minting, tenant bootstrap CLI) | None. Every step above that touches configuration is manual. | `@singleton-sd/post-kit-client` and `@singleton-sd/post-kit-editor` are -published on npmjs. Further editor epic work (if any) is tracked by -[#5](https://github.com/singleton-sd/post-kit/issues/5). +published on npmjs. Admin embedding (list/load/save + Send-test BFF) is +demonstrated in [`examples/admin-editor`](../../examples/admin-editor/); +onboarding epic: [#7](https://github.com/singleton-sd/post-kit/issues/7). diff --git a/examples/admin-editor/App.tsx b/examples/admin-editor/App.tsx index 203c8ea..bedc14e 100644 --- a/examples/admin-editor/App.tsx +++ b/examples/admin-editor/App.tsx @@ -1,43 +1,115 @@ /** - * Minimal React embedding for `@singleton-sd/post-kit-editor`. + * Reference React host for `@singleton-sd/post-kit-editor`. * - * Persistence is the in-memory adapter under `./src/memory-persistence.ts`. - * No PostKit credentials, no network, no Send-test chrome. + * - List/load: seed from the filesystem store in Node tests / server wiring; + * this component takes a preloaded catalog for the embedding demo. + * - Save: `onSave` → your API → Git/PR (here: in-memory adapter for the demo). + * - Send-test: `onSendTest` POSTs to **your** `/api/email-templates/send-test` + * BFF which holds `POSTKIT_API_KEY` (see `src/send-test-handler.ts`). + * + * Never pass API keys into this module or the editor props. */ +import { useMemo, useState } from 'react'; import { EmailTemplateEditor, - loadTemplateSource, + type SerializedTemplateSource, type TemplateSourceFiles, + type SendTestResult, } from '@singleton-sd/post-kit-editor'; import { createMemoryPersistence, toOnSave } from './src/memory-persistence'; -import templateJson from './sample/template.json'; -import metadata from './sample/metadata.json'; -import previewData from './sample/preview.json'; +export interface AdminEditorExampleProps { + /** Catalog of templates the admin may open (from Git / your list API). */ + templates: TemplateSourceFiles[]; + /** + * Optional override for Send-test. Default posts to + * `/api/email-templates/send-test` with `{ templateKey, to, variables }`. + */ + sendTest?: ( + serialized: SerializedTemplateSource, + files: TemplateSourceFiles, + recipient: string, + ) => Promise | SendTestResult | void; +} + +async function defaultSendTest( + _serialized: SerializedTemplateSource, + files: TemplateSourceFiles, + recipient: string, +): Promise { + const res = await fetch('/api/email-templates/send-test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + templateKey: files.metadata.key, + to: recipient, + variables: files.previewData, + }), + }); + if (!res.ok) { + let detail = 'Send-test failed.'; + try { + const body = (await res.json()) as { error?: string }; + if (typeof body.error === 'string' && body.error.length > 0) { + detail = body.error; + } + } catch { + // keep generic message + } + return { ok: false, message: detail }; + } + return { ok: true }; +} + +export function AdminEditorExample({ + templates, + sendTest = defaultSendTest, +}: AdminEditorExampleProps) { + if (templates.length === 0) { + throw new Error('AdminEditorExample requires at least one template.'); + } -const seed: TemplateSourceFiles = loadTemplateSource({ - templateJson, - metadata, - previewData, -}); + const initialKey = templates[0]!.metadata.key; + const [selectedKey, setSelectedKey] = useState(initialKey); -const persistence = createMemoryPersistence({ [seed.metadata.key]: seed }); + const persistence = useMemo(() => { + const seed: Record = {}; + for (const t of templates) { + seed[t.metadata.key] = t; + } + return createMemoryPersistence(seed); + }, [templates]); -export function AdminEditorExample() { - const template = persistence.load(seed.metadata.key); + const template = persistence.load(selectedKey); + const availableVariables = template.metadata.variables.map((name) => ({ + name, + label: name, + })); return ( - +
+ + +
); } diff --git a/examples/admin-editor/README.md b/examples/admin-editor/README.md index 416a13b..d7057a8 100644 --- a/examples/admin-editor/README.md +++ b/examples/admin-editor/README.md @@ -1,38 +1,95 @@ -# Example: admin editor embedding +# Example: admin editor + Send-test BFF -Minimal, testable host for -[`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor): a React -embedding plus an in-memory persistence adapter. Not a full admin app — no -router, auth UI, or HTTP server. +Reference host for embedding +[`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor) in a +**consumer** admin app (InkAds back-office, etc.). PostKit does not host an +admin CMS — your app owns list/load/save auth and Git; this example shows the +wiring. Guide: [`docs/guides/editor-integration.md`](../../docs/guides/editor-integration.md). +## Topology (1:1 with a real admin) + +```text +Admin browser + → EmailTemplateEditor (onSave / onSendTest callbacks only) + → Your admin API (session / SSO — not PostKit API keys) + ├─ list/load/save → content/email-templates/… (or open a PR) + └─ POST …/send-test → PostKitClient + POSTKIT_API_KEY → PostKit API +Consumer CI + → post-kit-publish → Blob (template must be published before Send-test works) +``` + +Never put `POSTKIT_API_KEY` in a browser bundle. + ## What this proves -- `App.tsx` mounts `EmailTemplateEditor` with synthetic sample sources - (`jane@example.com` only). -- `createMemoryPersistence` implements load/save around the package’s real - contract: `onSave(serialized, files)` and structured `TemplateSourceFiles`. -- The adapter spec covers load after seed, save round-trip, and a save failure - surfaced to the caller — no browser / jsdom. +| Piece | Location | +| --- | --- | +| List / load / save on disk | `src/template-store.ts` + seeded `content/email-templates/` | +| Save stub + PR reminder | `src/save-stub.ts` | +| React host (list select + editor) | `App.tsx` (in-memory save for the UI demo) | +| Send-test BFF handler | `src/send-test-handler.ts` | +| Env → `PostKitClient` | `src/create-client-from-env.ts` | +| In-memory adapter (UI contract) | `src/memory-persistence.ts` | -## What this does not do +## Environment (server only) + +| Variable | Purpose | +| --- | --- | +| `POSTKIT_API_BASE_URL` | PostKit API base URL | +| `POSTKIT_API_KEY` | Bearer credential from the **consumer** secret store | + +Send-test targets the Blob templates for the tenant/environment bound to that +key. Draft-only Git files are not sendable until publish CI has run. + +## Wire the BFF (Express-style sketch) -- No PostKit send credentials or `@singleton-sd/post-kit-client` -- No Git, Blob, or network I/O -- No `onSendTest` (Send-test chrome stays hidden) +```ts +import express from 'express'; +import { createPostKitClientFromEnv } from './create-client-from-env'; +import { handleSendTest } from './send-test-handler'; + +const app = express(); +app.use(express.json()); + +app.post('/api/email-templates/send-test', async (req, res) => { + const client = createPostKitClientFromEnv(); + const result = await handleSendTest(req.body, { + client, + logError: (event, detail) => console.error(event, detail), + }); + res.status(result.status).json(result.body); +}); +``` + +`App.tsx` defaults `onSendTest` to `POST /api/email-templates/send-test` with +`{ templateKey, to, variables }` — no secrets in the payload. + +## Map to InkAds (or any) admin + +1. Embed `EmailTemplateEditor` on an authenticated admin route (your SSO/RBAC). +2. List/load from your Git tree or admin API (`createFsTemplateStore` pattern). +3. `onSave` → trusted server → commit or GitHub App PR (this example writes + locally and logs a PR reminder via `toFsOnSave`). +4. `onSendTest` → your BFF → `handleSendTest` + `createPostKitClientFromEnv`. +5. Publish CI: adapt [`docs/examples/publish-email-templates.yml`](../../docs/examples/publish-email-templates.yml). +6. Per-environment keys so Send-test hits the right Blob prefix. ## Layout ```text examples/admin-editor/ - App.tsx # EmailTemplateEditor + memory adapter - sample/ # synthetic template / metadata / preview + App.tsx + sample/ # single-template fixture for memory tests + content/email-templates/ # multi-template list/load/save seed src/ - memory-persistence.ts # in-memory load/save - memory-persistence.spec.ts - package.json # private - README.md + memory-persistence.ts + template-store.ts + save-stub.ts + send-test-handler.ts + create-client-from-env.ts + *.spec.ts ``` ## Run the tests @@ -43,11 +100,9 @@ From the repository root: pnpm --filter @singleton-sd/example-admin-editor test ``` -`pnpm test` at the root runs it too. - -## Copy into a real host +## What this does not do -1. Copy `App.tsx` (or the pattern) into your React admin route. -2. Replace `createMemoryPersistence` with a server-backed load/save that - writes `content/email-templates//` (or opens a PR / staging store). -3. Optionally add `onSendTest` that POSTs to **your** trusted server only. +- Real GitHub App / PR creation +- Consumer SSO/RBAC +- Live HTTP server in this package (handler is framework-agnostic) +- Browser-held PostKit credentials diff --git a/examples/admin-editor/content/email-templates/auth.password-reset/metadata.json b/examples/admin-editor/content/email-templates/auth.password-reset/metadata.json new file mode 100644 index 0000000..cddb9aa --- /dev/null +++ b/examples/admin-editor/content/email-templates/auth.password-reset/metadata.json @@ -0,0 +1,8 @@ +{ + "key": "auth.password-reset", + "name": "Password Reset", + "subject": "Reset your password", + "description": "Sent when a user requests a password reset link", + "variables": ["name", "resetUrl"], + "schemaVersion": "1" +} diff --git a/examples/admin-editor/content/email-templates/auth.password-reset/preview.json b/examples/admin-editor/content/email-templates/auth.password-reset/preview.json new file mode 100644 index 0000000..f203471 --- /dev/null +++ b/examples/admin-editor/content/email-templates/auth.password-reset/preview.json @@ -0,0 +1,4 @@ +{ + "name": "Jane Doe", + "resetUrl": "https://app.example.com/reset?token=preview-placeholder" +} diff --git a/examples/admin-editor/content/email-templates/auth.password-reset/template.json b/examples/admin-editor/content/email-templates/auth.password-reset/template.json new file mode 100644 index 0000000..7a45faa --- /dev/null +++ b/examples/admin-editor/content/email-templates/auth.password-reset/template.json @@ -0,0 +1,36 @@ +{ + "root": { + "type": "EmailLayout", + "data": { + "backdropColor": "#F8F8F8", + "canvasColor": "#FFFFFF", + "textColor": "#242424", + "fontFamily": "MODERN_SANS", + "childrenIds": ["block-greeting", "block-action"] + } + }, + "block-greeting": { + "type": "Text", + "data": { + "style": { + "fontWeight": "normal", + "padding": { "top": 24, "bottom": 8, "right": 24, "left": 24 } + }, + "props": { + "text": "Hi {{name}}, we received a request to reset your password." + } + } + }, + "block-action": { + "type": "Text", + "data": { + "style": { + "fontWeight": "normal", + "padding": { "top": 8, "bottom": 24, "right": 24, "left": 24 } + }, + "props": { + "text": "Open {{resetUrl}} to choose a new password. The link expires shortly and can be used once. If you did not request this, no action is needed." + } + } + } +} diff --git a/examples/admin-editor/content/email-templates/demo.welcome/metadata.json b/examples/admin-editor/content/email-templates/demo.welcome/metadata.json new file mode 100644 index 0000000..28361b0 --- /dev/null +++ b/examples/admin-editor/content/email-templates/demo.welcome/metadata.json @@ -0,0 +1,7 @@ +{ + "key": "demo.welcome", + "name": "Welcome", + "subject": "Hello {{name}}", + "variables": ["name"], + "schemaVersion": "1" +} diff --git a/examples/admin-editor/content/email-templates/demo.welcome/preview.json b/examples/admin-editor/content/email-templates/demo.welcome/preview.json new file mode 100644 index 0000000..0653c7b --- /dev/null +++ b/examples/admin-editor/content/email-templates/demo.welcome/preview.json @@ -0,0 +1,3 @@ +{ + "name": "Jane Doe" +} diff --git a/examples/admin-editor/content/email-templates/demo.welcome/template.json b/examples/admin-editor/content/email-templates/demo.welcome/template.json new file mode 100644 index 0000000..8867bc2 --- /dev/null +++ b/examples/admin-editor/content/email-templates/demo.welcome/template.json @@ -0,0 +1,24 @@ +{ + "root": { + "type": "EmailLayout", + "data": { + "backdropColor": "#F8F8F8", + "canvasColor": "#FFFFFF", + "textColor": "#242424", + "fontFamily": "MODERN_SANS", + "childrenIds": ["block-text"] + } + }, + "block-text": { + "type": "Text", + "data": { + "style": { + "fontWeight": "normal", + "padding": { "top": 16, "bottom": 16, "right": 24, "left": 24 } + }, + "props": { + "text": "Hello {{name}}" + } + } + } +} diff --git a/examples/admin-editor/package.json b/examples/admin-editor/package.json index 0010d53..6f4633a 100644 --- a/examples/admin-editor/package.json +++ b/examples/admin-editor/package.json @@ -2,12 +2,13 @@ "name": "@singleton-sd/example-admin-editor", "version": "0.0.0", "private": true, - "description": "Minimal React embedding + in-memory persistence for @singleton-sd/post-kit-editor", + "description": "Reference admin host: list/load/save stub + Send-test BFF for @singleton-sd/post-kit-editor", "license": "MIT", "scripts": { - "test": "pnpm --filter @singleton-sd/post-kit-editor run build && tsc -p tsconfig.json && node --import tsx --test src/memory-persistence.spec.ts" + "test": "pnpm --filter @singleton-sd/post-kit-editor run build && pnpm --filter @singleton-sd/post-kit-client run build && tsc -p tsconfig.json && node --import tsx --test src/*.spec.ts" }, "dependencies": { + "@singleton-sd/post-kit-client": "workspace:*", "@singleton-sd/post-kit-editor": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/examples/admin-editor/src/create-client-from-env.spec.ts b/examples/admin-editor/src/create-client-from-env.spec.ts new file mode 100644 index 0000000..17e0e6f --- /dev/null +++ b/examples/admin-editor/src/create-client-from-env.spec.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createPostKitClientFromEnv } from './create-client-from-env'; + +describe('createPostKitClientFromEnv', () => { + it('throws when env is incomplete', () => { + assert.throws(() => createPostKitClientFromEnv({}), /POSTKIT_API_BASE_URL/); + assert.throws( + () => createPostKitClientFromEnv({ POSTKIT_API_BASE_URL: 'https://example' }), + /POSTKIT_API_KEY/, + ); + }); + + it('builds a client when both vars are set', () => { + const client = createPostKitClientFromEnv({ + POSTKIT_API_BASE_URL: 'https://postkit.example/', + POSTKIT_API_KEY: 'key-for-tests-only', + }); + assert.ok(client); + }); +}); diff --git a/examples/admin-editor/src/create-client-from-env.ts b/examples/admin-editor/src/create-client-from-env.ts new file mode 100644 index 0000000..76d8b71 --- /dev/null +++ b/examples/admin-editor/src/create-client-from-env.ts @@ -0,0 +1,22 @@ +/** + * Build a {@link PostKitClient} from process env for admin Send-test BFFs. + * + * Required: + * - `POSTKIT_API_BASE_URL` — PostKit API base (no trailing slash required) + * - `POSTKIT_API_KEY` — Bearer credential (consumer Key Vault / secret store) + * + * Never call this from a browser bundle. + */ +import { PostKitClient } from '@singleton-sd/post-kit-client'; + +export function createPostKitClientFromEnv(env: NodeJS.ProcessEnv = process.env): PostKitClient { + const endpoint = env['POSTKIT_API_BASE_URL']?.trim(); + const apiKey = env['POSTKIT_API_KEY']?.trim(); + if (!endpoint) { + throw new Error('POSTKIT_API_BASE_URL is required for Send-test.'); + } + if (!apiKey) { + throw new Error('POSTKIT_API_KEY is required for Send-test.'); + } + return new PostKitClient({ endpoint, apiKey }); +} diff --git a/examples/admin-editor/src/save-stub.spec.ts b/examples/admin-editor/src/save-stub.spec.ts new file mode 100644 index 0000000..d3d6402 --- /dev/null +++ b/examples/admin-editor/src/save-stub.spec.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { after, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { serializeTemplateSource } from '@singleton-sd/post-kit-editor'; +import { createFsTemplateStore } from './template-store'; +import { toFsOnSave } from './save-stub'; + +const CONTENT_ROOT = fileURLToPath(new URL('../content/email-templates', import.meta.url)); + +describe('toFsOnSave', () => { + it('writes files and logs the PR reminder', () => { + const root = mkdtempSync(join(tmpdir(), 'pk-admin-save-')); + after(() => rmSync(root, { recursive: true, force: true })); + + const seedDir = join(root, 'demo.welcome'); + mkdirSync(seedDir); + for (const name of ['template.json', 'metadata.json', 'preview.json'] as const) { + writeFileSync( + join(seedDir, name), + readFileSync(join(CONTENT_ROOT, 'demo.welcome', name), 'utf8'), + ); + } + + const messages: string[] = []; + const store = createFsTemplateStore(root); + const onSave = toFsOnSave( + store, + (key) => key, + (m) => messages.push(m), + ); + const files = store.load('demo.welcome'); + const result = onSave(serializeTemplateSource(files), files); + + assert.equal(result.ok, true); + assert.equal(messages.length, 1); + assert.match(messages[0]!, /post-kit-publish/); + }); +}); diff --git a/examples/admin-editor/src/save-stub.ts b/examples/admin-editor/src/save-stub.ts new file mode 100644 index 0000000..7a903fa --- /dev/null +++ b/examples/admin-editor/src/save-stub.ts @@ -0,0 +1,31 @@ +/** + * Wire a filesystem {@link TemplateStore} as `EmailTemplateEditor` `onSave`. + * + * Writes the three source files under `content/email-templates//`. + * Real hosts usually open a GitHub PR instead of writing the publish branch + * directly — this stub prints that reminder after a successful write. + */ +import type { + SerializedTemplateSource, + TemplateSourceFiles, + SaveResult, +} from '@singleton-sd/post-kit-editor'; +import type { TemplateStore } from './template-store'; + +export function toFsOnSave( + store: TemplateStore, + directoryForKey: (key: string) => string, + log: (message: string) => void = console.info, +): (serialized: SerializedTemplateSource, files: TemplateSourceFiles) => SaveResult { + return (serialized, files) => { + const directory = directoryForKey(files.metadata.key); + const result = store.save(directory, serialized, files); + if (result.ok) { + log( + `[post-kit admin-editor] Saved ${directory}/{template,metadata,preview}.json. ` + + 'In production, open a PR for these files and let CI run post-kit-publish.', + ); + } + return result; + }; +} diff --git a/examples/admin-editor/src/send-test-handler.spec.ts b/examples/admin-editor/src/send-test-handler.spec.ts new file mode 100644 index 0000000..b8222a3 --- /dev/null +++ b/examples/admin-editor/src/send-test-handler.spec.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { PostKitClient } from '@singleton-sd/post-kit-client'; +import { handleSendTest } from './send-test-handler'; + +const TEST_KEY = 'test-key-not-a-real-credential'; + +function mockClient(fetchImpl: typeof fetch): PostKitClient { + return new PostKitClient({ + endpoint: 'https://postkit.example', + apiKey: TEST_KEY, + fetch: fetchImpl, + }); +} + +describe('handleSendTest', () => { + it('sends with server-injected client and returns 202', async () => { + let sawAuth = false; + const client = mockClient(async (input, init) => { + const headers = new Headers(init?.headers); + sawAuth = headers.get('authorization') === `Bearer ${TEST_KEY}`; + const body = JSON.parse(String(init?.body)) as { + template: string; + to: string; + variables: Record; + }; + assert.equal(body.template, 'demo.welcome'); + assert.equal(body.to, 'ops@example.com'); + assert.equal(body.variables['name'], 'Ada'); + return new Response(JSON.stringify({ id: 'corr-1', status: 'sent' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const result = await handleSendTest( + { + templateKey: 'demo.welcome', + to: 'ops@example.com', + variables: { name: 'Ada' }, + apiKey: 'attacker-supplied-ignored', + }, + { client }, + ); + + assert.equal(result.status, 202); + assert.equal(sawAuth, true); + if (result.status === 202) { + assert.equal(result.body.correlationId, 'corr-1'); + } + }); + + it('rejects invalid templateKey and to before calling PostKit', async () => { + let calls = 0; + const client = mockClient(async () => { + calls += 1; + return new Response('{}', { status: 200 }); + }); + + const badKey = await handleSendTest( + { templateKey: '../etc', to: 'a@b.co', variables: {} }, + { client }, + ); + assert.equal(badKey.status, 400); + if (badKey.status === 400) assert.equal(badKey.body.field, 'templateKey'); + + const badTo = await handleSendTest( + { templateKey: 'demo.welcome', to: 'not-an-email', variables: {} }, + { client }, + ); + assert.equal(badTo.status, 400); + if (badTo.status === 400) assert.equal(badTo.body.field, 'to'); + + assert.equal(calls, 0); + }); + + it('maps PostKit failures to a generic 502', async () => { + const logs: Array<{ event: string; detail: Record }> = []; + const client = mockClient(async () => new Response('nope', { status: 503 })); + + const result = await handleSendTest( + { templateKey: 'demo.welcome', to: 'ops@example.com', variables: {} }, + { + client, + logError: (event, detail) => logs.push({ event, detail }), + }, + ); + + assert.equal(result.status, 502); + if (result.status === 502) { + assert.equal(result.body.error, 'Test email could not be sent. Please try again later.'); + } + assert.equal(logs.length, 1); + assert.equal(logs[0]?.event, 'admin.send_test.failed'); + }); +}); diff --git a/examples/admin-editor/src/send-test-handler.ts b/examples/admin-editor/src/send-test-handler.ts new file mode 100644 index 0000000..204fa40 --- /dev/null +++ b/examples/admin-editor/src/send-test-handler.ts @@ -0,0 +1,142 @@ +import { PostKitClient, PostKitRequestError } from '@singleton-sd/post-kit-client'; + +/** + * Framework-agnostic Send-test BFF for an admin host embedding + * `@singleton-sd/post-kit-editor`. + * + * Browser → `POST /api/email-templates/send-test` (your route) → this handler + * → `PostKitClient.send`. The PostKit API key never leaves the server. + * + * The template must already be **published** to Blob for the tenant/environment + * bound to the Bearer credential. Draft-only Git files are not sendable until CI + * runs `post-kit-publish`. + */ + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const TEMPLATE_KEY_RE = /^[a-zA-Z0-9._-]+$/; + +export const SEND_TEST_LIMITS = { + emailMax: 254, + templateKeyMax: 128, +} as const; + +export interface SendTestDependencies { + client: PostKitClient; + logError?: (event: string, detail: Record) => void; +} + +export type SendTestResult = + | { status: 202; body: { status: 'accepted'; correlationId?: string } } + | { status: 400; body: { error: string; field: string } } + | { status: 502; body: { error: string } }; + +interface ValidSendTest { + templateKey: string; + to: string; + variables: Record; +} + +/** + * Validate an untrusted JSON body and send a test email through PostKit. + * Ignores any `apiKey` / `endpoint` / `from` fields on the body. + */ +export async function handleSendTest( + body: unknown, + deps: SendTestDependencies, +): Promise { + const validated = validateSendTestBody(body); + if (!validated.ok) { + return { status: 400, body: { error: validated.error, field: validated.field } }; + } + + const { templateKey, to, variables } = validated.value; + + try { + const response = await deps.client.send({ + template: templateKey, + to, + variables, + }); + return { + status: 202, + body: { status: 'accepted', correlationId: response.id }, + }; + } catch (err) { + if (err instanceof PostKitRequestError) { + deps.logError?.('admin.send_test.failed', { + code: err.code, + status: err.status, + correlationId: err.correlationId, + }); + } else { + deps.logError?.('admin.send_test.failed', { + name: err instanceof Error ? err.name : 'Error', + }); + } + return { + status: 502, + body: { error: 'Test email could not be sent. Please try again later.' }, + }; + } +} + +type ValidationOutcome = + { ok: true; value: ValidSendTest } | { ok: false; field: string; error: string }; + +function validateSendTestBody(body: unknown): ValidationOutcome { + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return { ok: false, field: 'body', error: 'Request body must be a JSON object.' }; + } + const obj = body as Record; + + if (typeof obj['templateKey'] !== 'string' || obj['templateKey'].trim() === '') { + return { ok: false, field: 'templateKey', error: 'templateKey is required.' }; + } + const templateKey = obj['templateKey'].trim(); + if ( + templateKey.length > SEND_TEST_LIMITS.templateKeyMax || + !TEMPLATE_KEY_RE.test(templateKey) || + templateKey === '.' || + templateKey === '..' + ) { + return { ok: false, field: 'templateKey', error: 'templateKey is invalid.' }; + } + + if (typeof obj['to'] !== 'string' || obj['to'].trim() === '') { + return { ok: false, field: 'to', error: 'to is required.' }; + } + const to = obj['to'].trim(); + if (to.length > SEND_TEST_LIMITS.emailMax || !EMAIL_RE.test(to)) { + return { ok: false, field: 'to', error: 'to must be a valid email address.' }; + } + + const variables = normalizeVariables(obj['variables']); + if (!variables.ok) { + return variables; + } + + return { ok: true, value: { templateKey, to, variables: variables.value } }; +} + +function normalizeVariables( + value: unknown, +): { ok: true; value: Record } | { ok: false; field: string; error: string } { + if (value === undefined || value === null) { + return { ok: true, value: {} }; + } + if (typeof value !== 'object' || Array.isArray(value)) { + return { ok: false, field: 'variables', error: 'variables must be an object of strings.' }; + } + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry !== 'string') { + return { + ok: false, + field: 'variables', + error: 'variables must be an object of strings.', + }; + } + out[key] = entry; + } + return { ok: true, value: out }; +} diff --git a/examples/admin-editor/src/template-store.spec.ts b/examples/admin-editor/src/template-store.spec.ts new file mode 100644 index 0000000..04357bf --- /dev/null +++ b/examples/admin-editor/src/template-store.spec.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, it, after } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { serializeTemplateSource } from '@singleton-sd/post-kit-editor'; +import { assertSafeDirectory, createFsTemplateStore } from './template-store'; + +const CONTENT_ROOT = fileURLToPath(new URL('../content/email-templates', import.meta.url)); + +describe('assertSafeDirectory', () => { + it('rejects traversal and empty names', () => { + assert.throws(() => assertSafeDirectory(''), /Invalid/); + assert.throws(() => assertSafeDirectory('..'), /Invalid/); + assert.throws(() => assertSafeDirectory('a/b'), /Invalid/); + }); +}); + +describe('createFsTemplateStore', () => { + it('lists seeded example templates', () => { + const store = createFsTemplateStore(CONTENT_ROOT); + const items = store.list(); + const keys = items.map((i) => i.key).sort(); + assert.deepEqual(keys, ['auth.password-reset', 'demo.welcome']); + }); + + it('loads demo.welcome sources', () => { + const store = createFsTemplateStore(CONTENT_ROOT); + const files = store.load('demo.welcome'); + assert.equal(files.metadata.key, 'demo.welcome'); + assert.ok(files.previewData['name']); + }); + + it('saves a round-trip into a temp directory', () => { + const root = mkdtempSync(join(tmpdir(), 'pk-admin-store-')); + after(() => rmSync(root, { recursive: true, force: true })); + + const seedDir = join(root, 'demo.welcome'); + mkdirSync(seedDir); + for (const name of ['template.json', 'metadata.json', 'preview.json'] as const) { + writeFileSync( + join(seedDir, name), + readFileSync(join(CONTENT_ROOT, 'demo.welcome', name), 'utf8'), + ); + } + + const store = createFsTemplateStore(root); + const files = store.load('demo.welcome'); + const serialized = serializeTemplateSource(files); + const result = store.save('demo.welcome', serialized, files); + assert.equal(result.ok, true); + const reloaded = store.load('demo.welcome'); + assert.equal(reloaded.metadata.key, 'demo.welcome'); + }); +}); diff --git a/examples/admin-editor/src/template-store.ts b/examples/admin-editor/src/template-store.ts new file mode 100644 index 0000000..598fcf8 --- /dev/null +++ b/examples/admin-editor/src/template-store.ts @@ -0,0 +1,138 @@ +/** + * Filesystem list/load/save for Git-backed template sources. + * + * Server-side / Node only — never import from a browser bundle. Real hosts + * expose these operations behind trusted admin APIs (then open a PR / write + * to `content/email-templates//`). + */ +import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { + loadTemplateSource, + type SerializedTemplateSource, + type TemplateSourceFiles, + type SaveResult, +} from '@singleton-sd/post-kit-editor'; + +const SOURCE_FILES = ['template.json', 'metadata.json', 'preview.json'] as const; + +export interface TemplateListItem { + /** Directory name under the templates root (usually equals metadata.key). */ + directory: string; + key: string; + name: string; +} + +export interface TemplateStore { + list(): TemplateListItem[]; + load(directory: string): TemplateSourceFiles; + save( + directory: string, + serialized: SerializedTemplateSource, + files: TemplateSourceFiles, + ): SaveResult; +} + +/** + * Create a store rooted at `templatesRoot` (absolute or relative to cwd). + * Default layout: `//{template,metadata,preview}.json`. + */ +export function createFsTemplateStore(templatesRoot: string): TemplateStore { + return { + list(): TemplateListItem[] { + if (!existsSync(templatesRoot)) { + return []; + } + const entries = readdirSync(templatesRoot, { withFileTypes: true }); + const items: TemplateListItem[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dir = entry.name; + try { + const files = loadDirectory(templatesRoot, dir); + items.push({ + directory: dir, + key: files.metadata.key, + name: files.metadata.name, + }); + } catch { + // Skip incomplete / invalid directories — real hosts should log. + } + } + return items.sort((a, b) => a.key.localeCompare(b.key)); + }, + + load(directory: string): TemplateSourceFiles { + assertSafeDirectory(directory); + return loadDirectory(templatesRoot, directory); + }, + + save(directory, serialized, files): SaveResult { + assertSafeDirectory(directory); + if (files.metadata.key.length === 0) { + return { ok: false, message: 'metadata.key is required.' }; + } + + let fromSerialized: TemplateSourceFiles; + try { + fromSerialized = loadTemplateSource({ + templateJson: JSON.parse(serialized.templateJson) as unknown, + metadata: JSON.parse(serialized.metadataJson) as unknown, + previewData: JSON.parse(serialized.previewJson) as unknown, + }); + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : 'Invalid serialized template.', + }; + } + + if (fromSerialized.metadata.key !== files.metadata.key) { + return { ok: false, message: 'Serialized key does not match files.metadata.key.' }; + } + + const dirPath = join(templatesRoot, directory); + mkdirSync(dirPath, { recursive: true }); + writeFileSync( + join(dirPath, 'template.json'), + `${serialized.templateJson.trimEnd()}\n`, + 'utf8', + ); + writeFileSync( + join(dirPath, 'metadata.json'), + `${serialized.metadataJson.trimEnd()}\n`, + 'utf8', + ); + writeFileSync(join(dirPath, 'preview.json'), `${serialized.previewJson.trimEnd()}\n`, 'utf8'); + return { ok: true }; + }, + }; +} + +function loadDirectory(root: string, directory: string): TemplateSourceFiles { + const dirPath = join(root, directory); + for (const name of SOURCE_FILES) { + if (!existsSync(join(dirPath, name))) { + throw new Error(`Missing ${name} in ${directory}`); + } + } + return loadTemplateSource({ + templateJson: JSON.parse(readFileSync(join(dirPath, 'template.json'), 'utf8')) as unknown, + metadata: JSON.parse(readFileSync(join(dirPath, 'metadata.json'), 'utf8')) as unknown, + previewData: JSON.parse(readFileSync(join(dirPath, 'preview.json'), 'utf8')) as unknown, + }); +} + +/** Reject path traversal — directory must be a single path segment. */ +export function assertSafeDirectory(directory: string): void { + if ( + !directory || + directory === '.' || + directory === '..' || + directory.includes('/') || + directory.includes('\\') || + directory.includes('\0') + ) { + throw new Error('Invalid template directory name.'); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e07eb8..435e95a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: examples/admin-editor: dependencies: + '@singleton-sd/post-kit-client': + specifier: workspace:* + version: link:../../packages/post-kit-client '@singleton-sd/post-kit-editor': specifier: workspace:* version: link:../../packages/post-kit-editor From ef51e38c9e16cd60cec59353872f442328f20968 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 16:25:51 +1000 Subject: [PATCH 2/4] fix(examples): harden admin-editor from review feedback Opt-in Send-test only when the host passes a callback; keep the memory catalog stable across prop changes; stage filesystem saves atomically; strengthen store/persistence tests and README BFF gating. Co-authored-by: Cursor --- examples/admin-editor/App.tsx | 55 +++++++---- examples/admin-editor/README.md | 17 +++- .../src/create-client-from-env.spec.ts | 13 ++- .../src/create-client-from-env.ts | 21 ++-- .../src/memory-persistence.spec.ts | 47 ++++++++- .../admin-editor/src/memory-persistence.ts | 42 ++++++++ .../admin-editor/src/template-store.spec.ts | 66 ++++++++++--- examples/admin-editor/src/template-store.ts | 95 +++++++++++++++---- 8 files changed, 293 insertions(+), 63 deletions(-) diff --git a/examples/admin-editor/App.tsx b/examples/admin-editor/App.tsx index bedc14e..a14fbbc 100644 --- a/examples/admin-editor/App.tsx +++ b/examples/admin-editor/App.tsx @@ -4,12 +4,12 @@ * - List/load: seed from the filesystem store in Node tests / server wiring; * this component takes a preloaded catalog for the embedding demo. * - Save: `onSave` → your API → Git/PR (here: in-memory adapter for the demo). - * - Send-test: `onSendTest` POSTs to **your** `/api/email-templates/send-test` - * BFF which holds `POSTKIT_API_KEY` (see `src/send-test-handler.ts`). + * - Send-test: pass `sendTest` only when your BFF is configured; omitting it + * hides Send-test chrome (see `postSendTestToBff` + README). * * Never pass API keys into this module or the editor props. */ -import { useMemo, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { EmailTemplateEditor, type SerializedTemplateSource, @@ -17,14 +17,20 @@ import { type SendTestResult, } from '@singleton-sd/post-kit-editor'; -import { createMemoryPersistence, toOnSave } from './src/memory-persistence'; +import { + createMemoryPersistence, + reconcileSelectedKey, + toOnSave, + type MemoryPersistence, +} from './src/memory-persistence'; export interface AdminEditorExampleProps { /** Catalog of templates the admin may open (from Git / your list API). */ templates: TemplateSourceFiles[]; /** - * Optional override for Send-test. Default posts to - * `/api/email-templates/send-test` with `{ templateKey, to, variables }`. + * Optional Send-test callback. Omit when the trusted BFF / env is not + * configured so the editor hides Send-test. Use {@link postSendTestToBff} + * once `POSTKIT_API_*` is available server-side. */ sendTest?: ( serialized: SerializedTemplateSource, @@ -33,7 +39,8 @@ export interface AdminEditorExampleProps { ) => Promise | SendTestResult | void; } -async function defaultSendTest( +/** Browser helper that POSTs to the consumer Send-test BFF (no secrets). */ +export async function postSendTestToBff( _serialized: SerializedTemplateSource, files: TemplateSourceFiles, recipient: string, @@ -62,26 +69,32 @@ async function defaultSendTest( return { ok: true }; } -export function AdminEditorExample({ - templates, - sendTest = defaultSendTest, -}: AdminEditorExampleProps) { +export function AdminEditorExample({ templates, sendTest }: AdminEditorExampleProps) { if (templates.length === 0) { throw new Error('AdminEditorExample requires at least one template.'); } - const initialKey = templates[0]!.metadata.key; - const [selectedKey, setSelectedKey] = useState(initialKey); - - const persistence = useMemo(() => { + const catalogKeys = templates.map((t) => t.metadata.key); + const persistenceRef = useRef(null); + if (persistenceRef.current === null) { const seed: Record = {}; for (const t of templates) { seed[t.metadata.key] = t; } - return createMemoryPersistence(seed); - }, [templates]); + persistenceRef.current = createMemoryPersistence(seed); + } else { + persistenceRef.current.syncCatalog(templates); + } + const persistence = persistenceRef.current; + + const [selectedKey, setSelectedKey] = useState(() => catalogKeys[0]!); + + useEffect(() => { + setSelectedKey((current) => reconcileSelectedKey(current, catalogKeys)); + }, [catalogKeys.join('\0')]); - const template = persistence.load(selectedKey); + const effectiveKey = reconcileSelectedKey(selectedKey, catalogKeys); + const template = persistence.load(effectiveKey); const availableVariables = template.metadata.variables.map((name) => ({ name, label: name, @@ -92,7 +105,7 @@ export function AdminEditorExample({ ); diff --git a/examples/admin-editor/README.md b/examples/admin-editor/README.md index d7057a8..40386cb 100644 --- a/examples/admin-editor/README.md +++ b/examples/admin-editor/README.md @@ -45,15 +45,26 @@ key. Draft-only Git files are not sendable until publish CI has run. ## Wire the BFF (Express-style sketch) +Gate the route (and the editor callback) on the same env boundary. When env is +incomplete, omit `sendTest` on `AdminEditorExample` so Send-test chrome stays +hidden — do not default the browser handler on. + ```ts import express from 'express'; -import { createPostKitClientFromEnv } from './create-client-from-env'; +import { + createPostKitClientFromEnv, + isSendTestEnvConfigured, +} from './create-client-from-env'; import { handleSendTest } from './send-test-handler'; const app = express(); app.use(express.json()); app.post('/api/email-templates/send-test', async (req, res) => { + if (!isSendTestEnvConfigured()) { + res.status(503).json({ error: 'Send-test is not configured.' }); + return; + } const client = createPostKitClientFromEnv(); const result = await handleSendTest(req.body, { client, @@ -63,8 +74,8 @@ app.post('/api/email-templates/send-test', async (req, res) => { }); ``` -`App.tsx` defaults `onSendTest` to `POST /api/email-templates/send-test` with -`{ templateKey, to, variables }` — no secrets in the payload. +When the BFF is configured, pass `sendTest={postSendTestToBff}` into +`AdminEditorExample` (POST `{ templateKey, to, variables }` — no secrets). ## Map to InkAds (or any) admin diff --git a/examples/admin-editor/src/create-client-from-env.spec.ts b/examples/admin-editor/src/create-client-from-env.spec.ts index 17e0e6f..e3cf1bc 100644 --- a/examples/admin-editor/src/create-client-from-env.spec.ts +++ b/examples/admin-editor/src/create-client-from-env.spec.ts @@ -1,8 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { createPostKitClientFromEnv } from './create-client-from-env'; +import { createPostKitClientFromEnv, isSendTestEnvConfigured } from './create-client-from-env'; describe('createPostKitClientFromEnv', () => { + it('reports incomplete env as not configured', () => { + assert.equal(isSendTestEnvConfigured({}), false); + assert.equal(isSendTestEnvConfigured({ POSTKIT_API_BASE_URL: 'https://example' }), false); + }); + it('throws when env is incomplete', () => { assert.throws(() => createPostKitClientFromEnv({}), /POSTKIT_API_BASE_URL/); assert.throws( @@ -12,10 +17,12 @@ describe('createPostKitClientFromEnv', () => { }); it('builds a client when both vars are set', () => { - const client = createPostKitClientFromEnv({ + const env = { POSTKIT_API_BASE_URL: 'https://postkit.example/', POSTKIT_API_KEY: 'key-for-tests-only', - }); + }; + assert.equal(isSendTestEnvConfigured(env), true); + const client = createPostKitClientFromEnv(env); assert.ok(client); }); }); diff --git a/examples/admin-editor/src/create-client-from-env.ts b/examples/admin-editor/src/create-client-from-env.ts index 76d8b71..0d51281 100644 --- a/examples/admin-editor/src/create-client-from-env.ts +++ b/examples/admin-editor/src/create-client-from-env.ts @@ -9,14 +9,21 @@ */ import { PostKitClient } from '@singleton-sd/post-kit-client'; +/** True when both Send-test env vars are non-empty (same gate as the BFF). */ +export function isSendTestEnvConfigured(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env['POSTKIT_API_BASE_URL']?.trim() && env['POSTKIT_API_KEY']?.trim()); +} + export function createPostKitClientFromEnv(env: NodeJS.ProcessEnv = process.env): PostKitClient { - const endpoint = env['POSTKIT_API_BASE_URL']?.trim(); - const apiKey = env['POSTKIT_API_KEY']?.trim(); - if (!endpoint) { - throw new Error('POSTKIT_API_BASE_URL is required for Send-test.'); - } - if (!apiKey) { + if (!isSendTestEnvConfigured(env)) { + const endpoint = env['POSTKIT_API_BASE_URL']?.trim(); + if (!endpoint) { + throw new Error('POSTKIT_API_BASE_URL is required for Send-test.'); + } throw new Error('POSTKIT_API_KEY is required for Send-test.'); } - return new PostKitClient({ endpoint, apiKey }); + return new PostKitClient({ + endpoint: env['POSTKIT_API_BASE_URL']!.trim(), + apiKey: env['POSTKIT_API_KEY']!.trim(), + }); } diff --git a/examples/admin-editor/src/memory-persistence.spec.ts b/examples/admin-editor/src/memory-persistence.spec.ts index fe7d763..91c1c86 100644 --- a/examples/admin-editor/src/memory-persistence.spec.ts +++ b/examples/admin-editor/src/memory-persistence.spec.ts @@ -7,7 +7,7 @@ import { type TemplateSourceFiles, } from '@singleton-sd/post-kit-editor'; -import { createMemoryPersistence, toOnSave } from './memory-persistence'; +import { createMemoryPersistence, reconcileSelectedKey, toOnSave } from './memory-persistence'; import templateJson from '../sample/template.json'; import metadata from '../sample/metadata.json'; @@ -19,6 +19,17 @@ const seed: TemplateSourceFiles = loadTemplateSource({ previewData, }); +const other: TemplateSourceFiles = loadTemplateSource({ + templateJson, + metadata: { + ...metadata, + key: 'auth.password-reset', + name: 'Password reset', + variables: ['resetUrl'], + }, + previewData: { resetUrl: 'https://example.com/reset' }, +}); + describe('memory persistence adapter', () => { it('load returns the seeded source for a key', () => { const persistence = createMemoryPersistence({ [seed.metadata.key]: seed }); @@ -72,4 +83,38 @@ describe('memory persistence adapter', () => { const persistence = createMemoryPersistence(); assert.throws(() => persistence.load('missing.key'), /Template not found/); }); + + it('syncCatalog preserves in-memory edits and drops removed keys', async () => { + const persistence = createMemoryPersistence({ [seed.metadata.key]: seed }); + const edited: TemplateSourceFiles = { + ...seed, + metadata: { ...seed.metadata, name: 'Local edit' }, + }; + await toOnSave(persistence)(serializeTemplateSource(edited), edited); + + persistence.syncCatalog([ + { ...seed, metadata: { ...seed.metadata, name: 'Catalog overwrite attempt' } }, + other, + ]); + + assert.equal(persistence.load('demo.welcome').metadata.name, 'Local edit'); + assert.equal(persistence.has('auth.password-reset'), true); + + persistence.syncCatalog([other]); + assert.equal(persistence.has('demo.welcome'), false); + assert.equal(persistence.has('auth.password-reset'), true); + }); +}); + +describe('reconcileSelectedKey', () => { + it('keeps the selection when still in the catalog', () => { + assert.equal(reconcileSelectedKey('demo.welcome', ['demo.welcome', 'other']), 'demo.welcome'); + }); + + it('falls back when the selected template was removed', () => { + assert.equal( + reconcileSelectedKey('demo.welcome', ['auth.password-reset']), + 'auth.password-reset', + ); + }); }); diff --git a/examples/admin-editor/src/memory-persistence.ts b/examples/admin-editor/src/memory-persistence.ts index bac0769..256d1bc 100644 --- a/examples/admin-editor/src/memory-persistence.ts +++ b/examples/admin-editor/src/memory-persistence.ts @@ -15,6 +15,15 @@ import { loadTemplateSource, serializeTemplateSource } from '@singleton-sd/post- export interface MemoryPersistence { /** Return the stored triple for `key`, or throw if missing. */ load(key: string): TemplateSourceFiles; + /** True when `key` is present in the in-memory map. */ + has(key: string): boolean; + /** Keys currently held (including unsaved edits). */ + keys(): string[]; + /** + * Merge a parent catalog into the store without clobbering in-memory edits. + * Adds missing keys; drops keys absent from the catalog. + */ + syncCatalog(templates: TemplateSourceFiles[]): void; /** * Persist from an `onSave` payload. Returns a {@link SaveResult} so the * editor can keep dirty state on failure. @@ -25,6 +34,17 @@ export interface MemoryPersistence { ): Promise | SaveResult | void; } +/** + * Keep selection on a key that still exists in the catalog. + * Returns `catalogKeys[0]` when the current selection was removed. + */ +export function reconcileSelectedKey(selectedKey: string, catalogKeys: string[]): string { + if (catalogKeys.length === 0) { + throw new Error('Catalog must contain at least one template key.'); + } + return catalogKeys.includes(selectedKey) ? selectedKey : catalogKeys[0]!; +} + export interface MemoryPersistenceOptions { /** When true, the next `save` returns `{ ok: false, message }` without writing. */ failNextSave?: boolean; @@ -50,6 +70,28 @@ export function createMemoryPersistence( return structuredClone(found); }, + has(key: string): boolean { + return store.has(key); + }, + + keys(): string[] { + return [...store.keys()]; + }, + + syncCatalog(templates: TemplateSourceFiles[]): void { + const nextKeys = new Set(templates.map((t) => t.metadata.key)); + for (const key of store.keys()) { + if (!nextKeys.has(key)) { + store.delete(key); + } + } + for (const files of templates) { + if (!store.has(files.metadata.key)) { + store.set(files.metadata.key, structuredClone(files)); + } + } + }, + save(serialized, files): SaveResult { if (failNextSave) { failNextSave = false; diff --git a/examples/admin-editor/src/template-store.spec.ts b/examples/admin-editor/src/template-store.spec.ts index 04357bf..11bbc94 100644 --- a/examples/admin-editor/src/template-store.spec.ts +++ b/examples/admin-editor/src/template-store.spec.ts @@ -9,6 +9,17 @@ import { assertSafeDirectory, createFsTemplateStore } from './template-store'; const CONTENT_ROOT = fileURLToPath(new URL('../content/email-templates', import.meta.url)); +function seedDemoWelcome(root: string): void { + const seedDir = join(root, 'demo.welcome'); + mkdirSync(seedDir); + for (const name of ['template.json', 'metadata.json', 'preview.json'] as const) { + writeFileSync( + join(seedDir, name), + readFileSync(join(CONTENT_ROOT, 'demo.welcome', name), 'utf8'), + ); + } +} + describe('assertSafeDirectory', () => { it('rejects traversal and empty names', () => { assert.throws(() => assertSafeDirectory(''), /Invalid/); @@ -32,25 +43,56 @@ describe('createFsTemplateStore', () => { assert.ok(files.previewData['name']); }); - it('saves a round-trip into a temp directory', () => { + it('saves changed content into a temp directory', () => { const root = mkdtempSync(join(tmpdir(), 'pk-admin-store-')); after(() => rmSync(root, { recursive: true, force: true })); - - const seedDir = join(root, 'demo.welcome'); - mkdirSync(seedDir); - for (const name of ['template.json', 'metadata.json', 'preview.json'] as const) { - writeFileSync( - join(seedDir, name), - readFileSync(join(CONTENT_ROOT, 'demo.welcome', name), 'utf8'), - ); - } + seedDemoWelcome(root); const store = createFsTemplateStore(root); const files = store.load('demo.welcome'); - const serialized = serializeTemplateSource(files); - const result = store.save('demo.welcome', serialized, files); + const next = { + ...files, + metadata: { ...files.metadata, name: 'Welcome (persisted)' }, + previewData: { ...files.previewData, name: 'Persisted Name' }, + }; + const result = store.save('demo.welcome', serializeTemplateSource(next), next); assert.equal(result.ok, true); const reloaded = store.load('demo.welcome'); assert.equal(reloaded.metadata.key, 'demo.welcome'); + assert.equal(reloaded.metadata.name, 'Welcome (persisted)'); + assert.equal(reloaded.previewData['name'], 'Persisted Name'); + }); + + it('leaves original files unchanged when a later staged write fails', () => { + const root = mkdtempSync(join(tmpdir(), 'pk-admin-store-fail-')); + after(() => rmSync(root, { recursive: true, force: true })); + seedDemoWelcome(root); + + const originalPreview = readFileSync(join(root, 'demo.welcome', 'preview.json'), 'utf8'); + const originalMeta = readFileSync(join(root, 'demo.welcome', 'metadata.json'), 'utf8'); + + const store = createFsTemplateStore(root, { + writeFileSync(path, data, options) { + if (String(path).endsWith('preview.json')) { + throw new Error('simulated preview write failure'); + } + return writeFileSync(path, data, options); + }, + }); + + const files = store.load('demo.welcome'); + const next = { + ...files, + metadata: { ...files.metadata, name: 'Should not land' }, + }; + const result = store.save('demo.welcome', serializeTemplateSource(next), next); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.message ?? '', /simulated preview write failure/); + } + + assert.equal(readFileSync(join(root, 'demo.welcome', 'preview.json'), 'utf8'), originalPreview); + assert.equal(readFileSync(join(root, 'demo.welcome', 'metadata.json'), 'utf8'), originalMeta); + assert.equal(store.load('demo.welcome').metadata.name, files.metadata.name); }); }); diff --git a/examples/admin-editor/src/template-store.ts b/examples/admin-editor/src/template-store.ts index 598fcf8..6c2622b 100644 --- a/examples/admin-editor/src/template-store.ts +++ b/examples/admin-editor/src/template-store.ts @@ -5,7 +5,16 @@ * expose these operations behind trusted admin APIs (then open a PR / write * to `content/email-templates//`). */ -import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { + readdirSync, + readFileSync, + writeFileSync, + mkdirSync, + existsSync, + renameSync, + rmSync, + mkdtempSync, +} from 'node:fs'; import { join } from 'node:path'; import { loadTemplateSource, @@ -33,11 +42,24 @@ export interface TemplateStore { ): SaveResult; } +export interface FsTemplateStoreOptions { + /** Injectable for tests (e.g. fail a later write). Defaults to `fs.writeFileSync`. */ + writeFileSync?: typeof writeFileSync; +} + /** * Create a store rooted at `templatesRoot` (absolute or relative to cwd). * Default layout: `//{template,metadata,preview}.json`. + * + * `save` stages all three files in a temp directory, then swaps that directory + * into place so a mid-write failure cannot leave a mixed old/new triple. */ -export function createFsTemplateStore(templatesRoot: string): TemplateStore { +export function createFsTemplateStore( + templatesRoot: string, + options: FsTemplateStoreOptions = {}, +): TemplateStore { + const writeFile = options.writeFileSync ?? writeFileSync; + return { list(): TemplateListItem[] { if (!existsSync(templatesRoot)) { @@ -48,6 +70,7 @@ export function createFsTemplateStore(templatesRoot: string): TemplateStore { for (const entry of entries) { if (!entry.isDirectory()) continue; const dir = entry.name; + if (dir.startsWith('.')) continue; try { const files = loadDirectory(templatesRoot, dir); items.push({ @@ -68,7 +91,15 @@ export function createFsTemplateStore(templatesRoot: string): TemplateStore { }, save(directory, serialized, files): SaveResult { - assertSafeDirectory(directory); + try { + assertSafeDirectory(directory); + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : 'Invalid template directory name.', + }; + } + if (files.metadata.key.length === 0) { return { ok: false, message: 'metadata.key is required.' }; } @@ -91,20 +122,52 @@ export function createFsTemplateStore(templatesRoot: string): TemplateStore { return { ok: false, message: 'Serialized key does not match files.metadata.key.' }; } + mkdirSync(templatesRoot, { recursive: true }); const dirPath = join(templatesRoot, directory); - mkdirSync(dirPath, { recursive: true }); - writeFileSync( - join(dirPath, 'template.json'), - `${serialized.templateJson.trimEnd()}\n`, - 'utf8', - ); - writeFileSync( - join(dirPath, 'metadata.json'), - `${serialized.metadataJson.trimEnd()}\n`, - 'utf8', - ); - writeFileSync(join(dirPath, 'preview.json'), `${serialized.previewJson.trimEnd()}\n`, 'utf8'); - return { ok: true }; + const stagingPath = mkdtempSync(join(templatesRoot, `.${directory}-staging-`)); + const backupPath = join(templatesRoot, `.${directory}-backup-${process.pid}-${Date.now()}`); + + try { + writeFile( + join(stagingPath, 'template.json'), + `${serialized.templateJson.trimEnd()}\n`, + 'utf8', + ); + writeFile( + join(stagingPath, 'metadata.json'), + `${serialized.metadataJson.trimEnd()}\n`, + 'utf8', + ); + writeFile( + join(stagingPath, 'preview.json'), + `${serialized.previewJson.trimEnd()}\n`, + 'utf8', + ); + + if (existsSync(dirPath)) { + renameSync(dirPath, backupPath); + } + renameSync(stagingPath, dirPath); + if (existsSync(backupPath)) { + rmSync(backupPath, { recursive: true, force: true }); + } + return { ok: true }; + } catch (err) { + rmSync(stagingPath, { recursive: true, force: true }); + if (existsSync(backupPath) && !existsSync(dirPath)) { + try { + renameSync(backupPath, dirPath); + } catch { + // leave backup in place for manual recovery + } + } else if (existsSync(backupPath)) { + rmSync(backupPath, { recursive: true, force: true }); + } + return { + ok: false, + message: err instanceof Error ? err.message : 'Failed to save template files.', + }; + } }, }; } From 0536f9e1677a796bac223fc49df5a2db0c6015f0 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 16:33:45 +1000 Subject: [PATCH 3/4] fix(examples): reject dot dirs and catch staging setup errors Align list/save on non-dot directory names, and return { ok: false } when mkdir/mkdtemp fails during atomic save staging. Co-authored-by: Cursor --- .../admin-editor/src/template-store.spec.ts | 50 +++++++++++++++++++ examples/admin-editor/src/template-store.ts | 26 ++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/examples/admin-editor/src/template-store.spec.ts b/examples/admin-editor/src/template-store.spec.ts index 11bbc94..d4f5cba 100644 --- a/examples/admin-editor/src/template-store.spec.ts +++ b/examples/admin-editor/src/template-store.spec.ts @@ -26,6 +26,11 @@ describe('assertSafeDirectory', () => { assert.throws(() => assertSafeDirectory('..'), /Invalid/); assert.throws(() => assertSafeDirectory('a/b'), /Invalid/); }); + + it('rejects dot-prefixed directory names', () => { + assert.throws(() => assertSafeDirectory('.draft'), /Invalid/); + assert.throws(() => assertSafeDirectory('.demo.welcome-staging-xyz'), /Invalid/); + }); }); describe('createFsTemplateStore', () => { @@ -95,4 +100,49 @@ describe('createFsTemplateStore', () => { assert.equal(readFileSync(join(root, 'demo.welcome', 'metadata.json'), 'utf8'), originalMeta); assert.equal(store.load('demo.welcome').metadata.name, files.metadata.name); }); + + it('rejects saving under a dot-prefixed directory name', () => { + const root = mkdtempSync(join(tmpdir(), 'pk-admin-store-dot-')); + after(() => rmSync(root, { recursive: true, force: true })); + seedDemoWelcome(root); + + const store = createFsTemplateStore(root); + const files = store.load('demo.welcome'); + const next = { + ...files, + metadata: { ...files.metadata, key: '.draft', name: 'Hidden draft' }, + }; + const result = store.save('.draft', serializeTemplateSource(next), next); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.message ?? '', /Invalid/); + } + assert.deepEqual( + store.list().map((i) => i.directory), + ['demo.welcome'], + ); + }); + + it('returns ok:false when staging directory creation fails', () => { + const root = mkdtempSync(join(tmpdir(), 'pk-admin-store-stage-')); + after(() => rmSync(root, { recursive: true, force: true })); + seedDemoWelcome(root); + + const store = createFsTemplateStore(root, { + mkdtempSync() { + throw new Error('simulated mkdtemp failure'); + }, + }); + const files = store.load('demo.welcome'); + const next = { + ...files, + metadata: { ...files.metadata, name: 'Should not land' }, + }; + const result = store.save('demo.welcome', serializeTemplateSource(next), next); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.message ?? '', /simulated mkdtemp failure/); + } + assert.equal(store.load('demo.welcome').metadata.name, files.metadata.name); + }); }); diff --git a/examples/admin-editor/src/template-store.ts b/examples/admin-editor/src/template-store.ts index 6c2622b..960cbce 100644 --- a/examples/admin-editor/src/template-store.ts +++ b/examples/admin-editor/src/template-store.ts @@ -45,6 +45,10 @@ export interface TemplateStore { export interface FsTemplateStoreOptions { /** Injectable for tests (e.g. fail a later write). Defaults to `fs.writeFileSync`. */ writeFileSync?: typeof writeFileSync; + /** Injectable for tests. Defaults to `fs.mkdirSync`. */ + mkdirSync?: typeof mkdirSync; + /** Injectable for tests. Defaults to `fs.mkdtempSync`. */ + mkdtempSync?: typeof mkdtempSync; } /** @@ -59,6 +63,8 @@ export function createFsTemplateStore( options: FsTemplateStoreOptions = {}, ): TemplateStore { const writeFile = options.writeFileSync ?? writeFileSync; + const mkdir = options.mkdirSync ?? mkdirSync; + const mkdtemp = options.mkdtempSync ?? mkdtempSync; return { list(): TemplateListItem[] { @@ -70,6 +76,8 @@ export function createFsTemplateStore( for (const entry of entries) { if (!entry.isDirectory()) continue; const dir = entry.name; + // Skip store-owned staging/backup dirs and any other dot-prefixed names + // (assertSafeDirectory also rejects them for save/load). if (dir.startsWith('.')) continue; try { const files = loadDirectory(templatesRoot, dir); @@ -122,12 +130,14 @@ export function createFsTemplateStore( return { ok: false, message: 'Serialized key does not match files.metadata.key.' }; } - mkdirSync(templatesRoot, { recursive: true }); const dirPath = join(templatesRoot, directory); - const stagingPath = mkdtempSync(join(templatesRoot, `.${directory}-staging-`)); const backupPath = join(templatesRoot, `.${directory}-backup-${process.pid}-${Date.now()}`); + let stagingPath: string | undefined; try { + mkdir(templatesRoot, { recursive: true }); + stagingPath = mkdtemp(join(templatesRoot, `.${directory}-staging-`)); + writeFile( join(stagingPath, 'template.json'), `${serialized.templateJson.trimEnd()}\n`, @@ -148,12 +158,15 @@ export function createFsTemplateStore( renameSync(dirPath, backupPath); } renameSync(stagingPath, dirPath); + stagingPath = undefined; if (existsSync(backupPath)) { rmSync(backupPath, { recursive: true, force: true }); } return { ok: true }; } catch (err) { - rmSync(stagingPath, { recursive: true, force: true }); + if (stagingPath !== undefined) { + rmSync(stagingPath, { recursive: true, force: true }); + } if (existsSync(backupPath) && !existsSync(dirPath)) { try { renameSync(backupPath, dirPath); @@ -186,12 +199,17 @@ function loadDirectory(root: string, directory: string): TemplateSourceFiles { }); } -/** Reject path traversal — directory must be a single path segment. */ +/** + * Reject path traversal and store-reserved names. + * Directory must be a single non-empty path segment that does not start with `.` + * (staging/backup dirs are dot-prefixed). + */ export function assertSafeDirectory(directory: string): void { if ( !directory || directory === '.' || directory === '..' || + directory.startsWith('.') || directory.includes('/') || directory.includes('\\') || directory.includes('\0') From 280fe9f697777d451aecdc80ccafc93759b69a94 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 11 Sep 2026 16:42:45 +1000 Subject: [PATCH 4/4] fix(examples): keep save error when staging cleanup fails Wrap rmSync cleanup in its own try/catch so a secondary cleanup failure cannot replace the original SaveResult message. Co-authored-by: Cursor --- .../admin-editor/src/template-store.spec.ts | 29 +++++++++++++++++++ examples/admin-editor/src/template-store.ts | 29 ++++++++++++------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/examples/admin-editor/src/template-store.spec.ts b/examples/admin-editor/src/template-store.spec.ts index d4f5cba..deaef81 100644 --- a/examples/admin-editor/src/template-store.spec.ts +++ b/examples/admin-editor/src/template-store.spec.ts @@ -145,4 +145,33 @@ describe('createFsTemplateStore', () => { } assert.equal(store.load('demo.welcome').metadata.name, files.metadata.name); }); + + it('preserves the original save error when cleanup also fails', () => { + const root = mkdtempSync(join(tmpdir(), 'pk-admin-store-cleanup-')); + after(() => rmSync(root, { recursive: true, force: true })); + seedDemoWelcome(root); + + const store = createFsTemplateStore(root, { + writeFileSync(path, data, options) { + if (String(path).endsWith('preview.json')) { + throw new Error('simulated preview write failure'); + } + return writeFileSync(path, data, options); + }, + rmSync() { + throw new Error('simulated cleanup failure'); + }, + }); + const files = store.load('demo.welcome'); + const next = { + ...files, + metadata: { ...files.metadata, name: 'Should not land' }, + }; + const result = store.save('demo.welcome', serializeTemplateSource(next), next); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.message ?? '', /simulated preview write failure/); + assert.doesNotMatch(result.message ?? '', /cleanup failure/); + } + }); }); diff --git a/examples/admin-editor/src/template-store.ts b/examples/admin-editor/src/template-store.ts index 960cbce..12769ce 100644 --- a/examples/admin-editor/src/template-store.ts +++ b/examples/admin-editor/src/template-store.ts @@ -49,6 +49,8 @@ export interface FsTemplateStoreOptions { mkdirSync?: typeof mkdirSync; /** Injectable for tests. Defaults to `fs.mkdtempSync`. */ mkdtempSync?: typeof mkdtempSync; + /** Injectable for tests. Defaults to `fs.rmSync`. */ + rmSync?: typeof rmSync; } /** @@ -65,6 +67,7 @@ export function createFsTemplateStore( const writeFile = options.writeFileSync ?? writeFileSync; const mkdir = options.mkdirSync ?? mkdirSync; const mkdtemp = options.mkdtempSync ?? mkdtempSync; + const remove = options.rmSync ?? rmSync; return { list(): TemplateListItem[] { @@ -160,21 +163,25 @@ export function createFsTemplateStore( renameSync(stagingPath, dirPath); stagingPath = undefined; if (existsSync(backupPath)) { - rmSync(backupPath, { recursive: true, force: true }); + remove(backupPath, { recursive: true, force: true }); } return { ok: true }; } catch (err) { - if (stagingPath !== undefined) { - rmSync(stagingPath, { recursive: true, force: true }); - } - if (existsSync(backupPath) && !existsSync(dirPath)) { - try { - renameSync(backupPath, dirPath); - } catch { - // leave backup in place for manual recovery + try { + if (stagingPath !== undefined) { + remove(stagingPath, { recursive: true, force: true }); } - } else if (existsSync(backupPath)) { - rmSync(backupPath, { recursive: true, force: true }); + if (existsSync(backupPath) && !existsSync(dirPath)) { + try { + renameSync(backupPath, dirPath); + } catch { + // leave backup in place for manual recovery + } + } else if (existsSync(backupPath)) { + remove(backupPath, { recursive: true, force: true }); + } + } catch { + // Cleanup must not replace the original save failure. } return { ok: false,