diff --git a/apps/web/src/__tests__/templates-api.test.ts b/apps/web/src/__tests__/templates-api.test.ts new file mode 100644 index 00000000..fb5a23ff --- /dev/null +++ b/apps/web/src/__tests__/templates-api.test.ts @@ -0,0 +1,493 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +/** + * Template routes: persistence and preview fidelity (outpost#226). + * + * Before this suite, all three write paths were stubs. `PUT` validated its input + * and returned a success body without touching the database, while the UI reported + * "Template saved successfully" — so an author's edit was silently discarded. `GET` + * read only the filesystem, so even a real write would not have been visible. And + * preview rendered the STORED template rather than the unsaved draft, which is what + * made the XSS sink on that screen matter: the preview is the surface an author + * trusts to show them what they wrote. + * + * The loader and renderer are used for real here (repo-root `templates/` resolves + * from `apps/web`), so these assertions are about rendered output rather than about + * a mock of the thing under test. Only the session and the database are mocked. + */ + +const mockGetServerSession = vi.fn(); + +// require-admin imports from 'next-auth'; the list and preview routes import from +// 'next-auth/next'. Both need mocking or half the routes see no session. +// Wrapped rather than passed directly: the route modules are imported by the hoisted +// import statements below, which run before this file's consts initialise, so the +// factory must not read the variable until call time. +vi.mock('next-auth', () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); +vi.mock('next-auth/next', () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +const mockOverrideFindUnique = vi.fn(); +const mockOverrideFindMany = vi.fn(); +const mockOverrideUpsert = vi.fn(); +const mockOverrideDeleteMany = vi.fn(); + +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: { + templateOverride: { + findUnique: (...args: unknown[]) => mockOverrideFindUnique(...args), + findMany: (...args: unknown[]) => mockOverrideFindMany(...args), + upsert: (...args: unknown[]) => mockOverrideUpsert(...args), + deleteMany: (...args: unknown[]) => mockOverrideDeleteMany(...args), + }, + }, +})); + +import { + GET as getTemplate, + PUT as putTemplate, + DELETE as deleteTemplate, +} from '@/app/api/templates/[slug]/route'; +import { GET as listTemplates } from '@/app/api/templates/route'; +import { POST as previewTemplate } from '@/app/api/templates/[slug]/preview/route'; + +const ADMIN_SESSION = { user: { id: 'u-admin', email: 'admin@copilotkit.ai', role: 'ADMIN' } }; +const MEMBER_SESSION = { user: { id: 'u-member', email: 'member@copilotkit.ai', role: 'MEMBER' } }; + +/** A slug that exists in the repo-root templates/ directory. */ +const REAL_SLUG = 'welcome'; + +// Both `text` and `json` are stubbed because the two routes read the body +// differently: PUT uses `json()`, and preview reads `text()` once and parses it +// itself — a request body can only be read once, so calling `json()` first would +// consume the stream and leave an unparseable body looking empty. +function jsonRequest(body: unknown): NextRequest { + return { + text: async () => JSON.stringify(body), + json: async () => body, + } as unknown as NextRequest; +} + +/** A request with no body at all, which preview treats as "render what is stored". */ +function bareRequest(): NextRequest { + return { + text: async () => '', + json: async () => ({}), + } as unknown as NextRequest; +} + +function routeParams(slug: string) { + return { params: Promise.resolve({ slug }) }; +} + +describe('template persistence and preview (outpost#226)', () => { + beforeEach(() => { + mockGetServerSession.mockReset(); + mockGetServerSession.mockResolvedValue(ADMIN_SESSION); + mockOverrideFindUnique.mockReset(); + mockOverrideFindUnique.mockResolvedValue(null); + mockOverrideFindMany.mockReset(); + mockOverrideFindMany.mockResolvedValue([]); + mockOverrideUpsert.mockReset(); + // Returns a realistic row, including the non-nullable updatedAt. A mock that + // omitted it previously made a dead `?? new Date()` fallback in the route look + // exercised — a test artifact shaping production code. + mockOverrideUpsert.mockImplementation( + async (args: { create: Record }) => ({ + id: 'ovr-1', + editedBy: null, + createdAt: new Date('2026-08-20T00:00:00Z'), + updatedAt: new Date('2026-08-20T00:00:00Z'), + ...args.create, + }), + ); + mockOverrideDeleteMany.mockReset(); + mockOverrideDeleteMany.mockResolvedValue({ count: 1 }); + }); + + describe('PUT actually persists', () => { + it('writes the override to the database', async () => { + const res = await putTemplate( + jsonRequest({ subject: 'Saved subject', body: 'Saved body' }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(200); + expect(mockOverrideUpsert).toHaveBeenCalledTimes(1); + + const args = mockOverrideUpsert.mock.calls[0][0]; + expect(args.where).toEqual({ slug: REAL_SLUG }); + expect(args.create).toMatchObject({ + slug: REAL_SLUG, + subject: 'Saved subject', + body: 'Saved body', + }); + expect(args.update).toMatchObject({ subject: 'Saved subject', body: 'Saved body' }); + }); + + it('records who edited it', async () => { + await putTemplate(jsonRequest({ subject: 's', body: 'b' }), routeParams(REAL_SLUG)); + + const args = mockOverrideUpsert.mock.calls[0][0]; + expect(args.create.editedBy).toBe('admin@copilotkit.ai'); + expect(args.update.editedBy).toBe('admin@copilotkit.ai'); + }); + + it('refuses a slug that has no template, rather than creating a phantom override', async () => { + const res = await putTemplate( + jsonRequest({ subject: 's', body: 'b' }), + routeParams('no-such-template'), + ); + + expect(res.status).toBe(404); + expect(mockOverrideUpsert).not.toHaveBeenCalled(); + }); + + it('still rejects a non-admin', async () => { + mockGetServerSession.mockResolvedValue(MEMBER_SESSION); + + const res = await putTemplate( + jsonRequest({ subject: 's', body: 'b' }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(403); + expect(mockOverrideUpsert).not.toHaveBeenCalled(); + }); + }); + + describe('the write paths stay gated', () => { + // These were stubs before this change and are now real, so the gate on each is + // the assertion most worth pinning: swapping requireAdmin for requireSession + // would otherwise let any member wipe every override with nothing failing. + it('refuses DELETE for a non-admin', async () => { + mockGetServerSession.mockResolvedValue(MEMBER_SESSION); + + const res = await deleteTemplate(bareRequest(), routeParams(REAL_SLUG)); + + expect(res.status).toBe(403); + expect(mockOverrideDeleteMany).not.toHaveBeenCalled(); + }); + + it('refuses preview for an unauthenticated request', async () => { + mockGetServerSession.mockResolvedValue(null); + + const res = await previewTemplate(bareRequest(), routeParams(REAL_SLUG)); + + expect(res.status).toBe(401); + }); + + it('refuses the list for an unauthenticated request', async () => { + mockGetServerSession.mockResolvedValue(null); + + const res = await listTemplates(bareRequest()); + + expect(res.status).toBe(401); + }); + }); + + describe('PUT validates types and bounds, not just truthiness', () => { + it('rejects non-string fields with a 400 rather than failing in the database', async () => { + const res = await putTemplate( + jsonRequest({ subject: {}, body: [1] }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(400); + expect(mockOverrideUpsert).not.toHaveBeenCalled(); + }); + + it('rejects whitespace-only content', async () => { + const res = await putTemplate( + jsonRequest({ subject: ' ', body: ' \n ' }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(400); + expect(mockOverrideUpsert).not.toHaveBeenCalled(); + }); + + it('rejects an oversized body', async () => { + const res = await putTemplate( + jsonRequest({ subject: 's', body: 'x'.repeat(100_001) }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(400); + expect(mockOverrideUpsert).not.toHaveBeenCalled(); + }); + + it('rejects a body that is not valid JSON', async () => { + const badRequest = { + json: async () => { + throw new SyntaxError('bad'); + }, + } as unknown as NextRequest; + + const res = await putTemplate(badRequest, routeParams(REAL_SLUG)); + + expect(res.status).toBe(400); + }); + }); + + describe('GET reads back what was saved', () => { + it('returns the stored override instead of the filesystem default', async () => { + mockOverrideFindUnique.mockResolvedValue({ + slug: REAL_SLUG, + subject: 'Overridden subject', + body: 'Overridden body', + editedBy: 'admin@copilotkit.ai', + updatedAt: new Date('2026-08-20T00:00:00Z'), + }); + + const res = await getTemplate(bareRequest(), routeParams(REAL_SLUG)); + const data = await res.json(); + + expect(data.subject).toBe('Overridden subject'); + expect(data.body).toBe('Overridden body'); + expect(data.isOverride).toBe(true); + }); + + it('falls back to the filesystem default when there is no override', async () => { + const res = await getTemplate(bareRequest(), routeParams(REAL_SLUG)); + const data = await res.json(); + + expect(data.isOverride).toBe(false); + expect(data.body.length).toBeGreaterThan(0); + expect(data.body).not.toBe('Overridden body'); + }); + }); + + // The slug reached `join(dir, slug + '.md')` unvalidated, and Next decodes + // percent-encoding in a dynamic segment before a handler runs — so + // `../docs/deployment` arrived as a traversal, the loader read the file, and GET + // returned its `subject` and `body` in the JSON response. Confirmed against the + // real loader: `../README`, `../CLAUDE`, `../docs/deployment` and + // `invite/../../README` all returned file contents. + // + // The existence checks did not close it — they WERE it. A guard shaped like + // `if (!loadFromFilesystem(slug))` succeeds for every path above, so it approved + // the request it appeared to reject. + describe('a slug outside the template set is refused everywhere', () => { + const TRAVERSALS = ['../README', '../CLAUDE', '../docs/deployment', 'invite/../../README']; + + it.each(TRAVERSALS)('GET refuses %s rather than returning the file', async (slug) => { + const res = await getTemplate(bareRequest(), routeParams(slug)); + + expect(res.status).toBe(404); + const data = await res.json(); + // The proof is the absence of file content, not just the status: a 200 + // carrying the deployment guide is the failure being pinned. + expect(data.body).toBeUndefined(); + expect(data.subject).toBeUndefined(); + }); + + it.each(TRAVERSALS)('PUT refuses %s', async (slug) => { + const res = await putTemplate( + jsonRequest({ subject: 'x', body: 'y' }), + routeParams(slug), + ); + + expect(res.status).toBe(404); + expect(mockOverrideUpsert).not.toHaveBeenCalled(); + }); + + it.each(TRAVERSALS)('preview refuses %s', async (slug) => { + const res = await previewTemplate(bareRequest(), routeParams(slug)); + + expect(res.status).toBe(404); + }); + + // Harmless on its own — deleteMany matches nothing — but all four handlers + // should answer the same way for the same input. + it.each(TRAVERSALS)('DELETE refuses %s', async (slug) => { + const res = await deleteTemplate(bareRequest(), routeParams(slug)); + + expect(res.status).toBe(404); + expect(mockOverrideDeleteMany).not.toHaveBeenCalled(); + }); + + it('still serves the real slugs', async () => { + const res = await getTemplate(bareRequest(), routeParams(REAL_SLUG)); + + expect(res.status).toBe(200); + expect((await res.json()).body.length).toBeGreaterThan(0); + }); + }); + + describe('DELETE actually removes the override', () => { + it('deletes the stored row', async () => { + const res = await deleteTemplate(bareRequest(), routeParams(REAL_SLUG)); + + expect(res.status).toBe(200); + expect(mockOverrideDeleteMany).toHaveBeenCalledWith({ where: { slug: REAL_SLUG } }); + }); + }); + + describe('the list reflects override state', () => { + it('marks an overridden template and carries its edit metadata', async () => { + mockOverrideFindMany.mockResolvedValue([ + { + slug: REAL_SLUG, + subject: 'Overridden subject', + body: 'b', + editedBy: 'admin@copilotkit.ai', + updatedAt: new Date('2026-08-20T00:00:00Z'), + }, + ]); + + const res = await listTemplates(bareRequest()); + const entries = await res.json(); + const entry = entries.find((e: { slug: string }) => e.slug === REAL_SLUG); + + expect(entry.isOverride).toBe(true); + expect(entry.subject).toBe('Overridden subject'); + expect(entry.editedBy).toBe('admin@copilotkit.ai'); + expect(entry.updatedAt).not.toBeNull(); + }); + + it('leaves a non-overridden template marked as default', async () => { + const res = await listTemplates(bareRequest()); + const entries = await res.json(); + const entry = entries.find((e: { slug: string }) => e.slug === REAL_SLUG); + + expect(entry.isOverride).toBe(false); + expect(entry.editedBy).toBeNull(); + }); + }); + + describe('a draft that cannot be honoured is refused, not silently swapped', () => { + // Falling back to the stored template on a bad draft would re-introduce the + // exact bug this route is being fixed for: the author sees content they are + // not editing, and gets a 200 saying all is well. + it('rejects a draft missing its subject rather than rendering the stored template', async () => { + mockOverrideFindUnique.mockResolvedValue({ + slug: REAL_SLUG, + subject: 'Stored subject', + body: 'STORED-BODY-MARKER', + editedBy: null, + updatedAt: new Date(), + }); + + const res = await previewTemplate( + jsonRequest({ draft: { body: 'only a body' } }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.html).toBeUndefined(); + }); + + it('rejects a draft whose fields are the wrong type', async () => { + const res = await previewTemplate( + jsonRequest({ draft: { subject: { evil: true }, body: ['x'] } }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(400); + }); + + it('rejects a body that is not valid JSON', async () => { + const badRequest = { + // Unparseable text rather than a throwing `json()`: the route parses + // the text itself, so that is the shape a real bad body arrives in. + text: async () => '{ not json', + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + headers: { get: () => null }, + } as unknown as NextRequest; + + const res = await previewTemplate(badRequest, routeParams(REAL_SLUG)); + + expect(res.status).toBe(400); + }); + + it('still allows an intentionally empty body, which is a valid draft', async () => { + const res = await previewTemplate( + jsonRequest({ draft: { subject: 'Subject only', body: '' } }), + routeParams(REAL_SLUG), + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.subject).toBe('Subject only'); + }); + + it('refuses a slug with no template even when a draft is supplied', async () => { + const res = await previewTemplate( + jsonRequest({ draft: { subject: 's', body: 'b' } }), + routeParams('no-such-template'), + ); + + expect(res.status).toBe(404); + }); + }); + + describe('preview renders the draft, not the stored template', () => { + it('renders unsaved body content sent with the request', async () => { + const res = await previewTemplate( + jsonRequest({ draft: { subject: 'Draft subject', body: 'UNSAVED-BODY-MARKER' } }), + routeParams(REAL_SLUG), + ); + const data = await res.json(); + + expect(data.html).toContain('UNSAVED-BODY-MARKER'); + expect(data.subject).toBe('Draft subject'); + }); + + it('does not fall back to the stored override when a draft is supplied', async () => { + mockOverrideFindUnique.mockResolvedValue({ + slug: REAL_SLUG, + subject: 'Stored subject', + body: 'STORED-BODY-MARKER', + editedBy: null, + updatedAt: new Date(), + }); + + const res = await previewTemplate( + jsonRequest({ draft: { subject: 'Draft subject', body: 'UNSAVED-BODY-MARKER' } }), + routeParams(REAL_SLUG), + ); + const data = await res.json(); + + expect(data.html).toContain('UNSAVED-BODY-MARKER'); + expect(data.html).not.toContain('STORED-BODY-MARKER'); + }); + + it('still renders the stored template when no draft is supplied', async () => { + mockOverrideFindUnique.mockResolvedValue({ + slug: REAL_SLUG, + subject: 'Stored subject', + body: 'STORED-BODY-MARKER', + editedBy: null, + updatedAt: new Date(), + }); + + const res = await previewTemplate(bareRequest(), routeParams(REAL_SLUG)); + const data = await res.json(); + + expect(data.html).toContain('STORED-BODY-MARKER'); + expect(data.subject).toBe('Stored subject'); + }); + + it('interpolates variables in the draft rather than emitting them raw', async () => { + const res = await previewTemplate( + jsonRequest({ + draft: { subject: 'Hi {{member.name}}', body: 'Hello {{member.name}}' }, + }), + routeParams(REAL_SLUG), + ); + const data = await res.json(); + + expect(data.subject).toBe('Hi Jane Smith'); + expect(data.html).toContain('Jane Smith'); + expect(data.html).not.toContain('{{member.name}}'); + }); + }); +}); diff --git a/apps/web/src/__tests__/templates-page.test.tsx b/apps/web/src/__tests__/templates-page.test.tsx new file mode 100644 index 00000000..bab8d3fe --- /dev/null +++ b/apps/web/src/__tests__/templates-page.test.tsx @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import TemplatesPage from '@/app/settings/templates/page'; + +/** + * Template editor: preview containment and draft fidelity (outpost#226). + * + * The preview rendered author-authored HTML through `dangerouslySetInnerHTML` with no + * sanitisation. Templates are dashboard-editable, so that is a STORED sink — whatever + * an author saved executed in every later viewer's session. It also mattered more than + * a typical XSS here, because the csrf cookie is `httpOnly: false` by necessity (the + * client reads it to build the `X-CSRF-Token` header), so script on this page could + * read the CSRF token directly. + * + * These tests assert containment structurally — the preview must render inside a + * sandboxed iframe that permits neither scripts nor same-origin access — rather than + * asserting that some particular payload string was escaped, which an allowlist could + * pass while still admitting the next payload. + */ + +const mockApiFetch = vi.fn(); +vi.mock('@/lib/api-fetch', () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), +})); + +const TEMPLATE_LIST = [ + { + slug: 'welcome', + name: 'Welcome', + subject: 'Hi', + isOverride: false, + updatedAt: null, + editedBy: null, + }, +]; + +const TEMPLATE_DETAIL = { + slug: 'welcome', + name: 'Welcome', + subject: 'Hi {{member.name}}', + from: 'support@acme.com', + body: 'Stored body', + isOverride: false, +}; + +/** A rendered preview whose content would execute if it were injected into this document. */ +const MALICIOUS_PREVIEW = { + subject: 'Pwned subject', + html: '

hello

', + text: 'hello', + markdown: 'hello', +}; + +function jsonOk(data: unknown) { + return { ok: true, json: async () => data }; +} + +/** Route apiFetch by URL and method so the component drives a realistic sequence. */ +function routeApiFetch(previewPayload: unknown = MALICIOUS_PREVIEW) { + mockApiFetch.mockImplementation(async (url: string, init?: { method?: string }) => { + if (url === '/api/templates') return jsonOk(TEMPLATE_LIST); + if (url === '/api/templates/welcome' && (!init?.method || init.method === 'GET')) { + return jsonOk(TEMPLATE_DETAIL); + } + if (url.endsWith('/preview')) return jsonOk(previewPayload); + return jsonOk({ ok: true }); + }); +} + +/** Select the one template and wait for the editor to appear. */ +async function openTemplate() { + render(); + const row = await screen.findByText('Welcome'); + fireEvent.click(row); + await screen.findByDisplayValue('Stored body'); +} + +async function openPreview() { + fireEvent.click(screen.getByRole('button', { name: /preview/i })); + await waitFor(() => expect(screen.getByTitle(/preview/i)).toBeTruthy()); +} + +describe('template preview containment (outpost#226)', () => { + beforeEach(() => { + mockApiFetch.mockReset(); + routeApiFetch(); + delete (window as unknown as Record).__xssFired; + }); + + it('renders the preview inside an iframe rather than injecting it into the page', async () => { + await openTemplate(); + await openPreview(); + + const frame = screen.getByTitle(/preview/i) as HTMLIFrameElement; + expect(frame.tagName).toBe('IFRAME'); + expect(frame.getAttribute('srcdoc')).toContain('

hello

'); + }); + + it('sandboxes the iframe so neither scripts nor same-origin access are permitted', async () => { + await openTemplate(); + await openPreview(); + + const frame = screen.getByTitle(/preview/i) as HTMLIFrameElement; + // The attribute must be present. An absent sandbox attribute is full privileges. + expect(frame.hasAttribute('sandbox')).toBe(true); + + // Asserted as an exact value, not by excluding two tokens: `sandbox= + // "allow-top-navigation allow-forms"` passes a not-contains check while letting a + // saved template's redirect the admin's whole tab. The source + // comment promises "grants nothing", so the test should hold it to exactly that. + expect(frame.getAttribute('sandbox')).toBe(''); + }); + + it('does not place the preview markup in the parent document', async () => { + await openTemplate(); + await openPreview(); + + // If the html were injected via dangerouslySetInnerHTML, these would exist in + // this document. Inside an iframe's srcdoc they are inert text. + expect(document.querySelector('script[data-xss]')).toBeNull(); + expect(document.querySelector('img[data-xss-img]')).toBeNull(); + expect((window as unknown as Record).__xssFired).toBeUndefined(); + }); +}); + +describe('the editor reports what the server actually said (outpost#226)', () => { + beforeEach(() => { + mockApiFetch.mockReset(); + }); + + /** Route reads normally, but fail the named mutation with `status` and `payload`. */ + function failMutation(match: string, status: number, payload: unknown) { + mockApiFetch.mockImplementation(async (url: string, init?: { method?: string }) => { + if (url === '/api/templates') return jsonOk(TEMPLATE_LIST); + if (url === '/api/templates/welcome' && (!init?.method || init.method === 'GET')) { + return jsonOk(TEMPLATE_DETAIL); + } + if (init?.method === match) { + return { + ok: false, + status, + json: async () => { + if (payload === undefined) throw new SyntaxError('no body'); + return payload; + }, + }; + } + return jsonOk({ ok: true }); + }); + } + + it('surfaces a validation message instead of a generic failure', async () => { + failMutation('PUT', 400, { error: 'subject and body are required' }); + await openTemplate(); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + // The reachable case: an author clears a field. "Save failed" would send them + // hunting for an outage instead of showing them the empty field. + expect(await screen.findByText('subject and body are required')).toBeTruthy(); + }); + + it('surfaces a permission message rather than looking like a bug', async () => { + failMutation('PUT', 403, { error: 'Forbidden: admin access required' }); + await openTemplate(); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + expect(await screen.findByText('Forbidden: admin access required')).toBeTruthy(); + }); + + it('distinguishes a server error from a rejected input by showing the status', async () => { + // A genuine 500 has no JSON body, so the status code is the only thing that + // separates "your input was rejected" from "the server broke". + failMutation('PUT', 500, undefined); + await openTemplate(); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + expect(await screen.findByText(/HTTP 500/)).toBeTruthy(); + }); +}); + +// A save writes the override and every read surface honours it, but outgoing email +// does not — `sendEmail` consults an override only when handed a `dbLookup`, and +// neither invite call site passes one, so the invitee gets the on-disk copy. +// "Template saved successfully" was true about the row and false about the thing +// the author cared about. These pin the honest copy, because the dishonest version +// is the shorter and more natural string to write. +describe('the editor does not claim more than a save delivers (outpost#226)', () => { + beforeEach(() => { + mockApiFetch.mockReset(); + }); + + it('says the save is not yet used for outgoing email', async () => { + mockApiFetch.mockImplementation(async (url: string, init?: { method?: string }) => { + if (url === '/api/templates') return jsonOk(TEMPLATE_LIST); + if (url === '/api/templates/welcome' && (!init?.method || init.method === 'GET')) { + return jsonOk(TEMPLATE_DETAIL); + } + return jsonOk({ ok: true }); + }); + await openTemplate(); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + const banner = await screen.findByText(/not yet used for outgoing email/i); + expect(banner).toBeTruthy(); + // The bare claim must not be what the author reads. + expect(screen.queryByText('Template saved successfully')).toBeNull(); + }); + + it('marks an overridden template as preview-only in the list', async () => { + // The shared fixture is un-overridden, so this test supplies its own — the + // badge only appears for a template that actually has a stored override. + const overridden = [{ ...TEMPLATE_LIST[0], isOverride: true }]; + mockApiFetch.mockImplementation(async (url: string) => { + if (url === '/api/templates') return jsonOk(overridden); + return jsonOk(TEMPLATE_DETAIL); + }); + render(); + + expect(await screen.findByText(/custom \(preview only\)/i)).toBeTruthy(); + }); +}); + +describe('reset gives the author feedback (outpost#226)', () => { + beforeEach(() => { + mockApiFetch.mockReset(); + }); + + function resetReturns(payload: unknown) { + mockApiFetch.mockImplementation(async (url: string, init?: { method?: string }) => { + if (url === '/api/templates') return jsonOk(TEMPLATE_LIST); + if (url === '/api/templates/welcome' && (!init?.method || init.method === 'GET')) { + return jsonOk(TEMPLATE_DETAIL); + } + if (init?.method === 'DELETE') return jsonOk(payload); + return jsonOk({ ok: true }); + }); + } + + it('confirms the reset after the editor reloads', async () => { + // DELETE now really deletes, so a reset with no confirmation is a destructive + // action with no feedback at all. + resetReturns({ slug: 'welcome', reset: true, hadOverride: true }); + await openTemplate(); + + fireEvent.click(screen.getByRole('button', { name: /reset/i })); + + expect(await screen.findByText(/reset to default/i)).toBeTruthy(); + }); + + it('says so when the template was already using the default', async () => { + resetReturns({ slug: 'welcome', reset: true, hadOverride: false }); + await openTemplate(); + + fireEvent.click(screen.getByRole('button', { name: /reset/i })); + + expect(await screen.findByText(/already using the default/i)).toBeTruthy(); + }); +}); + +describe('preview shows the unsaved draft (outpost#226)', () => { + beforeEach(() => { + mockApiFetch.mockReset(); + routeApiFetch(); + }); + + it('sends the current editor content, not an empty body', async () => { + await openTemplate(); + + const bodyField = screen.getByDisplayValue('Stored body'); + fireEvent.change(bodyField, { target: { value: 'Edited but unsaved' } }); + + fireEvent.click(screen.getByRole('button', { name: /preview/i })); + + await waitFor(() => { + const call = mockApiFetch.mock.calls.find(([url]) => String(url).endsWith('/preview')); + expect(call).toBeTruthy(); + const sent = JSON.parse(call![1].body); + expect(sent.draft.body).toBe('Edited but unsaved'); + }); + }); + + it('sends the edited subject too', async () => { + await openTemplate(); + + const subjectField = screen.getByDisplayValue('Hi {{member.name}}'); + fireEvent.change(subjectField, { target: { value: 'New subject' } }); + + fireEvent.click(screen.getByRole('button', { name: /preview/i })); + + await waitFor(() => { + const call = mockApiFetch.mock.calls.find(([url]) => String(url).endsWith('/preview')); + const sent = JSON.parse(call![1].body); + expect(sent.draft.subject).toBe('New subject'); + }); + }); +}); diff --git a/apps/web/src/app/api/templates/[slug]/preview/route.ts b/apps/web/src/app/api/templates/[slug]/preview/route.ts index c34ad06c..2d183495 100644 --- a/apps/web/src/app/api/templates/[slug]/preview/route.ts +++ b/apps/web/src/app/api/templates/[slug]/preview/route.ts @@ -1,13 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth/next'; import { authOptions } from '@/lib/auth'; -import { renderTemplate } from '@copilotkit/outpost/shared/server'; +import { prisma } from '@copilotkit/outpost/db'; +import { isKnownTemplateSlug, renderTemplate } from '@copilotkit/outpost/shared/server'; import type { TemplateContext } from '@copilotkit/outpost/shared/server'; /** Sample data used for template previews. */ const SAMPLE_CONTEXT: TemplateContext = { org: { name: 'Acme Corp', email: 'support@acme.com' }, - member: { name: 'Jane Smith', email: 'jane@acme.com', invitedBy: 'John Admin', role: 'Engineer' }, + member: { + name: 'Jane Smith', + email: 'jane@acme.com', + invitedBy: 'John Admin', + role: 'Engineer', + }, customer: { name: 'Alex Customer', email: 'alex@example.com' }, invite: { url: 'https://app.outpost.dev/invite/sample-token', expiresIn: '7 days' }, app: { url: 'https://app.outpost.dev' }, @@ -17,12 +23,23 @@ const SAMPLE_CONTEXT: TemplateContext = { url: 'https://app.outpost.dev/tickets/tkt-0042', priority: 'HIGH', assignee: 'Jane Smith', - resolution: 'The API endpoint was updated to v2. Updated the SDK configuration to point to the new URL.', + resolution: + 'The API endpoint was updated to v2. Updated the SDK configuration to point to the new URL.', }, sla: { target: '4 hours', elapsed: '6 hours 23 minutes' }, - escalation: { by: 'System', from: 'Jane Smith', to: 'John Admin', reason: 'SLA breach and no response in 6 hours' }, + escalation: { + by: 'System', + from: 'Jane Smith', + to: 'John Admin', + reason: 'SLA breach and no response in 6 hours', + }, digest: { - date: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }), + date: new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }), openTickets: '12', resolvedToday: '5', breachedSla: '1', @@ -35,7 +52,11 @@ const SAMPLE_CONTEXT: TemplateContext = { * POST /api/templates/[slug]/preview * * Render a template with sample data and return the HTML. - * Optionally accepts a custom context in the request body. + * + * Accepts an optional `draft` ({ subject, body }) so the editor can preview + * UNSAVED edits. Without it the preview rendered whatever was stored, which meant + * an author validated content they were not about to save. Also accepts an optional + * `context` to override the sample data. */ export async function POST( request: NextRequest, @@ -48,17 +69,79 @@ export async function POST( const { slug } = await params; + // A template must exist on disk for an override or a draft to layer onto, and PUT + // refuses a slug that has none. Checked here too so preview and save agree — + // otherwise a renamed or deleted template previews happily and then fails to save. + // + // Membership rather than an existence probe, for the reason PUT gives: a read + // of `../docs/deployment` succeeds, so `if (!loadFromFilesystem(slug))` + // approved the traversal it looked like it rejected. + if (!isKnownTemplateSlug(slug)) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }); + } + + // Read once, then parsed here rather than by `request.json()`. + // + // No body at all is fine — it means "preview what is stored". Unparseable JSON + // is not: silently rendering the stored template would show the author content + // they did not ask for, which is the bug this route was fixed for. + // + // Two reasons for this shape. "Empty" now comes from the body itself instead of + // `content-length`, because a bodiless POST can arrive with no such header at + // all — chunked, or a server-side `new Request(url, { method: 'POST' })` — and + // keying on `!== '0'` sent those to a 400 rather than previewing, the opposite + // of the intent. And a request body can only be read once: calling + // `request.json()` first consumes the stream, so a later `text()` returns empty + // and an unparseable body would have read as "no body" and previewed the stored + // template — reintroducing the bug this route exists to fix. + const raw = await request.text().catch(() => ''); + + let parsed: unknown = null; + if (raw.trim() !== '') { + try { + parsed = JSON.parse(raw); + } catch { + return NextResponse.json({ error: 'Request body is not valid JSON' }, { status: 400 }); + } + } + + const body = (parsed ?? {}) as Record; + let context = SAMPLE_CONTEXT; - try { - const body = await request.json(); - if (body.context) { - context = { ...SAMPLE_CONTEXT, ...body.context }; + if (body.context && typeof body.context === 'object') { + context = { ...SAMPLE_CONTEXT, ...(body.context as object) }; + } + + // A draft that is present but unusable is a client bug, not a request to preview + // the stored copy. Note '' is a valid body — an author may be clearing it. + const draft = body.draft as { subject?: unknown; body?: unknown } | undefined; + if (draft !== undefined) { + if ( + draft === null || + typeof draft !== 'object' || + typeof draft.subject !== 'string' || + typeof draft.body !== 'string' + ) { + return NextResponse.json( + { error: 'draft requires both subject and body as strings' }, + { status: 400 }, + ); } - } catch { - // Use default sample context } - const result = await renderTemplate(slug, context); + const useDraft = draft !== undefined; + + // The loader takes its content from this lookup when it returns non-null, so an + // unsaved draft is supplied the same way a stored override would be — the draft + // wins over the stored row, which is the whole point of previewing edits. + const lookup = useDraft + ? async () => ({ subject: draft!.subject as string, body: draft!.body as string }) + : async (s: string) => { + const override = await prisma.templateOverride.findUnique({ where: { slug: s } }); + return override ? { subject: override.subject, body: override.body } : null; + }; + + const result = await renderTemplate(slug, context, lookup); if (!result) { return NextResponse.json({ error: 'Template not found' }, { status: 404 }); } diff --git a/apps/web/src/app/api/templates/[slug]/route.ts b/apps/web/src/app/api/templates/[slug]/route.ts index 9a7f36d2..c45fbc6f 100644 --- a/apps/web/src/app/api/templates/[slug]/route.ts +++ b/apps/web/src/app/api/templates/[slug]/route.ts @@ -1,11 +1,29 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireSession, requireAdmin } from '@/lib/require-admin'; -import { loadFromFilesystem } from '@copilotkit/outpost/shared/server'; +import { prisma } from '@copilotkit/outpost/db'; +import { isKnownTemplateSlug, loadTemplate } from '@copilotkit/outpost/shared/server'; + +/** Bounds on stored template content. The subject becomes an email header downstream. */ +const SUBJECT_MAX = 500; +const BODY_MAX = 100_000; + +/** + * Look up a stored override for a slug. + * + * Passed into `loadTemplate` so the shared loader stays free of a Prisma + * dependency — it takes the lookup as an argument. + */ +async function findOverride(slug: string): Promise<{ subject: string; body: string } | null> { + const override = await prisma.templateOverride.findUnique({ where: { slug } }); + if (!override) return null; + return { subject: override.subject, body: override.body }; +} /** * GET /api/templates/[slug] * - * Get a single template's content (filesystem default or DB override). + * Get a single template's content: the stored override if one exists, otherwise + * the filesystem default. */ export async function GET( _request: NextRequest, @@ -15,7 +33,17 @@ export async function GET( if (error) return error; const { slug } = await params; - const loaded = loadFromFilesystem(slug); + + // Checked before the loader sees it. `loadFromFilesystem` builds + // `join(dir, slug + '.md')` with no validation and Next decodes + // percent-encoding in a dynamic segment, so `../docs/deployment` reached the + // loader as a traversal and this handler returned the file's `subject` and + // `body` in its JSON. Verified against the real function before fixing. + if (!isKnownTemplateSlug(slug)) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }); + } + + const loaded = await loadTemplate(slug, findOverride); if (!loaded) { return NextResponse.json({ error: 'Template not found' }, { status: 404 }); @@ -35,44 +63,87 @@ export async function GET( * PUT /api/templates/[slug] * * Save a template override (ADMIN only). - * In production this writes to the TemplateOverride table. - * For now, returns a mock success response. */ -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ slug: string }> }, -) { - const { error } = await requireAdmin(); +export async function PUT(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + const { error, session } = await requireAdmin(); if (error) return error; const { slug } = await params; - const body = await request.json(); - // Validate required fields - if (!body.subject || !body.body) { + let payload: unknown; + try { + payload = await request.json(); + } catch { + return NextResponse.json({ error: 'Request body is not valid JSON' }, { status: 400 }); + } + + const { subject, body: templateBody } = (payload ?? {}) as Record; + + // Type-checked, not truthiness-checked: `{ subject: {}, body: [1] }` used to reach + // the upsert and surface as a 500 where a 400 belongs. + if ( + typeof subject !== 'string' || + typeof templateBody !== 'string' || + !subject.trim() || + !templateBody.trim() + ) { return NextResponse.json( - { error: 'subject and body are required' }, + { error: 'subject and body are required strings' }, { status: 400 }, ); } - // In production, this would write to the TemplateOverride table: - // await prisma.templateOverride.upsert({ where: { slug }, create: {...}, update: {...} }) + // Both columns are unbounded in the schema, and the subject becomes an email header + // downstream, so the bound belongs here. + if (subject.length > SUBJECT_MAX || templateBody.length > BODY_MAX) { + return NextResponse.json( + { error: `subject must be under ${SUBJECT_MAX} characters and body under ${BODY_MAX}` }, + { status: 400 }, + ); + } + + // An override only means anything layered over a real template: the loader takes + // its name and `from` from the filesystem entry, and the editor lists filesystem + // slugs. Without this check an arbitrary slug would create a row nothing reads. + // + // Membership rather than an existence probe. `if (!loadFromFilesystem(slug))` + // read as a guard and was the traversal: the read SUCCEEDS for + // `../docs/deployment`, so the check approved the request it appeared to + // reject. A traversal path is never a member of the slug list however it is + // spelled, and a slug that passes here is already known to exist. + if (!isKnownTemplateSlug(slug)) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }); + } + + // Falls back to the member id: the session callback always sets one, so an absent + // email should not cost us the audit trail on a security-relevant screen. + const user = session.user as Record | undefined; + const editedBy = + (typeof user?.email === 'string' ? user.email : null) ?? + (typeof user?.memberId === 'string' ? user.memberId : null); + + const saved = await prisma.templateOverride.upsert({ + where: { slug }, + create: { slug, subject, body: templateBody, editedBy }, + update: { subject, body: templateBody, editedBy }, + }); return NextResponse.json({ slug, - subject: body.subject, - body: body.body, + subject: saved.subject, + body: saved.body, isOverride: true, - updatedAt: new Date().toISOString(), + editedBy: saved.editedBy ?? editedBy, + updatedAt: saved.updatedAt, }); } /** * DELETE /api/templates/[slug] * - * Reset a template to filesystem default (ADMIN only). - * Deletes the DB override if one exists. + * Reset a template to its filesystem default by removing the stored override + * (ADMIN only). Uses deleteMany so resetting an already-default template is a + * no-op rather than a 404 from Prisma. */ export async function DELETE( _request: NextRequest, @@ -83,7 +154,16 @@ export async function DELETE( const { slug } = await params; - // In production: await prisma.templateOverride.delete({ where: { slug } }) + // Not a traversal risk on its own — `deleteMany` on an unknown slug matches + // nothing. Checked anyway so all four handlers answer the same way for the + // same input; GET returning 404 while DELETE reports `reset: true` for the + // same slug is the kind of disagreement that gets read as one of them being + // wrong. + if (!isKnownTemplateSlug(slug)) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }); + } + + const { count } = await prisma.templateOverride.deleteMany({ where: { slug } }); - return NextResponse.json({ slug, reset: true }); + return NextResponse.json({ slug, reset: true, hadOverride: count > 0 }); } diff --git a/apps/web/src/app/api/templates/route.ts b/apps/web/src/app/api/templates/route.ts index 9c09d013..bbb57fad 100644 --- a/apps/web/src/app/api/templates/route.ts +++ b/apps/web/src/app/api/templates/route.ts @@ -1,18 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth/next'; import { authOptions } from '@/lib/auth'; -import { - listTemplateSlugs, - loadFromFilesystem, -} from '@copilotkit/outpost/shared/server'; +import { prisma } from '@copilotkit/outpost/db'; +import { listTemplateSlugs, loadFromFilesystem } from '@copilotkit/outpost/shared/server'; import type { TemplateListEntry } from '@copilotkit/outpost/shared/server'; /** * GET /api/templates * - * List all templates. Merges filesystem defaults with DB overrides. - * In this mock implementation, we only return filesystem templates - * (DB overrides would be merged in production with Prisma). + * List all templates, merging filesystem defaults with stored overrides. An + * overridden entry reports the override's subject and edit metadata, so the list + * shows what is actually in effect rather than what is on disk. */ export async function GET(_request: NextRequest) { const session = await getServerSession(authOptions); @@ -22,15 +20,23 @@ export async function GET(_request: NextRequest) { const slugs = listTemplateSlugs(); + // One query rather than one per slug. + const overrides = await prisma.templateOverride.findMany({ + where: { slug: { in: slugs } }, + }); + const bySlug = new Map(overrides.map((o) => [o.slug, o])); + const entries: TemplateListEntry[] = slugs.map((slug) => { const loaded = loadFromFilesystem(slug); + const override = bySlug.get(slug); + return { slug, name: loaded?.meta.name || slug, - subject: loaded?.meta.subject || '', - isOverride: false, - updatedAt: null, - editedBy: null, + subject: override?.subject ?? loaded?.meta.subject ?? '', + isOverride: Boolean(override), + updatedAt: override?.updatedAt ? override.updatedAt.toISOString() : null, + editedBy: override?.editedBy ?? null, }; }); diff --git a/apps/web/src/app/settings/templates/page.tsx b/apps/web/src/app/settings/templates/page.tsx index 5742de4b..2b233427 100644 --- a/apps/web/src/app/settings/templates/page.tsx +++ b/apps/web/src/app/settings/templates/page.tsx @@ -5,6 +5,21 @@ import { Mail, FileText, RotateCcw, Save, Eye, ChevronLeft } from 'lucide-react' import { PageHeader } from '@/components/page-header'; import { apiFetch } from '@/lib/api-fetch'; +/** + * Turn a failed response into a message worth showing. + * + * The routes return `{ error }` with an actionable reason — an empty field, a missing + * template, insufficient permissions — and reporting a fixed string instead sends the + * author looking for an outage. The status tail matters for a genuine 500, which has no + * JSON body: it is the only thing separating "your input was rejected" from "the server + * broke". + */ +async function describeFailure(res: Response, fallback: string): Promise { + const detail = await res.json().catch(() => null); + const serverMessage = detail && typeof detail.error === 'string' ? detail.error : null; + return serverMessage ?? `${fallback} (HTTP ${res.status})`; +} + interface TemplateEntry { slug: string; name: string; @@ -44,7 +59,7 @@ export default function TemplatesPage() { const fetchTemplates = useCallback(async () => { try { const res = await apiFetch('/api/templates'); - if (!res.ok) throw new Error('Failed to load templates'); + if (!res.ok) throw new Error(await describeFailure(res, 'Failed to load templates')); const data = await res.json(); setTemplates(data); } catch (err) { @@ -64,7 +79,7 @@ export default function TemplatesPage() { try { const res = await apiFetch(`/api/templates/${slug}`); - if (!res.ok) throw new Error('Failed to load template'); + if (!res.ok) throw new Error(await describeFailure(res, 'Failed to load template')); const data: TemplateDetail = await res.json(); setSelected(data); setEditSubject(data.subject); @@ -78,12 +93,14 @@ export default function TemplatesPage() { if (!selected) return; try { + // Send the draft, not an empty body. Previously the server rendered the + // STORED template, so an author previewed content they were not saving. const res = await apiFetch(`/api/templates/${selected.slug}/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({ draft: { subject: editSubject, body: editBody } }), }); - if (!res.ok) throw new Error('Preview failed'); + if (!res.ok) throw new Error(await describeFailure(res, 'Preview failed')); const data: PreviewResult = await res.json(); setPreview(data); setShowPreview(true); @@ -104,9 +121,21 @@ export default function TemplatesPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject: editSubject, body: editBody }), }); - if (!res.ok) throw new Error('Save failed'); - setSuccess('Template saved successfully'); + if (!res.ok) throw new Error(await describeFailure(res, 'Save failed')); await fetchTemplates(); + // Says what actually happened. The override is written and every read + // surface honours it — this list, GET, the preview — but outgoing email + // does not: `sendEmail` consults an override only when handed a + // `dbLookup` (shared/src/email/sender.ts:166), and neither + // api/team/invite/route.ts:72 nor invite/resend/route.ts:52 passes one. + // So an invitee receives the on-disk copy. + // + // "Template saved successfully" was true about the row and false about + // the thing the author cared about, which is the same silent-success + // shape as the rest of this screen's history. Wiring the lookup is + // tracked separately, and per outpost#253 would not make an edited + // template reach an invitee today either. + setSuccess('Saved. Not yet used for outgoing email — see outpost#226.'); } catch (err) { setError(err instanceof Error ? err.message : 'Save failed'); } finally { @@ -123,10 +152,19 @@ export default function TemplatesPage() { const res = await apiFetch(`/api/templates/${selected.slug}`, { method: 'DELETE', }); - if (!res.ok) throw new Error('Reset failed'); - setSuccess('Template reset to default'); + if (!res.ok) throw new Error(await describeFailure(res, 'Reset failed')); + const result = await res.json().catch(() => null); + + // selectTemplate clears the banners, so the message has to be set after the + // reloads rather than before them — otherwise a destructive action that now + // really deletes gives the author no feedback at all. await selectTemplate(selected.slug); await fetchTemplates(); + setSuccess( + result?.hadOverride === false + ? 'Template was already using the default' + : 'Template reset to default', + ); } catch (err) { setError(err instanceof Error ? err.message : 'Reset failed'); } @@ -136,12 +174,9 @@ export default function TemplatesPage() {
{error && ( @@ -183,7 +218,7 @@ export default function TemplatesPage() { : 'bg-muted text-muted-foreground' }`} > - {t.isOverride ? 'Custom' : 'Default'} + {t.isOverride ? 'Custom (preview only)' : 'Default'}

@@ -248,9 +283,34 @@ export default function TemplatesPage() { Subject: {preview.subject}

-
) : ( diff --git a/packages/outpost/shared/src/templates/loader.ts b/packages/outpost/shared/src/templates/loader.ts index 90af2146..b4fc9a98 100644 --- a/packages/outpost/shared/src/templates/loader.ts +++ b/packages/outpost/shared/src/templates/loader.ts @@ -29,8 +29,10 @@ export function parseFrontmatter(raw: string): { meta: TemplateMeta; body: strin const key = line.slice(0, colonIdx).trim(); let value = line.slice(colonIdx + 1).trim(); // Strip surrounding quotes - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { value = value.slice(1, -1); } meta[key] = value; @@ -117,6 +119,34 @@ export async function loadTemplate( } /** List all available template slugs from the filesystem. */ +/** + * Whether `slug` names one of the templates that actually exist. + * + * The reason this exists rather than a path check: `loadFromFilesystem` builds + * `join(dir, slug + '.md')` with no validation, and Next decodes percent-encoding + * in a dynamic segment before a handler sees it — so `../docs/deployment` reaches + * the loader as a traversal and reads the file. Verified against the real + * function: `../README`, `../CLAUDE`, `../docs/deployment` and + * `invite/../../README` all returned file contents, and the API route hands + * `subject` and `body` back in its JSON response. + * + * An existence check does NOT close that, and this is the part worth being + * explicit about: a guard shaped like `if (!loadFromFilesystem(slug))` SUCCEEDS + * for every path above, so it approves the request it looks like it rejects. The + * check performs the escape it appears to prevent. + * + * Membership in the real slug list is the property that holds, because a + * traversal path is never a member however it is spelled. It also makes the + * existence probes redundant — a slug that passed here is known to exist. + * + * Note the interaction with outpost#253: where `templates/` is absent from the + * running image, this returns false for everything and callers 404. That is the + * same behaviour those callers already had, and it fails in the safe direction. + */ +export function isKnownTemplateSlug(slug: string): boolean { + return listTemplateSlugs().includes(slug); +} + export function listTemplateSlugs(): string[] { const dir = findTemplatesDir(); try {