From feb2e7c3e1d1bc10f64098b39270dd0a3bf892fe Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Sun, 4 Oct 2026 17:45:03 -0700 Subject: [PATCH 1/3] feat: add public agent runbooks and safe template activation --- convex/lists.ts | 34 +- convex/schema.ts | 1 + convex/templates.ts | 29 +- docs/templates.md | 38 ++ scripts/template-app-lock.test.mjs | 45 ++ scripts/template-continuation.test.mjs | 32 ++ scripts/template-pages.ts | 32 ++ scripts/template-seo.test.mjs | 44 ++ scripts/templates.test.mjs | 170 +++++++ server.ts | 5 +- shared/templates.ts | 604 +++++++++++++++++++++++++ src/App.tsx | 9 +- src/components/AppLockGuard.tsx | 11 +- src/components/TemplatePickerModal.tsx | 24 +- src/lib/analytics.ts | 5 + src/lib/builtinTemplates.ts | 132 +----- src/lib/templateActivation.ts | 29 ++ src/lib/templateMetadata.ts | 38 ++ src/pages/Landing.tsx | 1 + src/pages/Login.tsx | 9 +- src/pages/TemplateGallery.tsx | 132 ++++++ src/pages/Templates.tsx | 24 +- tests/fixtures/app.ts | 7 + tests/fixtures/server.mjs | 33 ++ tests/templates.e2e.ts | 65 +++ vite.config.ts | 2 + 26 files changed, 1366 insertions(+), 189 deletions(-) create mode 100644 docs/templates.md create mode 100644 scripts/template-app-lock.test.mjs create mode 100644 scripts/template-continuation.test.mjs create mode 100644 scripts/template-pages.ts create mode 100644 scripts/template-seo.test.mjs create mode 100644 scripts/templates.test.mjs create mode 100644 shared/templates.ts create mode 100644 src/lib/templateActivation.ts create mode 100644 src/lib/templateMetadata.ts create mode 100644 src/pages/TemplateGallery.tsx create mode 100644 tests/templates.e2e.ts diff --git a/convex/lists.ts b/convex/lists.ts index 7401510e..ea43ecd9 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -71,7 +71,7 @@ export function createListOwnershipVC( * Extracted rather than inlined so copyList cannot become a way around the * limit — a copy is a new list and counts like one. */ -async function assertListQuota( +export async function assertListQuota( ctx: MutationCtx, ownerDid: string ): Promise<{ owner: Doc<"users"> | null; isFirstList: boolean }> { @@ -107,6 +107,24 @@ async function assertListQuota( return { owner, isFirstList: existingLists.length === 0 }; } +/** Shared transaction-local first-list benefit for every template/list entry point. */ +export async function grantFirstListReferral(ctx: MutationCtx, owner: Doc<"users"> | null, isFirstList: boolean) { + // Award 30-day referral Pro to both referee and referrer on first list creation + if (owner && isFirstList) { + const referral = await ctx.db + .query("referrals") + .withIndex("by_referee", (q) => q.eq("refereeId", owner._id)) + .first(); + if (referral && !referral.proGrantedAt) { + const proUntil = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days + await ctx.db.patch(owner._id, { referralProUntil: proUntil }); + await ctx.db.patch(referral.referrerId, { referralProUntil: proUntil }); + await ctx.db.patch(referral._id, { proGrantedAt: Date.now() }); + } + } + +} + export const { public: createList, internal: createListInternal, replay: createListReplay } = actorMutation({ offlineOperation: "createList", resources: () => ({}), @@ -162,19 +180,7 @@ export const { public: createList, internal: createListInternal, replay: createL await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); } - // Award 30-day referral Pro to both referee and referrer on first list creation - if (owner && isFirstList) { - const referral = await ctx.db - .query("referrals") - .withIndex("by_referee", (q) => q.eq("refereeId", owner._id)) - .first(); - if (referral && !referral.proGrantedAt) { - const proUntil = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days - await ctx.db.patch(owner._id, { referralProUntil: proUntil }); - await ctx.db.patch(referral.referrerId, { referralProUntil: proUntil }); - await ctx.db.patch(referral._id, { proGrantedAt: Date.now() }); - } - } + await grantFirstListReferral(ctx, owner, isFirstList); return listId; }), diff --git a/convex/schema.ts b/convex/schema.ts index 72c5bab8..9421cf39 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -155,6 +155,7 @@ export default defineSchema({ // Lists table - each list is an Originals asset lists: defineTable({ + templateSource: v.optional(v.string()), // Catalog/source identity for safe creation retries assetDid: v.string(), // Originals asset DID (did:cel at genesis, did:webvh once published) name: v.string(), ownerDid: v.string(), // Creator's DID diff --git a/convex/templates.ts b/convex/templates.ts index f1fd4395..6f03a850 100644 --- a/convex/templates.ts +++ b/convex/templates.ts @@ -5,6 +5,8 @@ import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; import { query } from "./_generated/server"; +import { BUILTIN_TEMPLATES } from "../shared/templates"; +import { assertListQuota, createListOwnershipVC, grantFirstListReferral } from "./lists"; import { upsertListEnvelope } from "./lib/listEnvelope"; // Id type used in function arguments via v.id() @@ -182,31 +184,47 @@ export const { public: createListFromTemplate, internal: createListFromTemplateI resources: () => ({}), scope: "items:write", args: { - templateId: v.id("listTemplates"), + templateId: v.optional(v.id("listTemplates")), + builtinId: v.optional(v.string()), + expectedOwnerDid: v.optional(v.string()), listName: v.string(), // Genesis happens client-side (only the client holds the key), same as lists.createList. assetDid: v.string(), celEnvelope: v.optional(v.string()), }, handler: async (ctx, args) => { - const template = await ctx.db.get(args.templateId); + if (args.expectedOwnerDid && args.expectedOwnerDid !== ctx.actor.did) throw new Error("Account changed; select the template again"); + const templateSource = args.builtinId ? `builtin:${args.builtinId}` : `saved:${args.templateId}`; + if (!!args.templateId === !!args.builtinId) throw new Error("Choose exactly one template"); + const template = args.builtinId + ? BUILTIN_TEMPLATES.find(t => t.id === args.builtinId) + : await ctx.db.get(args.templateId!); if (!template) throw new Error("Template not found"); - - // Check if template is accessible - if (!template.isPublic && ![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { + if ("ownerDid" in template && !template.isPublic && ![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { throw new Error("Not authorized to use this template"); } + if (!args.listName.trim() || args.listName.length > 200) throw new Error("List name must be between 1 and 200 characters"); + // A client retains the minted asset across transport retries. Convex serializes + // this indexed read with insertion, so concurrent retries create one list. + const existing = await ctx.db.query("lists").withIndex("by_asset_did", q => q.eq("assetDid", args.assetDid)).first(); + if (existing) { + if (existing.ownerDid !== ctx.actor.did || existing.templateSource !== templateSource) throw new Error("Asset already used"); + return existing._id; + } + const { owner, isFirstList } = await assertListQuota(ctx, ctx.actor.did); const now = Date.now(); // Create the list const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, + templateSource, name: args.listName, ownerDid: ctx.actor.did, createdAt: now, }); + await ctx.db.patch(listId, { vcProof: createListOwnershipVC(listId, args.assetDid, ctx.actor.did, args.listName, now) }); if (args.celEnvelope) { await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); } @@ -225,6 +243,7 @@ export const { public: createListFromTemplate, internal: createListFromTemplateI }); } + await grantFirstListReferral(ctx, owner, isFirstList); return listId; }, }); diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 00000000..a266e09d --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,38 @@ +# Agent runbook templates + +The public catalog at `/templates` contains ten editorial runbooks in `shared/templates.ts`. Each has a stable slug, use-case copy, expected outcome, six ordered steps, priorities, and explicit agent/human responsibilities. The existing quick-start templates remain available. Personal and community templates remain in the authenticated `/templates/saved` manager; anonymous landing pages only read the source-controlled catalog, never saved-template records. + +## Creation and continuation + +“Use this template” sends anonymous visitors to `/login?template=`. Both the authenticated login redirect and OTP completion resolve that parameter against the runbook allowlist. Unknown IDs, URLs, and arbitrary redirect parameters fall back to `/d`. Successful authentication returns to `/templates//use`; the page waits for canonical identity, enters the existing app-lock boundary before attempting creation, then navigates to the created list. Failure remains on the runbook with an explicit retry and plan-limit guidance. Reloading login retains the selection; the continuation never accepts a private template ID. + +`templates.createListFromTemplate` accepts exactly one of `builtinId` and `templateId`. Authentication, saved-template access, name validation, and the existing list quota apply before insertion. The list, ownership proof, optional envelope, and every item are inserted in one Convex mutation: an item failure rejects the transaction instead of acknowledging partial success. First-list referral benefits share the same transaction-local helper as ordinary list creation. Creation does not execute the runbook or grant agent access. + +Public-page retries retain the minted asset in sessionStorage, keyed by canonical account DID and catalog slug. An in-flight promise deduplicates local genesis. The backend's indexed asset lookup acknowledges only the same owner and `templateSource`; concurrent/repeated requests cannot create duplicate lists, including when the first request filled the quota. Only an acknowledged completed attempt can be replaced by an explicit new use; pending attempts survive a lost response and reload. An `expectedOwnerDid` business argument rejects an account mismatch even if credentials change, and the page checks current identity before sending and navigating. AppLockGuard waits for the lock preference to resolve, fails closed on read errors, and never mounts activation before successful unlock when enabled. Storage failure prevents the request and presents retry rather than risking an unrepeatable write. Clearing browser session storage ends retry continuity. + +## Conversion semantics + +Existing consent-gated PostHog capture emits: + +- `template_viewed`: a mounted public runbook detail page; `template_id` is a catalog slug. Repeated visits count as views. +- `template_use_clicked`: explicit intent to use a runbook, on the public page or authenticated built-in picker/manager. Automatic continuation does not emit another click. Retry clicks count as attempts. +- `template_activated`: the backend has acknowledged a complete preloaded list. Includes `template_id`, `list_id`, and stable `$insert_id = template_activated:` for PostHog deduplication on response retries. Genesis, authentication, quota, and item insertion failures emit no activation. + +Build a per-template PostHog funnel `template_use_clicked → template_activated`, matching `template_id`, with unique users and a seven-day conversion window. A supplementary `template_viewed → template_use_clicked` funnel measures landing-page interest. Activation here means **complete runbook list created**, not first agent activity, signup, task completion, or the weekly collaborative-list north star. Existing PostHog identification connects consented anonymous activity to an authenticated user. No user-written task names/descriptions or private template IDs enter these events. Declined consent, ad blockers, unconfigured analytics, and lost success responses before a retry undercount; this is a product funnel, not an authoritative billing ledger. + +## Indexing and serving + +Vite emits `/templates/index.html` and one `/templates//index.html` for every runbook, with static use-case copy and steps, unique title/description, canonical URL, social metadata, and links. The generated sitemap adds all eleven URLs. `server.ts` serves directory indexes. Static hosts must serve directory indexes before their SPA fallback. Public pages do not require Convex to render their catalog. Metadata updates on client navigation; arbitrary slugs display a not-found page and are absent from the sitemap. + +## Verification + +- `bun test scripts/templates.test.mjs scripts/template-continuation.test.mjs scripts/template-app-lock.test.mjs`: catalog completeness; all runbook creation handlers; quota; retries; ownership/source mismatch; invalid selection; rejected insert failures. The in-memory handler fixture does not emulate Convex transaction rollback; atomicity relies on Convex's mutation transaction contract. +- `bunx tsc -b`, `bunx tsc -p convex/tsconfig.json --noEmit`, `bun run test:e2e:typecheck`. +- `SENTRY_AUTH_TOKEN='' bunx vite build`: local HTML generation without live Convex codegen or source-map upload. +- `bunx e2e run tests/templates.e2e.ts`: isolated loopback fixture; public gallery, signup continuation, complete population, quota failure/retry, invalid redirect fallback, and lost-response/reload retry at quota. It never provisions a live account or sends an OTP. + +Real OTP delivery, deployed Convex transactions, production crawling, and PostHog ingestion require deployment verification by the owner; local checks do not establish those outcomes. Platform builds, Lighthouse, full browser suite, and automated review remain repository delivery gates managed by the coordinator. + +Pending referral codes are redeemed and awaited on the unlocked activation route before first-list creation. Invalid/already-redeemed codes follow the existing backend semantics; transport failures retain the code and block creation until retry, rather than silently losing eligibility. The signup browser regression seeds a pending code and asserts redemption precedes the template mutation. + +Generated pages carry the original shell metadata in inert JSON. Client metadata reuses one canonical element, updates social fields, and restores the original shell on exit even when the session began on a prerendered template. `scripts/template-seo.test.mjs` exercises generated HTML and detail-to-detail-to-exit metadata transitions. diff --git a/scripts/template-app-lock.test.mjs b/scripts/template-app-lock.test.mjs new file mode 100644 index 00000000..88492ceb --- /dev/null +++ b/scripts/template-app-lock.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { mkdir } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import React, { useEffect } from 'react'; +import { render, act, cleanup } from '@testing-library/react'; + +if (!globalThis.document) { + const { GlobalRegistrator } = await import('@happy-dom/global-registrator'); + GlobalRegistrator.register({url:'http://localhost/'}); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; +} +await mkdir('tmp/template-app-lock-test', {recursive:true}); +await build({entryPoints:['src/components/AppLockGuard.tsx'],outfile:'tmp/template-app-lock-test/guard.mjs',bundle:true,jsx:'automatic',platform:'node',format:'esm',external:['react','react/*'],plugins:[{ + name:'lock-fixture',setup(b) { + b.onResolve({filter:/\/lib\/(storage|biometrics)$/},args=>({path:args.path,namespace:'lock-fixture'})); + b.onLoad({filter:/.*/,namespace:'lock-fixture'},args=>({contents:args.path.endsWith('storage')?'export const getBiometricLockEnabled = () => globalThis.__templateLockSettings();':'export const biometrics = {authenticate: () => globalThis.__templateLockAuthenticate()};'})); + } +}]}); +const {AppLockGuard} = await import(pathToFileURL(`${process.cwd()}/tmp/template-app-lock-test/guard.mjs`)); +test('template activation cannot mount before delayed settings and successful biometric unlock',async()=>{ + let settings, unlock, writes=0; + globalThis.__templateLockSettings=()=>new Promise(resolve=>{settings=resolve;}); + globalThis.__templateLockAuthenticate=()=>new Promise(resolve=>{unlock=resolve;}); + function Activation(){useEffect(()=>{writes++;},[]);return React.createElement('div',null,'Activation');} + const view=render(React.createElement(AppLockGuard,null,React.createElement(Activation))); + assert.equal(writes,0); + await act(async()=>settings(true)); + assert.equal(writes,0); + assert.ok(view.getByText('boop is locked')); + await act(async()=>unlock(true)); + assert.equal(writes,1); + cleanup(); +}); +test('settings failure fails closed rather than mounting template activation',async()=>{ + globalThis.__templateLockSettings=async()=>{throw new Error('unavailable');}; + let mounts=0; + function Activation(){useEffect(()=>{mounts++;},[]);return null;} + await act(async()=>{render(React.createElement(AppLockGuard,null,React.createElement(Activation)));}); + assert.equal(mounts,0); + cleanup(); + delete globalThis.__templateLockSettings; + delete globalThis.__templateLockAuthenticate; +}); diff --git a/scripts/template-continuation.test.mjs b/scripts/template-continuation.test.mjs new file mode 100644 index 00000000..f54d39e1 --- /dev/null +++ b/scripts/template-continuation.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { mkdir } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +await mkdir('tmp/template-continuation-test', {recursive:true}); +await build({entryPoints:['src/lib/templateActivation.ts'],outfile:'tmp/template-continuation-test/activation.mjs',bundle:true,platform:'node',format:'esm',plugins:[{ + name:'isolated-genesis',setup(b) { b.onResolve({filter:/^\.\/originals$/},()=>({path:'genesis',namespace:'fixture'})); b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:'let n=0; export async function createListAsset(name,did) { return {assetDid:`did:cel:${did}:${++n}`,envelope:JSON.stringify({name,did})}; }'})); } +}]}); +const {templateAsset,finishTemplateAttempt} = await import(pathToFileURL(`${process.cwd()}/tmp/template-continuation-test/activation.mjs`)); +if (!globalThis.sessionStorage) { + const storage = new Map(); + Object.defineProperty(globalThis, 'sessionStorage', {configurable:true,value:{getItem:k=>storage.get(k)??null,setItem:(k,v)=>storage.set(k,v),clear:()=>storage.clear()}}); +} +const values = sessionStorage; +test('pending attempts survive explicit retry and refresh, completed uses can start anew',async()=>{ + values.clear(); + const first = await templateAsset('owner','release','Release',true); + assert.deepEqual(await templateAsset('owner','release','Release',true),first); + assert.deepEqual(await templateAsset('owner','release','Release'),first); + finishTemplateAttempt('owner','release'); + assert.equal((await templateAsset('owner','release','Release')).assetDid,first.assetDid); + assert.notEqual((await templateAsset('owner','release','Release',true)).assetDid,first.assetDid); +}); +test('account and template changes cannot reuse another attempt; concurrent genesis is deduplicated',async()=>{ + values.clear(); + const [a,b] = await Promise.all([templateAsset('a','release','Release'),templateAsset('a','release','Release')]); + assert.deepEqual(a,b); + assert.notEqual((await templateAsset('b','release','Release')).assetDid,a.assetDid); + assert.notEqual((await templateAsset('a','research','Research')).assetDid,a.assetDid); +}); diff --git a/scripts/template-pages.ts b/scripts/template-pages.ts new file mode 100644 index 00000000..9bf8dc82 --- /dev/null +++ b/scripts/template-pages.ts @@ -0,0 +1,32 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { AGENT_RUNBOOKS } from '../shared/templates'; + +const escape = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); +/** Emit real crawlable HTML, using the same public-only catalog as the app/backend. */ +export async function writeTemplatePages(outDir: string) { + const shell = await readFile(resolve(outDir, 'index.html'), 'utf8'); + const shellMetadata = { + title: shell.match(/(.*?)<\/title>/)?.[1] ?? 'boop', + canonical: shell.match(/<link rel="canonical" href="([^"]*)"/)?.[1] ?? null, + meta: Object.fromEntries([...shell.matchAll(/<meta (?:name|property)="([^"]*)" content="([^"]*)"/g)].map(match => [match[1], match[2]])), + }; + for (const template of [undefined, ...AGENT_RUNBOOKS]) { + const path = `/templates${template ? `/${template.id}` : ''}`; + const title = template ? `${template.name} | boop` : 'Agent runbook templates | boop'; + const description = template?.description ?? 'Ten practical agent runbooks for work you can review.'; + const body = template + ? `<h1>${escape(template.name)}</h1><p>${escape(description)}</p><p>${escape(template.useCase!)}</p><h2>What you’ll have at the end</h2><p>${escape(template.outcome!)}</p><h2>The runbook</h2><ol>${template.items.map(i => `<li><h3>${escape(i.name)}</h3><p>${escape(i.description!)}</p></li>`).join('')}</ol><a href="/login?template=${template.id}">Use this template</a>` + : `<h1>Agent runbooks for work you can review.</h1>${AGENT_RUNBOOKS.map(t => `<article><h2><a href="/templates/${t.id}">${escape(t.name)}</a></h2><p>${escape(t.description)}</p></article>`).join('')}`; + const html = shell.replace(/<title>.*?<\/title>/, `<title>${escape(title)}`) + .replace(/()/g, `$1${escape(description)}$2`) + .replace(/()/g, `$1${escape(title)}$2`) + .replace(/()/, `$1https://boop.ad${path}$2`) + .replace('', ``) + .replace('
', ``); + await mkdir(resolve(outDir, path.slice(1)), { recursive: true }); + await writeFile(resolve(outDir, path.slice(1), 'index.html'), html); + } + const sitemap = await readFile(resolve(outDir, 'sitemap.xml'), 'utf8'); + await writeFile(resolve(outDir, 'sitemap.xml'), sitemap.replace('', ['/templates', ...AGENT_RUNBOOKS.map(t => `/templates/${t.id}`)].map(p => `https://boop.ad${p}`).join('\n') + '\n')); +} diff --git a/scripts/template-seo.test.mjs b/scripts/template-seo.test.mjs new file mode 100644 index 00000000..46297f51 --- /dev/null +++ b/scripts/template-seo.test.mjs @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, readFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; +import { writeTemplatePages } from './template-pages.ts'; +import { applyTemplateMetadata } from '../src/lib/templateMetadata.ts'; +import { AGENT_RUNBOOKS } from '../shared/templates.ts'; + +const out='tmp/template-seo-test'; +await mkdir(out,{recursive:true}); +const shell=await readFile('index.html','utf8'); +await writeFile(`${out}/index.html`,shell); +await writeFile(`${out}/sitemap.xml`,await readFile('public/sitemap.xml','utf8')); +await writeTemplatePages(out); +test('every generated page contains crawlable copy, steps, canonical, and a sitemap entry',async()=>{ + const sitemap=await readFile(`${out}/sitemap.xml`,'utf8'); + for(const t of AGENT_RUNBOOKS){ + const html=await readFile(`${out}/templates/${t.id}/index.html`,'utf8'); + assert.ok(html.includes(`

${t.name}

`)); + assert.ok(html.includes(t.useCase)); + assert.equal((html.match(/
  • /g)??[]).length,t.items.length); + assert.equal((html.match(/rel="canonical"/g)??[]).length,1); + assert.ok(sitemap.includes(`https://boop.ad/templates/${t.id}`)); + } +}); +test('hard-loaded template metadata stays singular across details and restores shell on exit',async()=>{ + const oldHead=document.head.innerHTML; + try { + const html=await readFile(`${out}/templates/release-checklist/index.html`,'utf8'); + document.head.innerHTML=html.match(/([\s\S]*?)<\/head>/)[1]; + const first=applyTemplateMetadata(AGENT_RUNBOOKS[0]); + assert.equal(document.querySelectorAll('link[rel="canonical"]').length,1); + first(); + const second=applyTemplateMetadata(AGENT_RUNBOOKS[1]); + assert.equal(document.querySelectorAll('link[rel="canonical"]').length,1); + assert.equal(document.querySelector('link[rel="canonical"]').href,'https://boop.ad/templates/research-pipeline'); + assert.equal(document.querySelector('meta[property="og:title"]').content,'Research pipeline | boop'); + second(); // Navigation to quickstart/home must not retain a runbook canonical. + assert.equal(document.title,'boop'); + assert.equal(document.querySelector('link[rel="canonical"]'),null); + assert.ok(!document.querySelector('meta[name="description"]').content.includes('research')); + assert.ok(!document.querySelector('meta[property="og:title"]').content.includes('pipeline')); + } finally {document.head.innerHTML=oldHead;} +}); diff --git a/scripts/templates.test.mjs b/scripts/templates.test.mjs new file mode 100644 index 00000000..66f2bd91 --- /dev/null +++ b/scripts/templates.test.mjs @@ -0,0 +1,170 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; +import { createAuthFixture } from "./helpers/auth-fixture.mjs"; + +const outdir = "tmp/templates-test"; + +async function loadModule() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["./convex/templates.ts"], + outfile: `${outdir}/templates.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/templates.mjs`).href}?t=${Date.now()}` + ); +} + +const mod = await loadModule(); +const unwrap = (fn) => fn._handler ?? fn.handler; + +const OWNER = "did:webvh:QmS:boop.ad:user-owner"; +const STRANGER = "did:webvh:QmS:boop.ad:user-stranger"; +const ownerSession = await createAuthFixture(OWNER); +const strangerSession = await createAuthFixture(STRANGER); + +/** + * The source list is a migrated one: its envelope exists but nobody holds the + * key, which is the situation copyList is for. + */ +function makeCtx({ items = [], lists: extraLists = [], user = null, subscription = null, failItem = false } = {}) { + const rows = { + lists: [ + { _id: "L1", ownerDid: OWNER, name: "Camping", createdAt: 1000, assetDid: "did:cel:old", itemViewMode: "categorized", itemCategories: [{ id: "c1", name: "Gear", emoji: "🎒", order: 0 }] }, + ...extraLists, + ], + items: items.map((i, n) => ({ _id: `I${n}`, listId: "L1", ...i })), + users: [{ ...ownerSession.user, ...user }, strangerSession.user], + accessSessions: [ownerSession.accessSession, strangerSession.accessSession], + subscriptions: subscription ? [subscription] : [], + referrals: [], + listEnvelopes: [], + }; + + const byId = new Map(); + for (const table of Object.values(rows)) for (const r of table) byId.set(r._id, r); + + let seq = 0; + return { + rows, + db: { + get: async (id) => byId.get(id) ?? null, + patch: async (id, fields) => Object.assign(byId.get(id), fields), + insert: async (table, doc) => { + if (table === "items" && failItem) throw new Error("Injected insert failure"); + const _id = `${table}-${++seq}`; + const row = { _id, _creationTime: Date.now(), ...doc }; + (rows[table] ??= []).push(row); + byId.set(_id, row); + return _id; + }, + query: (table) => { + let rowsFor = () => rows[table] ?? []; + const result = { + withIndex: (_name, fn) => { + // Emulate just enough index filtering for by_list / by_owner. + const captured = {}; + const builder = { eq: (field, value) => { captured[field] = value; return builder; } }; + fn?.(builder); + const base = rowsFor; + rowsFor = () => + base().filter((r) => Object.entries(captured).every(([k, v]) => r[k] === v)); + return result; + }, + collect: async () => rowsFor(), + first: async () => rowsFor()[0] ?? null, + unique: async () => rowsFor()[0] ?? null, + }; + return result; + }, + }, + }; +} + + +const create = unwrap(mod.createListFromTemplate); +const args = { builtinId: 'release-checklist', listName: 'Release checklist', assetDid: 'did:cel:template-test', authToken: ownerSession.authToken, expectedOwnerDid: OWNER }; + +test('all ten catalog runbooks have actionable, ordered steps and distinct landing copy', async () => { + const { AGENT_RUNBOOKS, templateLoginDestination } = await import('../shared/templates.ts'); + assert.equal(AGENT_RUNBOOKS.length, 10); + assert.equal(new Set(AGENT_RUNBOOKS.map(t => t.id)).size, 10); + for (const template of AGENT_RUNBOOKS) { + assert.ok(template.useCase.length > 40 && template.outcome.length > 40); + assert.ok(template.items.length >= 6); + assert.deepEqual(template.items.map(i => i.order), template.items.map((_, i) => i)); + assert.ok(template.items.every(i => i.description.length > 60)); + const ctx = makeCtx(); + const id = await create(ctx, { ...args, builtinId: template.id }); + assert.deepEqual(ctx.rows.items.filter(i => i.listId === id).map(i => i.name), template.items.map(i => i.name)); + } + assert.equal(templateLoginDestination('?template=release-checklist'), '/templates/release-checklist/use'); + for (const input of ['?template=https://evil.test', '?template=../d', '?template=private-id', '?next=//evil.test']) assert.equal(templateLoginDestination(input), '/d'); +}); +test('retry returns the complete original list without extra items, even at quota', async () => { + const ctx = makeCtx({ lists: Array.from({length:3}, (_,i) => ({_id:`extra-${i}`, ownerDid:OWNER})) }); + const id = await create(ctx, args); + assert.equal(await create(ctx, args), id); + assert.equal(ctx.rows.lists.length, 5); + assert.equal(ctx.rows.items.length, 6); + assert.ok(ctx.rows.items.every(i => i.checked === false && i.createdByDid === OWNER)); + assert.ok((await ctx.db.get(id)).vcProof); +}); +test('new creation enforces free-plan quota before writing', async () => { + const ctx = makeCtx({ lists: Array.from({length:4}, (_,i) => ({_id:`extra-${i}`, ownerDid:OWNER})) }); + await assert.rejects(create(ctx, args), /PLAN_LIMIT/); + assert.equal(ctx.rows.lists.length, 5); + assert.equal(ctx.rows.items.length, 0); +}); +test('unknown template, mismatched account and unrelated asset cannot be acknowledged as activation', async () => { + const ctx = makeCtx(); + await assert.rejects(create(ctx, {...args,builtinId:'private-id'}), /Template not found/); + await assert.rejects(create(ctx, {...args,expectedOwnerDid:STRANGER}), /Account changed/); + await assert.rejects(create(ctx, {...args,assetDid:'did:cel:old'}), /Asset already used/); + await assert.rejects(create(ctx, {...args,authToken:undefined}), /auth|sign|session/i); + assert.equal(ctx.rows.lists.length, 1); +}); +test('item failure rejects the whole mutation; it never reports list creation success', async () => { + const ctx = makeCtx({failItem:true}); + // The handler fixture intentionally is not a database emulator. Convex supplies + // rollback in production; here verify that an insert error escapes the transaction. + await assert.rejects(create(ctx,args), /Injected insert failure/); +}); +test('same asset cannot cross template or account boundaries', async () => { + const ctx = makeCtx(); + await create(ctx,args); + await assert.rejects(create(ctx,{...args,builtinId:'ci-triage'}), /Asset already used/); + await assert.rejects(create(ctx,{...args,authToken:strangerSession.authToken,expectedOwnerDid:STRANGER}), /Asset already used/); +}); + +test('first template grants referral benefits atomically and retries do not renew them',async()=>{ + const ctx=makeCtx(); + ctx.rows.lists.length=0; + ctx.rows.referrals.push({_id:'referral',refereeId:ownerSession.user._id,referrerId:strangerSession.user._id}); + // The fixture map predates adding the referral; route its patches to the row. + const patch=ctx.db.patch; + ctx.db.patch=async(id,fields)=>id==='referral'?Object.assign(ctx.rows.referrals[0],fields):patch(id,fields); + await create(ctx,args); + assert.ok(ctx.rows.users[0].referralProUntil > Date.now()); + assert.equal(ctx.rows.users[0].referralProUntil,ctx.rows.users[1].referralProUntil); + const granted=ctx.rows.referrals[0].proGrantedAt; + await create(ctx,args); + assert.equal(ctx.rows.referrals[0].proGrantedAt,granted); +}); +test('private saved templates remain owner-only under the optional source validator',async()=>{ + const ctx=makeCtx(); + const id=await ctx.db.insert('listTemplates',{name:'Private',ownerDid:OWNER,isPublic:false,items:[]}); + await assert.rejects(create(ctx,{...args,builtinId:undefined,templateId:id,authToken:strangerSession.authToken,expectedOwnerDid:STRANGER}),/Not authorized/); + await assert.rejects(create(ctx,{...args,templateId:id}),/exactly one/); + await assert.rejects(create(ctx,{...args,builtinId:undefined}),/exactly one/); + assert.equal(ctx.rows.lists.length,1); +}); diff --git a/server.ts b/server.ts index c6231cd9..6d10b0a8 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { join, normalize } from "node:path"; import { anonDistinctId, captureEvent } from "./convex/lib/analytics"; @@ -49,6 +49,7 @@ function contentTypeFor(pathname: string): string { if (pathname.endsWith(".js")) return "application/javascript; charset=utf-8"; if (pathname.endsWith(".css")) return "text/css; charset=utf-8"; if (pathname.endsWith(".json")) return "application/json; charset=utf-8"; + if (pathname.endsWith(".xml")) return "application/xml; charset=utf-8"; if (pathname.endsWith(".png")) return "image/png"; if (pathname.endsWith(".svg")) return "image/svg+xml"; if (pathname.endsWith(".ico")) return "image/x-icon"; @@ -66,6 +67,8 @@ async function serveApp(request: Request): Promise { const url = new URL(request.url); let filePath = safeDistPath(url.pathname); + if (existsSync(filePath) && statSync(filePath).isDirectory()) filePath = join(filePath, "index.html"); + if (!existsSync(filePath)) { filePath = join(distDir, "index.html"); } diff --git a/shared/templates.ts b/shared/templates.ts new file mode 100644 index 00000000..549c8a97 --- /dev/null +++ b/shared/templates.ts @@ -0,0 +1,604 @@ +/** + * Built-in list templates that are always available. + * Users can also create their own templates from existing lists. + */ + +export interface BuiltinTemplateItem { + name: string; + description?: string; + priority?: "high" | "medium" | "low"; + order: number; +} + +export interface BuiltinTemplate { + id: string; + name: string; + emoji: string; + description: string; + items: BuiltinTemplateItem[]; + useCase?: string; + outcome?: string; +} + +export const BUILTIN_TEMPLATES: BuiltinTemplate[] = [ + { + id: "grocery", + name: "Grocery List", + emoji: "🛒", + description: "Common grocery items to get you started", + items: [ + { name: "Milk", order: 0 }, + { name: "Eggs", order: 1 }, + { name: "Bread", order: 2 }, + { name: "Butter", order: 3 }, + { name: "Cheese", order: 4 }, + { name: "Fruits", order: 5 }, + { name: "Vegetables", order: 6 }, + { name: "Chicken/Meat", order: 7 }, + { name: "Rice/Pasta", order: 8 }, + { name: "Snacks", order: 9 }, + ], + }, + { + id: "packing", + name: "Packing Checklist", + emoji: "🧳", + description: "Essential items for your next trip", + items: [ + { name: "Passport/ID", priority: "high", order: 0 }, + { name: "Phone charger", priority: "high", order: 1 }, + { name: "Toiletries", order: 2 }, + { name: "Medications", priority: "high", order: 3 }, + { name: "Clothes (underwear, socks)", order: 4 }, + { name: "Outerwear/jacket", order: 5 }, + { name: "Shoes", order: 6 }, + { name: "Laptop/tablet", order: 7 }, + { name: "Headphones", order: 8 }, + { name: "Sunglasses", order: 9 }, + { name: "Wallet/cards", priority: "high", order: 10 }, + { name: "Travel pillow", order: 11 }, + ], + }, + { + id: "weekly-tasks", + name: "Weekly Tasks", + emoji: "📅", + description: "Common weekly chores and activities", + items: [ + { name: "Grocery shopping", order: 0 }, + { name: "Laundry", order: 1 }, + { name: "Clean bathroom", order: 2 }, + { name: "Vacuum/mop floors", order: 3 }, + { name: "Take out trash", order: 4 }, + { name: "Meal prep", order: 5 }, + { name: "Exercise (3x)", priority: "medium", order: 6 }, + { name: "Review budget", order: 7 }, + { name: "Call family/friends", order: 8 }, + ], + }, + { + id: "project-kickoff", + name: "Project Kickoff", + emoji: "🚀", + description: "Get your new project started right", + items: [ + { name: "Define project goals", priority: "high", order: 0 }, + { name: "Identify stakeholders", priority: "high", order: 1 }, + { name: "Set timeline/milestones", priority: "high", order: 2 }, + { name: "Create project repo/workspace", order: 3 }, + { name: "Write initial documentation", order: 4 }, + { name: "Set up communication channel", order: 5 }, + { name: "Schedule kickoff meeting", order: 6 }, + { name: "Assign initial tasks", order: 7 }, + { name: "Define success metrics", priority: "medium", order: 8 }, + ], + }, + { + id: "moving", + name: "Moving Checklist", + emoji: "📦", + description: "Everything you need for a smooth move", + items: [ + { name: "Update address (mail, subscriptions)", priority: "high", order: 0 }, + { name: "Transfer utilities", priority: "high", order: 1 }, + { name: "Pack room by room", order: 2 }, + { name: "Label all boxes", order: 3 }, + { name: "Hire movers or rent truck", priority: "high", order: 4 }, + { name: "Clean old place", order: 5 }, + { name: "Get new keys", priority: "high", order: 6 }, + { name: "Set up internet/cable", order: 7 }, + { name: "Update driver's license", order: 8 }, + { name: "Notify bank/employers", order: 9 }, + ], + }, + { + id: "party-planning", + name: "Party Planning", + emoji: "🎉", + description: "Plan the perfect celebration", + items: [ + { name: "Set date and time", priority: "high", order: 0 }, + { name: "Create guest list", priority: "high", order: 1 }, + { name: "Send invitations", order: 2 }, + { name: "Plan menu/food", order: 3 }, + { name: "Order cake", order: 4 }, + { name: "Buy drinks", order: 5 }, + { name: "Get decorations", order: 6 }, + { name: "Create playlist", order: 7 }, + { name: "Plan activities/games", order: 8 }, + { name: "Set up day before", order: 9 }, + ], + }, +]; + +// Editorial, version-controlled catalog only. Never load private user templates here. +export const AGENT_RUNBOOKS: BuiltinTemplate[] = [ + { + "id": "release-checklist", + "name": "Release checklist", + "emoji": "🚀", + "description": "Ship a reviewed release with an agent preparing evidence and a human approving production changes.", + "useCase": "For maintainers coordinating a release across code review, CI, rollout, and rollback.", + "outcome": "A release decision with linked checks, a rollback owner, and recorded approval.", + "items": [ + { + "name": "Define the release boundary", + "description": "Human: record the version, included PRs, release owner, maintenance window, and explicit out-of-scope changes.", + "order": 0, + "priority": "high" + }, + { + "name": "Collect CI evidence", + "description": "Agent: run the required checks on the exact release commit; attach commands, results, and failing logs. Stop on unexplained failures.", + "order": 1, + "priority": "medium" + }, + { + "name": "Review migration and rollback", + "description": "Agent: identify schema/config changes and draft reversible rollout steps. Human: approve migration safety and name the rollback owner.", + "order": 2, + "priority": "medium" + }, + { + "name": "Draft release notes", + "description": "Agent: summarize user-visible changes, known limitations, and upgrade steps; link every claim to a merged change.", + "order": 3, + "priority": "medium" + }, + { + "name": "Approve the rollout", + "description": "Human: review the evidence and explicitly authorize deployment. A completed checklist does not itself authorize production access.", + "order": 4, + "priority": "high" + }, + { + "name": "Verify and hand off", + "description": "After an authorized rollout, record smoke checks, monitoring links, final version, and any follow-up issues.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "research-pipeline", + "name": "Research pipeline", + "emoji": "🔎", + "description": "Turn a research question into a sourced decision brief an agent and reviewer can trust.", + "useCase": "For teams comparing technical approaches or investigating an unfamiliar domain.", + "outcome": "A concise recommendation with traceable sources, uncertainty, and a human review.", + "items": [ + { + "name": "Frame the question", + "description": "Human: define the decision, audience, deadline, and what evidence would change the recommendation.", + "order": 0, + "priority": "high" + }, + { + "name": "Build a source plan", + "description": "Agent: list primary sources and search terms; record inclusion criteria and freshness requirements.", + "order": 1, + "priority": "medium" + }, + { + "name": "Gather evidence", + "description": "Agent: capture source URLs, publication dates, and short evidence notes. Separate observations from inference.", + "order": 2, + "priority": "medium" + }, + { + "name": "Challenge the findings", + "description": "Agent: seek counterexamples and contradictory sources; flag gaps and unsupported claims.", + "order": 3, + "priority": "medium" + }, + { + "name": "Draft the decision brief", + "description": "Agent: compare options against the agreed criteria, cite claims, and describe confidence and tradeoffs.", + "order": 4, + "priority": "high" + }, + { + "name": "Review and archive", + "description": "Human: verify key citations and approve the recommendation; save the brief and unresolved questions.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "on-call-handoff", + "name": "On-call handoff", + "emoji": "📟", + "description": "Give the next responder a clear picture of active incidents, mitigations, and escalation paths.", + "useCase": "For rotating on-call teams that need continuity without repeating an investigation.", + "outcome": "An acknowledged handoff with current risk, owners, and next checks.", + "items": [ + { + "name": "Set the handoff window", + "description": "Human: record outgoing and incoming responders, timezone, shift window, and escalation contact.", + "order": 0, + "priority": "high" + }, + { + "name": "Summarize active incidents", + "description": "Agent: link incident records and timelines; state impact, current status, and last verified observations.", + "order": 1, + "priority": "medium" + }, + { + "name": "Document mitigations", + "description": "Agent: list actions already taken, their observed effects, and any expiry or rollback conditions. Do not execute new changes.", + "order": 2, + "priority": "medium" + }, + { + "name": "Queue follow-up checks", + "description": "Agent: create timed checks with dashboard links, expected signals, and thresholds requiring escalation.", + "order": 3, + "priority": "medium" + }, + { + "name": "Review access and coverage", + "description": "Human: verify the incoming responder can reach the required dashboards and escalation channels; never paste secrets.", + "order": 4, + "priority": "high" + }, + { + "name": "Acknowledge the handoff", + "description": "Incoming responder: confirm ownership and next check time; outgoing responder resolves outstanding context questions.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "ci-triage", + "name": "CI triage queue", + "emoji": "🧪", + "description": "Turn a failed build into a reproducible diagnosis and a focused fix proposal.", + "useCase": "For maintainers handling flaky tests, broken builds, and blocked pull requests.", + "outcome": "A classified failure with reproduction evidence and a reviewed remediation.", + "items": [ + { + "name": "Capture the failure", + "description": "Agent: record the failing job URL, commit SHA, environment, and first meaningful error; redact tokens from logs.", + "order": 0, + "priority": "high" + }, + { + "name": "Classify the cause", + "description": "Agent: distinguish code regressions, flaky tests, infrastructure, and configuration problems using prior runs.", + "order": 1, + "priority": "medium" + }, + { + "name": "Reproduce in isolation", + "description": "Agent: run the smallest relevant check on the failing commit; record exact commands and outcomes.", + "order": 2, + "priority": "medium" + }, + { + "name": "Propose a minimal fix", + "description": "Agent: explain the root cause, affected behavior, and a targeted regression check. Avoid disabling a required gate.", + "order": 3, + "priority": "medium" + }, + { + "name": "Validate the change", + "description": "Agent: rerun the focused check and affected suite; report unrelated failures separately.", + "order": 4, + "priority": "high" + }, + { + "name": "Review and resolve", + "description": "Human: review the diff and evidence, approve merge separately, and link the final CI result.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "content-production", + "name": "Content production pipeline", + "emoji": "✍️", + "description": "Move a brief through research, drafting, fact-checking, and editorial approval.", + "useCase": "For content teams using agents to draft while retaining editorial accountability.", + "outcome": "A reviewed draft with verified claims, accessible assets, and a publication owner.", + "items": [ + { + "name": "Approve the brief", + "description": "Human: define audience, angle, channel, length, deadline, and the desired reader action.", + "order": 0, + "priority": "high" + }, + { + "name": "Collect references", + "description": "Agent: gather primary sources, customer-approved examples, and brand constraints; record citation links.", + "order": 1, + "priority": "medium" + }, + { + "name": "Draft the narrative", + "description": "Agent: write an outline and draft around the brief; mark assumptions and missing information explicitly.", + "order": 2, + "priority": "medium" + }, + { + "name": "Check claims and rights", + "description": "Agent: verify factual statements, attribution, asset licenses, and sensitive material; flag unresolved concerns.", + "order": 3, + "priority": "medium" + }, + { + "name": "Edit for the reader", + "description": "Human: review accuracy, voice, accessibility, and usefulness; request revisions before approval.", + "order": 4, + "priority": "high" + }, + { + "name": "Prepare publication handoff", + "description": "Agent: package approved copy, metadata, and assets. Human: authorize and schedule publication separately.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "audit-preparation", + "name": "Compliance audit preparation", + "emoji": "📋", + "description": "Organize audit evidence with clear control owners and documented gaps.", + "useCase": "For teams collecting evidence against a defined audit scope; this runbook does not certify compliance.", + "outcome": "An evidence register and reviewer-approved gap list with responsible owners.", + "items": [ + { + "name": "Confirm audit scope", + "description": "Human: identify the framework, period, systems, reviewer, and approved evidence storage location.", + "order": 0, + "priority": "high" + }, + { + "name": "Map controls to owners", + "description": "Agent: build a control-to-owner register with required evidence and due dates; flag unassigned controls.", + "order": 1, + "priority": "medium" + }, + { + "name": "Collect approved evidence", + "description": "Agent: link authorized records with dates and provenance. Do not copy credentials or private customer data into tasks.", + "order": 2, + "priority": "medium" + }, + { + "name": "Assess evidence gaps", + "description": "Agent: compare evidence against the stated requirements and period; distinguish missing evidence from failed controls.", + "order": 3, + "priority": "medium" + }, + { + "name": "Plan remediation", + "description": "Human: prioritize gaps and assign owners; approve any system changes through the normal change process.", + "order": 4, + "priority": "high" + }, + { + "name": "Review the evidence package", + "description": "Human: validate completeness and access permissions before sharing; record reviewer questions and next actions.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "incident-review", + "name": "Incident review", + "emoji": "🧯", + "description": "Reconstruct an incident and turn lessons into verifiable follow-up work.", + "useCase": "For teams conducting a blameless review after service recovery.", + "outcome": "A sourced timeline and prioritized corrective actions with owners and success checks.", + "items": [ + { + "name": "Define impact and scope", + "description": "Human: record the incident window, affected users, severity, and review facilitator.", + "order": 0, + "priority": "high" + }, + { + "name": "Reconstruct the timeline", + "description": "Agent: correlate approved logs, alerts, and change records with timezone-normalized timestamps and links.", + "order": 1, + "priority": "medium" + }, + { + "name": "Identify contributing factors", + "description": "Agent: distinguish observed causes from hypotheses; describe detection and recovery delays without assigning blame.", + "order": 2, + "priority": "medium" + }, + { + "name": "Draft corrective actions", + "description": "Agent: propose bounded tasks with owners, due dates, and measurable verification steps.", + "order": 3, + "priority": "medium" + }, + { + "name": "Review with responders", + "description": "Human: check the timeline and conclusions with participants; record disputed or unknown points.", + "order": 4, + "priority": "high" + }, + { + "name": "Publish the approved review", + "description": "Human: approve the audience and redactions; assign follow-up tracking and a date to verify improvements.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "dependency-upgrade", + "name": "Dependency upgrade", + "emoji": "📦", + "description": "Assess an upgrade, test compatibility, and prepare a small reviewable change.", + "useCase": "For maintainers keeping dependencies current without losing track of breaking changes.", + "outcome": "A tested upgrade proposal with compatibility evidence and rollback instructions.", + "items": [ + { + "name": "Select the upgrade target", + "description": "Human: specify package, current and target versions, reason, and supported runtimes.", + "order": 0, + "priority": "high" + }, + { + "name": "Review upstream changes", + "description": "Agent: read official release notes and migration guidance; list breaking changes and security advisories with links.", + "order": 1, + "priority": "medium" + }, + { + "name": "Update in isolation", + "description": "Agent: adjust the dependency and lockfile using repository tooling; avoid unrelated upgrades.", + "order": 2, + "priority": "medium" + }, + { + "name": "Exercise compatibility", + "description": "Agent: run relevant tests and builds, including affected runtime platforms; capture errors and fixes.", + "order": 3, + "priority": "medium" + }, + { + "name": "Document rollout and rollback", + "description": "Agent: describe config or data changes, known limitations, and how to restore the prior version.", + "order": 4, + "priority": "high" + }, + { + "name": "Request review", + "description": "Human: assess the diff and validation evidence; approve merge and rollout through their separate gates.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "customer-feedback-triage", + "name": "Customer feedback triage", + "emoji": "💬", + "description": "Convert authorized feedback into deduplicated, actionable product questions.", + "useCase": "For product teams sorting support themes while protecting customer information.", + "outcome": "A prioritized theme list with evidence, owners, and response drafts awaiting approval.", + "items": [ + { + "name": "Define the intake window", + "description": "Human: choose approved feedback sources, reporting period, audience, and privacy rules.", + "order": 0, + "priority": "high" + }, + { + "name": "Collect and redact", + "description": "Agent: summarize authorized reports with internal references; remove personal details not needed for triage.", + "order": 1, + "priority": "medium" + }, + { + "name": "Group related reports", + "description": "Agent: deduplicate issues by user need and observable behavior; preserve differing contexts and counterexamples.", + "order": 2, + "priority": "medium" + }, + { + "name": "Assess impact", + "description": "Agent: estimate frequency and severity from evidence; label uncertain estimates and avoid inventing customer counts.", + "order": 3, + "priority": "medium" + }, + { + "name": "Route the next action", + "description": "Human: choose investigation, bug fix, documentation, or no action; assign an owner and due date.", + "order": 4, + "priority": "high" + }, + { + "name": "Prepare response drafts", + "description": "Agent: draft accurate responses without promises of unapproved work. Human: approve any external communication.", + "order": 5, + "priority": "medium" + } + ] + }, + { + "id": "agent-task-handoff", + "name": "Agent task handoff", + "emoji": "🤝", + "description": "Pass a bounded task between an agent and a human with enough context to continue safely.", + "useCase": "For teams delegating implementation or investigation across sessions and contributors.", + "outcome": "A reproducible handoff naming completed work, open blockers, and the next authorized action.", + "items": [ + { + "name": "State the objective and authority", + "description": "Human: define acceptance criteria, owned files or resources, allowed actions, and approval boundaries.", + "order": 0, + "priority": "high" + }, + { + "name": "Record the starting state", + "description": "Agent: capture branch or workspace, relevant docs, existing changes, and known environment constraints.", + "order": 1, + "priority": "medium" + }, + { + "name": "Execute the bounded task", + "description": "Agent: work within the agreed scope; record material decisions and stop at missing authority or conflicting work.", + "order": 2, + "priority": "medium" + }, + { + "name": "Verify the deliverable", + "description": "Agent: run focused checks against acceptance criteria; distinguish demonstrated behavior from untested assumptions.", + "order": 3, + "priority": "medium" + }, + { + "name": "Prepare the handoff", + "description": "Agent: list changed files, evidence, remaining risks, reproduction steps, and the next concrete action.", + "order": 4, + "priority": "high" + }, + { + "name": "Accept ownership", + "description": "Human or next agent: review the evidence, acknowledge blockers, and confirm the remaining authorized scope.", + "order": 5, + "priority": "medium" + } + ] + } +]; +BUILTIN_TEMPLATES.push(...AGENT_RUNBOOKS); +export function getRunbook(slug: string | null | undefined) { + return AGENT_RUNBOOKS.find(template => template.id === slug); +} +export function templateLoginDestination(search: string): string { + const template = getRunbook(new URLSearchParams(search).get('template')); + return template ? `/templates/${template.id}/use` : '/d'; +} diff --git a/src/App.tsx b/src/App.tsx index 69df2549..5b107f91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { CookieConsent } from './components/CookieConsent' import { useSwipeBack } from './hooks/useSwipeBack' import { useNativeLinks } from './hooks/useNativeLinks' import { initPushNotifications } from './lib/pushNotifications' +import { templateLoginDestination } from '../shared/templates' import { incrementMetric } from './lib/observability' // Lazy-loaded routes for better code splitting @@ -34,6 +35,7 @@ const PublicList = lazy(() => import('./pages/PublicList').then(m => ({ default: const SharedListResource = lazy(() => import('./components/SharedListResource').then(m => ({ default: m.SharedListResource }))) const Profile = lazy(() => import('./pages/Profile').then(m => ({ default: m.Profile }))) const Explorer = lazy(() => import('./pages/Explorer').then(m => ({ default: m.Explorer }))) +const TemplateGallery = lazy(() => import('./pages/TemplateGallery').then(m => ({ default: m.TemplateGallery }))) const Templates = lazy(() => import('./pages/Templates').then(m => ({ default: m.Templates }))) const PriorityFocus = lazy(() => import('./pages/PriorityFocus').then(m => ({ default: m.PriorityFocus }))) const Pricing = lazy(() => import('./pages/Pricing').then(m => ({ default: m.Pricing }))) @@ -251,6 +253,8 @@ function App() { {/* Static marketing/docs pages — no user data, reachable without app unlock */} : } /> + } /> + } /> } /> } /> } /> @@ -274,11 +278,12 @@ function App() { > {/* Public routes - accessible without authentication */} } /> - : } /> + : } /> } /> } /> } /> + } /> } /> : } /> : } /> @@ -289,7 +294,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/AppLockGuard.tsx b/src/components/AppLockGuard.tsx index 56e7872e..46cf2282 100644 --- a/src/components/AppLockGuard.tsx +++ b/src/components/AppLockGuard.tsx @@ -12,6 +12,8 @@ interface AppLockGuardProps { } export function AppLockGuard({ children }: AppLockGuardProps) { + const [settingsReady, setSettingsReady] = useState(false); + const [settingsError, setSettingsError] = useState(false); const [isLocked, setIsLocked] = useState(true); const [isAuthenticating, setIsAuthenticating] = useState(false); const [biometricLockEnabled, setBiometricLockEnabled] = useState(false); @@ -19,6 +21,7 @@ export function AppLockGuard({ children }: AppLockGuardProps) { useEffect(() => { getBiometricLockEnabled().then((lockEnabled) => { setBiometricLockEnabled(lockEnabled); + setSettingsReady(true); if (lockEnabled) { // Attempt authentication on mount authenticate(); @@ -26,7 +29,7 @@ export function AppLockGuard({ children }: AppLockGuardProps) { // If lock is not enabled, unlock immediately setIsLocked(false); } - }); + }).catch(() => setSettingsError(true)); }, []); const authenticate = async () => { @@ -43,6 +46,12 @@ export function AppLockGuard({ children }: AppLockGuardProps) { } }; + // Do not mount children (including mutations in effects) before the async + // preference is known. A read failure must never silently bypass the lock. + if (!settingsReady) return
    + {settingsError ? 'Unable to read app lock settings. Reload to try again.' : 'Checking app lock…'} +
    ; + // If lock is not enabled or already unlocked, render children if (!biometricLockEnabled || !isLocked) { return <>{children}; diff --git a/src/components/TemplatePickerModal.tsx b/src/components/TemplatePickerModal.tsx index a7a047fe..ccbd98f3 100644 --- a/src/components/TemplatePickerModal.tsx +++ b/src/components/TemplatePickerModal.tsx @@ -11,6 +11,8 @@ import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; +import { trackTemplate } from "../lib/analytics"; +import { getRunbook } from "../../shared/templates"; import { createListAsset } from "../lib/originals"; import { listCreationErrorMessage } from "../lib/planLimit"; import { BUILTIN_TEMPLATES, type BuiltinTemplate } from "../lib/builtinTemplates"; @@ -38,8 +40,6 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo ); // Mutations - const createList = useMutation(api.lists.createList); - const addItem = useMutation(api.items.addItem); const createListFromTemplate = useMutation(api.templates.createListFromTemplate); const handleSelectBuiltin = (template: BuiltinTemplate) => { @@ -59,25 +59,15 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo try { // Create the list const listAsset = await createListAsset(listName, did); - const listId = await createList({ + if (getRunbook(selectedTemplate.id)) trackTemplate("template_use_clicked", selectedTemplate.id); + const listId = await createListFromTemplate({ + builtinId: selectedTemplate.id, assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, - name: listName, - createdAt: Date.now(), + listName: listName, }); - // Add items from template - const now = Date.now(); - for (const item of selectedTemplate.items) { - await addItem({ - listId, - name: item.name, - createdAt: now, - priority: item.priority, - description: item.description, - }); - } - + if (getRunbook(selectedTemplate.id)) trackTemplate("template_activated", selectedTemplate.id, listId); haptic('success'); navigate(`/list/${listId}`); } catch (err) { diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index ea158ef1..70f59e0a 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -126,3 +126,8 @@ export function trackUpgradeClicked(plan: string, source: string): void { export function trackUpgradeCompleted(plan: string): void { capture('upgrade_completed', { plan }); } + +/** Public runbook funnel; only catalog IDs, never task text or user template names. */ +export function trackTemplate(event: 'template_viewed' | 'template_use_clicked' | 'template_activated', templateId: string, listId?: string): void { + capture(event, { template_id: templateId, ...(listId ? { list_id: listId, $insert_id: `template_activated:${listId}` } : {}) }); +} diff --git a/src/lib/builtinTemplates.ts b/src/lib/builtinTemplates.ts index 8beec81b..5f69ca00 100644 --- a/src/lib/builtinTemplates.ts +++ b/src/lib/builtinTemplates.ts @@ -1,130 +1,2 @@ -/** - * Built-in list templates that are always available. - * Users can also create their own templates from existing lists. - */ - -export interface BuiltinTemplateItem { - name: string; - description?: string; - priority?: "high" | "medium" | "low"; - order: number; -} - -export interface BuiltinTemplate { - id: string; - name: string; - emoji: string; - description: string; - items: BuiltinTemplateItem[]; -} - -export const BUILTIN_TEMPLATES: BuiltinTemplate[] = [ - { - id: "grocery", - name: "Grocery List", - emoji: "🛒", - description: "Common grocery items to get you started", - items: [ - { name: "Milk", order: 0 }, - { name: "Eggs", order: 1 }, - { name: "Bread", order: 2 }, - { name: "Butter", order: 3 }, - { name: "Cheese", order: 4 }, - { name: "Fruits", order: 5 }, - { name: "Vegetables", order: 6 }, - { name: "Chicken/Meat", order: 7 }, - { name: "Rice/Pasta", order: 8 }, - { name: "Snacks", order: 9 }, - ], - }, - { - id: "packing", - name: "Packing Checklist", - emoji: "🧳", - description: "Essential items for your next trip", - items: [ - { name: "Passport/ID", priority: "high", order: 0 }, - { name: "Phone charger", priority: "high", order: 1 }, - { name: "Toiletries", order: 2 }, - { name: "Medications", priority: "high", order: 3 }, - { name: "Clothes (underwear, socks)", order: 4 }, - { name: "Outerwear/jacket", order: 5 }, - { name: "Shoes", order: 6 }, - { name: "Laptop/tablet", order: 7 }, - { name: "Headphones", order: 8 }, - { name: "Sunglasses", order: 9 }, - { name: "Wallet/cards", priority: "high", order: 10 }, - { name: "Travel pillow", order: 11 }, - ], - }, - { - id: "weekly-tasks", - name: "Weekly Tasks", - emoji: "📅", - description: "Common weekly chores and activities", - items: [ - { name: "Grocery shopping", order: 0 }, - { name: "Laundry", order: 1 }, - { name: "Clean bathroom", order: 2 }, - { name: "Vacuum/mop floors", order: 3 }, - { name: "Take out trash", order: 4 }, - { name: "Meal prep", order: 5 }, - { name: "Exercise (3x)", priority: "medium", order: 6 }, - { name: "Review budget", order: 7 }, - { name: "Call family/friends", order: 8 }, - ], - }, - { - id: "project-kickoff", - name: "Project Kickoff", - emoji: "🚀", - description: "Get your new project started right", - items: [ - { name: "Define project goals", priority: "high", order: 0 }, - { name: "Identify stakeholders", priority: "high", order: 1 }, - { name: "Set timeline/milestones", priority: "high", order: 2 }, - { name: "Create project repo/workspace", order: 3 }, - { name: "Write initial documentation", order: 4 }, - { name: "Set up communication channel", order: 5 }, - { name: "Schedule kickoff meeting", order: 6 }, - { name: "Assign initial tasks", order: 7 }, - { name: "Define success metrics", priority: "medium", order: 8 }, - ], - }, - { - id: "moving", - name: "Moving Checklist", - emoji: "📦", - description: "Everything you need for a smooth move", - items: [ - { name: "Update address (mail, subscriptions)", priority: "high", order: 0 }, - { name: "Transfer utilities", priority: "high", order: 1 }, - { name: "Pack room by room", order: 2 }, - { name: "Label all boxes", order: 3 }, - { name: "Hire movers or rent truck", priority: "high", order: 4 }, - { name: "Clean old place", order: 5 }, - { name: "Get new keys", priority: "high", order: 6 }, - { name: "Set up internet/cable", order: 7 }, - { name: "Update driver's license", order: 8 }, - { name: "Notify bank/employers", order: 9 }, - ], - }, - { - id: "party-planning", - name: "Party Planning", - emoji: "🎉", - description: "Plan the perfect celebration", - items: [ - { name: "Set date and time", priority: "high", order: 0 }, - { name: "Create guest list", priority: "high", order: 1 }, - { name: "Send invitations", order: 2 }, - { name: "Plan menu/food", order: 3 }, - { name: "Order cake", order: 4 }, - { name: "Buy drinks", order: 5 }, - { name: "Get decorations", order: 6 }, - { name: "Create playlist", order: 7 }, - { name: "Plan activities/games", order: 8 }, - { name: "Set up day before", order: 9 }, - ], - }, -]; +export { BUILTIN_TEMPLATES, AGENT_RUNBOOKS, getRunbook } from '../../shared/templates'; +export type { BuiltinTemplate, BuiltinTemplateItem } from '../../shared/templates'; diff --git a/src/lib/templateActivation.ts b/src/lib/templateActivation.ts new file mode 100644 index 00000000..76da3a8a --- /dev/null +++ b/src/lib/templateActivation.ts @@ -0,0 +1,29 @@ +import { createListAsset } from './originals'; + +// Scoped to an authenticated account and runbook. Persist the asset before sending +// the mutation so a refresh after a lost response can safely retry the same write. +const inFlight = new Map>(); +export async function templateAsset(did: string, slug: string, name: string, fresh = false) { + const key = `boop:template-attempt:${did}:${slug}`; + const saved = sessionStorage.getItem(key); + if (saved) { + try { + const value = JSON.parse(saved); + if (!(fresh && value.completed) && typeof value.assetDid === 'string' && typeof value.envelope === 'string') return value as Awaited>; + } catch { /* Replace corrupt local state; never interpret it as a redirect. */ } + } + let pending = inFlight.get(key); + if (!pending) { + pending = createListAsset(name, did).then(asset => { + sessionStorage.setItem(key, JSON.stringify(asset)); + return asset; + }).finally(() => inFlight.delete(key)); + inFlight.set(key, pending); + } + return pending; +} +export function finishTemplateAttempt(did: string, slug: string) { + const key = `boop:template-attempt:${did}:${slug}`; + const saved = sessionStorage.getItem(key); + if (saved) sessionStorage.setItem(key, JSON.stringify({ ...JSON.parse(saved), completed: true })); +} diff --git a/src/lib/templateMetadata.ts b/src/lib/templateMetadata.ts new file mode 100644 index 00000000..fac0fc8e --- /dev/null +++ b/src/lib/templateMetadata.ts @@ -0,0 +1,38 @@ +import type { BuiltinTemplate } from "../../shared/templates"; + +/** Restore the app shell on exit even when entry HTML was a prerendered runbook. */ +export function applyTemplateMetadata(template?: BuiltinTemplate, unknownSlug = false) { + const shellData = document.getElementById('boop-shell-metadata')?.textContent; + const shell = shellData ? JSON.parse(shellData) as { title: string; canonical: string | null; meta: Record } : null; + const previousTitle = shell?.title ?? document.title; + document.title = `${template?.name ?? (unknownSlug ? 'Template not found' : 'Agent runbook templates')} | boop`; + const metadata = [ + ['name', 'description', template?.description ?? 'Ten practical agent runbooks for work you can review.'], + ['property', 'og:title', document.title], + ['property', 'og:description', template?.description ?? 'Ten practical agent runbooks for work you can review.'], + ['property', 'og:url', `https://boop.ad/templates${template ? `/${template.id}` : ''}`], + ['name', 'twitter:title', document.title], + ['name', 'twitter:description', template?.description ?? 'Ten practical agent runbooks for work you can review.'], + ['name', 'robots', unknownSlug ? 'noindex,follow' : 'index,follow'], + ].map(([attribute, key, content]) => { + const existing = document.querySelector(`meta[${attribute}="${key}"]`); + const node = existing ?? document.createElement('meta'); + const previous = shell ? shell.meta[key] : node.content; + node.setAttribute(attribute, key); + node.content = content; + if (!existing) document.head.appendChild(node); + return () => { if (existing && previous !== undefined) node.content = previous; else node.remove(); }; + }); + const existingCanonical = document.querySelector('link[rel="canonical"]'); + const previousCanonical = shell ? shell.canonical : existingCanonical?.href; + const canonical = existingCanonical ?? document.createElement('link'); + canonical.rel = 'canonical'; + canonical.href = `https://boop.ad/templates${template ? `/${template.id}` : ''}`; + document.head.appendChild(canonical); + return () => { + document.title = previousTitle; + metadata.forEach(restore => restore()); + if (existingCanonical && previousCanonical) canonical.href = previousCanonical; + else canonical.remove(); + }; +} diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index a6b17abb..5de23659 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -363,6 +363,7 @@ export function Landing() {
  • Verified sharing
  • Priority sync
  • + Agent runbooks Go Pro diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 85f3182e..675abe35 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -9,11 +9,13 @@ */ import { useState, type FormEvent } from "react"; -import { useNavigate, Link } from "react-router-dom"; +import { useNavigate, useLocation, Link } from "react-router-dom"; import { useAuth } from "../hooks/useAuth"; import { OtpInput } from "../components/auth/OtpInput"; import { trackSignupStarted, trackSignupCompleted } from "../lib/analytics"; +import { templateLoginDestination } from "../../shared/templates"; + type LoginStep = "email" | "otp"; interface LoginProps { @@ -33,6 +35,7 @@ function getGreeting(): string { export function Login({ embedded = false }: LoginProps) { const { startOtp, verifyOtp, isLoading, isAuthenticated } = useAuth(); const navigate = useNavigate(); + const location = useLocation(); const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); @@ -41,7 +44,7 @@ export function Login({ embedded = false }: LoginProps) { // Redirect if already authenticated (unless embedded) if (isAuthenticated && !embedded) { - navigate("/d", { replace: true }); + navigate(templateLoginDestination(location.search), { replace: true }); return null; } @@ -87,7 +90,7 @@ export function Login({ embedded = false }: LoginProps) { // On successful verification, useAuth will update isAuthenticated // If not embedded, redirect to app; if embedded, parent handles navigation if (!embedded) { - navigate("/d", { replace: true }); + navigate(templateLoginDestination(location.search), { replace: true }); } } catch (err) { console.error("Failed to verify OTP:", err); diff --git a/src/pages/TemplateGallery.tsx b/src/pages/TemplateGallery.tsx new file mode 100644 index 00000000..e5de0911 --- /dev/null +++ b/src/pages/TemplateGallery.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { AGENT_RUNBOOKS, getRunbook } from '../../shared/templates'; +import { useCurrentUser } from '../hooks/useCurrentUser'; +import { useMutation, useQuery } from '../lib/authenticatedConvex'; +import { api } from '../../convex/_generated/api'; +import { templateAsset, finishTemplateAttempt } from '../lib/templateActivation'; +import { trackTemplate } from '../lib/analytics'; +import { applyTemplateMetadata } from '../lib/templateMetadata'; +import { REFERRAL_CODE_KEY } from './InviteLanding'; +import { listCreationErrorMessage } from '../lib/planLimit'; +import './ApiQuickstart.css'; + +export function TemplateGallery({ activation = false }: { activation?: boolean }) { + const { slug } = useParams(); + const template = getRunbook(slug); + const { did, subOrgId, isLoading } = useCurrentUser(); + const [search] = useSearchParams(); + const navigate = useNavigate(); + const create = useMutation(api.templates.createListFromTemplate); + const redeemReferral = useMutation(api.referrals.redeemReferral); + const accountRecord = useQuery(api.auth.getUserByTurnkeyId, + activation && subOrgId ? { turnkeySubOrgId: subOrgId } : 'skip'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const started = useRef(false); + const autoAttempted = useRef(false); + const currentDid = useRef(did); + currentDid.current = did; + useEffect(() => { + currentDid.current = did; + return () => { currentDid.current = null; }; + }, [did]); + + useEffect(() => { + document.body.classList.add('scrollable-page'); + const restoreMetadata = applyTemplateMetadata(template, !!slug && !template); + if (template && !activation) trackTemplate('template_viewed', template.id); + return () => { + document.body.classList.remove('scrollable-page'); + restoreMetadata(); + }; + }, [template, slug, activation]); + + async function activate(automatic = false) { + if (!template || started.current || isLoading || (activation && !accountRecord)) return; + if (!automatic) trackTemplate('template_use_clicked', template.id); + if (!did) { + navigate(`/login?template=${template.id}`); + return; + } + if (!activation) { + navigate(`/templates/${template.id}/use?new=1`); + return; + } + const account = did; + started.current = true; + setBusy(true); + setError(null); + try { + // A signup can arrive here before AuthenticatedLayout's redeemer mounts. + // Preserve first-list eligibility by awaiting idempotent redemption first. + const referralCode = localStorage.getItem(REFERRAL_CODE_KEY); + if (referralCode && accountRecord) { + await redeemReferral({ code: referralCode, refereeUserId: accountRecord._id }); + if (currentDid.current !== account) return; + if (localStorage.getItem(REFERRAL_CODE_KEY) === referralCode) localStorage.removeItem(REFERRAL_CODE_KEY); + } + const asset = await templateAsset(account, template.id, template.name, !automatic || search.get('new') === '1'); + if (currentDid.current !== account) throw new Error('Your account changed. Select the template again.'); + const listId = await create({ builtinId: template.id, listName: template.name, + assetDid: asset.assetDid, celEnvelope: asset.envelope, expectedOwnerDid: account }); + if (currentDid.current !== account) return; + finishTemplateAttempt(account, template.id); + trackTemplate('template_activated', template.id, listId); + navigate(`/list/${listId}`, { replace: true }); + } catch (err) { + setError(listCreationErrorMessage(err)); + } finally { + setBusy(false); + started.current = false; + } + } + + // Creation is mounted only on the authenticated, app-unlocked /use route. + // Consume explicit new-use intent before starting; pending retries retain their asset. + useEffect(() => { + if (template && search.get('use') === '1' && !activation) { + navigate(`/templates/${template.id}/use`, { replace: true }); + } else if (activation && template && did && accountRecord && !isLoading && !started.current && !autoAttempted.current) { + autoAttempted.current = true; + if (search.has('new')) navigate(`/templates/${template.id}/use`, { replace: true }); + void activate(true); + } + }, [template, did, accountRecord, isLoading, search, activation]); // eslint-disable-line react-hooks/exhaustive-deps + + return
    + +
    + {slug && !template ? <>

    Template not found

    Browse agent runbooks : template ? <> + ← All agent runbooks +
    Agent runbook · {template.items.length} steps
    +

    {template.name}

    {template.description}

    +

    {template.useCase}

    + +

    {did ? 'Creates a private list in your account.' : 'Sign up or sign in to get a private list with every step preloaded.'} Your agent acts only with the access you grant.

    + {error &&

    {error}

    View plan limits
    } +

    What you’ll have at the end

    {template.outcome}

    +

    The runbook

      + {template.items.map((item, index) =>
    1. +

      {index + 1}. {item.name}

      {item.description}

      +
    2. )} +
    +

    Make it yours

    Replace the placeholders with your project context, assign owners, and connect an agent using the API quickstart. Review the steps before granting access; using a template does not execute the work.

    + : <> +
    Work together, step by step
    +

    Agent runbooks for work you can review.

    +

    Ten ready-to-use checklists. Give an agent a bounded task, keep the evidence with the work, and make the human review explicit.

    +
    {AGENT_RUNBOOKS.map((entry, index) =>
    +

    {String(index + 1).padStart(2, '0')} · {entry.items.length} steps

    +

    {entry.name} →

    +

    {entry.description}

    +
    )}
    + } +
    +
    ; +} diff --git a/src/pages/Templates.tsx b/src/pages/Templates.tsx index 23979ed6..3cf5777e 100644 --- a/src/pages/Templates.tsx +++ b/src/pages/Templates.tsx @@ -10,6 +10,8 @@ import { api } from "../../convex/_generated/api"; import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; +import { trackTemplate } from "../lib/analytics"; +import { getRunbook } from "../../shared/templates"; import { createListAsset } from "../lib/originals"; import { listCreationErrorMessage } from "../lib/planLimit"; import { BUILTIN_TEMPLATES, type BuiltinTemplate } from "../lib/builtinTemplates"; @@ -32,8 +34,6 @@ export function Templates() { const publicTemplates = useQuery(api.templates.getPublicTemplates) ?? []; // Mutations - const createList = useMutation(api.lists.createList); - const addItem = useMutation(api.items.addItem); const deleteTemplate = useMutation(api.templates.deleteTemplate); const createListFromTemplate = useMutation(api.templates.createListFromTemplate); @@ -45,24 +45,15 @@ export function Templates() { try { const listAsset = await createListAsset(template.name, did); - const listId = await createList({ + if (getRunbook(template.id)) trackTemplate("template_use_clicked", template.id); + const listId = await createListFromTemplate({ + builtinId: template.id, assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, - name: template.name, - createdAt: Date.now(), + listName: template.name, }); - const now = Date.now(); - for (const item of template.items) { - await addItem({ - listId, - name: item.name, - createdAt: now, - priority: item.priority, - description: item.description, - }); - } - + if (getRunbook(template.id)) trackTemplate("template_activated", template.id, listId); haptic('success'); navigate(`/list/${listId}`); } catch (err) { @@ -151,6 +142,7 @@ export function Templates() { )} +

    Explore agent runbooks and step-by-step previews →

    {/* Built-in Templates */}

    diff --git a/tests/fixtures/app.ts b/tests/fixtures/app.ts index 5b6eac2b..2cc19c33 100644 --- a/tests/fixtures/app.ts +++ b/tests/fixtures/app.ts @@ -9,6 +9,8 @@ export interface ScenarioOptions { items?: { name: string; checked?: boolean }[]; published?: boolean; failCreateList?: boolean; + loseTemplateResponseOnce?: boolean; + pendingReferralCode?: string; authenticated?: boolean; onboarding?: boolean; inviteNudgeDone?: boolean; @@ -26,6 +28,7 @@ interface FixtureState { export interface Scenario { open(path: string, options?: ScenarioOptions): Promise; state(): Promise; + resumeTemplateResponses(): Promise; httpCalls: { path: string; body: Record }[]; } @@ -42,6 +45,10 @@ export const test = webTest.extend<{ scenario: Scenario }>({ try { await runTest({ httpCalls, state, + async resumeTemplateResponses() { + const response = await fetch(new URL('/__e2e/resume-template', base), {method:'POST',headers:{authorization:`Bearer ${token}`}}); + if (!response.ok) throw new Error('Could not resume fixture responses'); + }, async open(path, options = {}) { if (token) throw new Error('Use app.open/browser.reload after scenario.open; a scenario is seeded once per test.'); const response = await fetch(new URL('/__e2e/seed', base), { diff --git a/tests/fixtures/server.mjs b/tests/fixtures/server.mjs index f58d8d99..381c684f 100644 --- a/tests/fixtures/server.mjs +++ b/tests/fixtures/server.mjs @@ -2,6 +2,7 @@ // Runs beside Vite on loopback; each test owns a separate in-memory account. import { createServer } from 'vite'; import { randomUUID } from 'node:crypto'; +import { BUILTIN_TEMPLATES } from '../../shared/templates.ts'; import { revision } from '../../shared/replay.ts'; const TS_ZERO = 'AAAAAAAAAAA='; @@ -74,6 +75,7 @@ function query(account, path, args) { case 'lists:getLegacyListIds': case 'publication:getUserBookmarkIds': case 'categories:getUserCategories': + case 'templates:getPublicTemplates': case 'templates:getUserTemplates': case 'bitcoinAnchors:getListAnchors': case 'bitcoinAnchors:getItemAnchors': @@ -93,6 +95,26 @@ async function mutation(account, path, args) { const list = account.lists.find(l => l._id === args.listId); switch (endpoint) { case 'actorSession:establish': break; + case 'referrals:redeemReferral': { + account.referralRedeemed = true; + result = {success:true}; + break; + } + case 'templates:createListFromTemplate': { + if (account.options.pendingReferralCode && !account.referralRedeemed) throw new Error('Template created before referral redemption'); + if (args.expectedOwnerDid && args.expectedOwnerDid !== user.did) throw new Error('Account changed'); + const template = BUILTIN_TEMPLATES.find(t => t.id === args.builtinId); + if (!template) throw new Error('Template not found'); + const previous = account.lists.find(l => l.assetDid === args.assetDid); + if (previous) { result = previous._id; break; } + if (account.options.failCreateList || account.lists.length >= 5) throw new Error('PLAN_LIMIT: Free plan allows a maximum of 5 lists'); + result = `lists:created${++account.nextId}`; + account.lists.push({ _id: result, _creationTime: Date.now(), name: args.listName, + assetDid: args.assetDid, ownerDid: user.did, createdAt: Date.now() }); + account.items.push(...template.items.map((item, i) => ({ ...item, _id: `items:template${++account.nextId}`, + _creationTime: Date.now(), listId: result, checked: false, createdByDid: user.did, createdAt: Date.now(), order: i }))); + break; + } case 'lists:createList': { if (account.options.failCreateList) throw new Error('PLAN_LIMIT: Free plan allows a maximum of 5 lists'); result = `lists:created${++account.nextId}`; @@ -164,6 +186,7 @@ const backend = Bun.serve({ const path = url.searchParams.get('path'); if (!account || !path?.startsWith('/') || path.startsWith('//')) return new Response('Invalid fixture', { status: 400 }); const storage = { 'poo-cookie-consent': 'declined' }; + if (account.options.pendingReferralCode) storage['poo-referral-code'] = account.options.pendingReferralCode; if (account.options.authenticated !== false) { storage['lisa-auth-state'] = JSON.stringify({ token: account.token, user }); storage['lisa-jwt-token'] = account.token; @@ -184,6 +207,10 @@ const backend = Bun.serve({ return Response.json({ lists: account.lists, items: account.items, calls: account.calls, errors: account.errors, published: account.published, receipts: account.receipts }); } + if (url.pathname === '/__e2e/resume-template' && account && request.method === 'POST') { + account.options.loseTemplateResponseOnce = false; + return new Response(null, {status:204}); + } if (url.pathname === '/__e2e/account' && request.method === 'DELETE' && account) { accounts.delete(token); return new Response(null, { status: 204 }); @@ -218,6 +245,12 @@ const backend = Bun.serve({ if (!account) throw new Error('No seeded test account'); account.calls.push({ path: msg.udfPath, args }); const result = await mutation(account, msg.udfPath, args); + if (msg.udfPath === 'templates:createListFromTemplate' && account.options.loseTemplateResponseOnce) { + // The write committed, but the caller receives an error instead + // of its acknowledgment. Keep failing retries until the test resumes. + ws.send(JSON.stringify({ type: 'MutationResponse', requestId: msg.requestId, success: false, result: 'Fixture: committed response lost', logLines: [] })); + return; + } ws.send(JSON.stringify({ type: 'MutationResponse', requestId: msg.requestId, success: true, result, ts: TS_ZERO, logLines: [] })); for (const socket of sockets) if (socket.data.account === account) transition(socket); } catch (error) { diff --git a/tests/templates.e2e.ts b/tests/templates.e2e.ts new file mode 100644 index 00000000..43ab033c --- /dev/null +++ b/tests/templates.e2e.ts @@ -0,0 +1,65 @@ +import { expect } from 'e2e'; +import { test } from './fixtures/app'; +import { AGENT_RUNBOOKS } from '../shared/templates'; + +test('public gallery exposes all ten linked runbooks and handles unknown slugs', async ({ scenario, screen, app }) => { + await scenario.open('/templates', { authenticated: false }); + await expect(screen.getByRole('heading', 'Agent runbooks for work you can review.')).toBeVisible({ timeout: 15000 }); + for (const template of AGENT_RUNBOOKS) { + await expect(screen.getByRole('link', `${template.name} →`)).toHaveAttribute('href', `/templates/${template.id}`); + } + await app.open('/templates/not-a-public-template'); + await expect(screen.getByRole('heading', 'Template not found')).toBeVisible(); +}); + +test('template selection survives signup and creates every runbook step', async ({ scenario, screen, browser }) => { + await scenario.open('/templates/release-checklist', { authenticated: false, onboarding: false, pendingReferralCode: 'friend-code' }); + await expect(screen.getByRole('heading', 'Release checklist', { level: 1 })).toBeVisible(); + await screen.getByRole('button', 'Use this template').click(); + await expect(browser).toHaveURL('/login?template=release-checklist'); + await screen.getByLabel('Email address').fill('new@example.test'); + await screen.getByRole('button', 'Send Code').click(); + for (let i = 1; i <= 6; i++) await screen.getByLabel(`Digit ${i}`, { exact: true }).fill(String(i)); + await expect(screen.getByRole('heading', 'Release checklist', { level: 1 })).toBeVisible({ timeout: 15000 }); + const state = await scenario.state(); + expect(state.lists.length).toBe(1); + expect(state.items.map(i => i.name)).toEqual(AGENT_RUNBOOKS[0].items.map(i => i.name)); + expect(state.calls.filter(c => c.path === 'templates:createListFromTemplate').length).toBe(1); + expect(state.calls.findIndex(c => c.path === 'referrals:redeemReferral') < state.calls.findIndex(c => c.path === 'templates:createListFromTemplate')).toBe(true); + expect(state.calls.some(c => c.path === 'referrals:redeemReferral')).toBe(true); +}); + +test('authenticated continuation creates once and quota failure remains retryable', async ({ scenario, screen, browser }) => { + await scenario.open('/login?template=ci-triage', { lists: 5, failCreateList: true }); + await expect(screen.getByRole('alert')).toContainText("You've reached the free plan limit"); + await expect(browser).toHaveURL('/templates/ci-triage/use'); + expect((await scenario.state()).lists.length).toBe(5); + await screen.getByRole('button', 'Retry template creation').click(); + await expect(screen.getByRole('alert')).toContainText("You've reached the free plan limit"); + expect((await scenario.state()).items.length).toBe(0); +}); + +test('arbitrary login destinations cannot become template continuations', async ({ scenario, browser }) => { + await scenario.open('/login?template=https://evil.test'); + await expect(browser).toHaveURL('/d'); + expect((await scenario.state()).lists.length).toBe(0); +}); + +test('lost success response and reload retry reuse the committed template at quota', async ({ scenario, screen, app, browser }) => { + await scenario.open('/templates/release-checklist/use', { lists: 4, loseTemplateResponseOnce: true }); + await expect.poll(async () => (await scenario.state()).lists.length).toBe(5); + await expect(screen.getByRole('alert')).toContainText('Failed to create list'); + expect((await scenario.state()).lists.length).toBe(5); + await app.open('/templates/release-checklist'); + await scenario.resumeTemplateResponses(); + await screen.getByRole('button', 'Use this template').click(); + await expect(screen.getByRole('heading', 'Release checklist', { level: 1 })).toBeVisible(); + // Wait for the app list instead of the same heading on the source page. + await expect(browser).toHaveURL(/\/list\/lists:created/); + const state = await scenario.state(); + expect(state.lists.length).toBe(5); + expect(state.items.length).toBe(6); + const attempts = state.calls.filter(c => c.path === 'templates:createListFromTemplate'); + expect(attempts.length >= 2).toBe(true); + expect(new Set(attempts.map(a => a.args.assetDid)).size).toBe(1); +}); diff --git a/vite.config.ts b/vite.config.ts index b1005ff8..d0206400 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import { writeTemplatePages } from './scripts/template-pages' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -64,6 +65,7 @@ export default defineConfig({ }, }), buildServiceWorker(), + { name: "template-pages", apply: "build", async closeBundle() { await writeTemplatePages(resolve(__dirname, "dist")); } }, // Upload source maps to Sentry on production builds (no-op if env vars not set) sentryVitePlugin({ org: process.env.SENTRY_ORG, From e5067870c0e23b27002649132c957d80df520a6b Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Sun, 4 Oct 2026 17:51:32 -0700 Subject: [PATCH 2/3] fix: preserve template authorship and keep catalog deploys in sync --- convex/items.ts | 2 +- .../lib/templateCatalog.ts | 0 convex/templates.ts | 12 +++++-- docs/templates.md | 8 ++++- scripts/template-pages.ts | 2 +- scripts/template-seo.test.mjs | 2 +- scripts/templates.test.mjs | 34 ++++++++++++++++++- src/App.tsx | 2 +- src/components/TemplatePickerModal.tsx | 2 +- src/lib/builtinTemplates.ts | 4 +-- src/lib/templateMetadata.ts | 2 +- src/pages/Login.tsx | 2 +- src/pages/TemplateGallery.tsx | 2 +- src/pages/Templates.tsx | 2 +- tests/fixtures/server.mjs | 2 +- tests/templates.e2e.ts | 2 +- 16 files changed, 63 insertions(+), 17 deletions(-) rename shared/templates.ts => convex/lib/templateCatalog.ts (100%) diff --git a/convex/items.ts b/convex/items.ts index 9c364e59..23b78469 100644 --- a/convex/items.ts +++ b/convex/items.ts @@ -22,7 +22,7 @@ import { MAX_NOTE_LENGTH, isNote } from "./lib/noteBody"; * * @see https://www.w3.org/TR/vc-data-model/ */ -function createItemAuthorshipVC( +export function createItemAuthorshipVC( itemId: Id<"items">, listId: Id<"lists">, creatorDid: string, diff --git a/shared/templates.ts b/convex/lib/templateCatalog.ts similarity index 100% rename from shared/templates.ts rename to convex/lib/templateCatalog.ts diff --git a/convex/templates.ts b/convex/templates.ts index 6f03a850..694dbeb7 100644 --- a/convex/templates.ts +++ b/convex/templates.ts @@ -5,8 +5,9 @@ import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; import { query } from "./_generated/server"; -import { BUILTIN_TEMPLATES } from "../shared/templates"; +import { BUILTIN_TEMPLATES } from "./lib/templateCatalog"; import { assertListQuota, createListOwnershipVC, grantFirstListReferral } from "./lists"; +import { createItemAuthorshipVC } from "./items"; import { upsertListEnvelope } from "./lib/listEnvelope"; // Id type used in function arguments via v.id() @@ -231,7 +232,7 @@ export const { public: createListFromTemplate, internal: createListFromTemplateI // Create items from template for (const templateItem of template.items) { - await ctx.db.insert("items", { + const itemId = await ctx.db.insert("items", { listId, name: templateItem.name, description: templateItem.description, @@ -239,8 +240,15 @@ export const { public: createListFromTemplate, internal: createListFromTemplateI checked: false, createdByDid: ctx.actor.did, createdAt: now, + updatedAt: now, + assignmentsVersion: 1, order: templateItem.order, }); + // Preserve the same historical authorship record as ordinary item creation. + // This is the existing unsigned placeholder, not a new signature claim. + await ctx.db.patch(itemId, { + vcProofs: [createItemAuthorshipVC(itemId, listId, ctx.actor.did, templateItem.name, now)], + }); } await grantFirstListReferral(ctx, owner, isFirstList); diff --git a/docs/templates.md b/docs/templates.md index a266e09d..55c4f55c 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -1,6 +1,6 @@ # Agent runbook templates -The public catalog at `/templates` contains ten editorial runbooks in `shared/templates.ts`. Each has a stable slug, use-case copy, expected outcome, six ordered steps, priorities, and explicit agent/human responsibilities. The existing quick-start templates remain available. Personal and community templates remain in the authenticated `/templates/saved` manager; anonymous landing pages only read the source-controlled catalog, never saved-template records. +The public catalog at `/templates` contains ten editorial runbooks in `convex/lib/templateCatalog.ts`. Each has a stable slug, use-case copy, expected outcome, six ordered steps, priorities, and explicit agent/human responsibilities. The existing quick-start templates remain available. Personal and community templates remain in the authenticated `/templates/saved` manager; anonymous landing pages only read the source-controlled catalog, never saved-template records. ## Creation and continuation @@ -24,6 +24,12 @@ Build a per-template PostHog funnel `template_use_clicked → template_activated Vite emits `/templates/index.html` and one `/templates//index.html` for every runbook, with static use-case copy and steps, unique title/description, canonical URL, social metadata, and links. The generated sitemap adds all eleven URLs. `server.ts` serves directory indexes. Static hosts must serve directory indexes before their SPA fallback. Public pages do not require Convex to render their catalog. Metadata updates on client navigation; arbitrary slugs display a not-found page and are absent from the sitemap. +## Release compatibility + +The release owner must make the compatible Convex backend available before the new frontend. The previous backend validator rejects `builtinId`, so releasing the frontend first temporarily breaks quick-start creation. Railway and the Convex workflow run independently; they do not enforce this order. Existing saved-template calls remain compatible. No deployment is performed by this change. + +The shared catalog lives under `convex/lib/`, within the existing deployment workflow’s `convex/**` filter, so future catalog-only edits update both frontend previews and backend creation data without changing workflow permissions. Coordinate both releases when adding or changing runbooks. + ## Verification - `bun test scripts/templates.test.mjs scripts/template-continuation.test.mjs scripts/template-app-lock.test.mjs`: catalog completeness; all runbook creation handlers; quota; retries; ownership/source mismatch; invalid selection; rejected insert failures. The in-memory handler fixture does not emulate Convex transaction rollback; atomicity relies on Convex's mutation transaction contract. diff --git a/scripts/template-pages.ts b/scripts/template-pages.ts index 9bf8dc82..7b4e32b4 100644 --- a/scripts/template-pages.ts +++ b/scripts/template-pages.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { AGENT_RUNBOOKS } from '../shared/templates'; +import { AGENT_RUNBOOKS } from '../convex/lib/templateCatalog'; const escape = (value: string) => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); /** Emit real crawlable HTML, using the same public-only catalog as the app/backend. */ diff --git a/scripts/template-seo.test.mjs b/scripts/template-seo.test.mjs index 46297f51..e5e7690a 100644 --- a/scripts/template-seo.test.mjs +++ b/scripts/template-seo.test.mjs @@ -4,7 +4,7 @@ import { mkdir, readFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises'; import { writeTemplatePages } from './template-pages.ts'; import { applyTemplateMetadata } from '../src/lib/templateMetadata.ts'; -import { AGENT_RUNBOOKS } from '../shared/templates.ts'; +import { AGENT_RUNBOOKS } from '../convex/lib/templateCatalog.ts'; const out='tmp/template-seo-test'; await mkdir(out,{recursive:true}); diff --git a/scripts/templates.test.mjs b/scripts/templates.test.mjs index 66f2bd91..41997ef0 100644 --- a/scripts/templates.test.mjs +++ b/scripts/templates.test.mjs @@ -95,7 +95,7 @@ const create = unwrap(mod.createListFromTemplate); const args = { builtinId: 'release-checklist', listName: 'Release checklist', assetDid: 'did:cel:template-test', authToken: ownerSession.authToken, expectedOwnerDid: OWNER }; test('all ten catalog runbooks have actionable, ordered steps and distinct landing copy', async () => { - const { AGENT_RUNBOOKS, templateLoginDestination } = await import('../shared/templates.ts'); + const { AGENT_RUNBOOKS, templateLoginDestination } = await import('../convex/lib/templateCatalog.ts'); assert.equal(AGENT_RUNBOOKS.length, 10); assert.equal(new Set(AGENT_RUNBOOKS.map(t => t.id)).size, 10); for (const template of AGENT_RUNBOOKS) { @@ -119,6 +119,38 @@ test('retry returns the complete original list without extra items, even at quot assert.ok(ctx.rows.items.every(i => i.checked === false && i.createdByDid === OWNER)); assert.ok((await ctx.db.get(id)).vcProof); }); +test('quick-start and saved template items retain authorship and revision metadata across retries', async () => { + for (const saved of [false, true]) { + const ctx = makeCtx(); + const source = saved + ? { builtinId: undefined, templateId: await ctx.db.insert('listTemplates', { + name: 'Saved runbook', ownerDid: OWNER, isPublic: false, + items: [{ name: 'Review evidence', order: 0 }], + }) } + : { builtinId: 'grocery' }; + const request = { ...args, ...source }; + const listId = await create(ctx, request); + const items = ctx.rows.items.filter(item => item.listId === listId); + assert.ok(items.length > 0); + for (const item of items) { + assert.equal(item.updatedAt, item.createdAt); + assert.equal(item.assignmentsVersion, 1); + assert.equal(item.vcProofs.length, 1); + const [record] = item.vcProofs; + assert.equal(record.type, 'ItemAuthorshipCredential'); + assert.equal(record.actorDid, OWNER); + assert.equal(record.issuanceDate, item.createdAt); + const historicalPayload = JSON.parse(record.proof); + assert.deepEqual(historicalPayload.credentialSubject, { + id: OWNER, itemId: item._id, listId, itemName: item.name, action: 'created', + }); + assert.equal(historicalPayload.proof, undefined); // Preserve unsigned historical semantics. + } + const before = structuredClone(items); + assert.equal(await create(ctx, request), listId); + assert.deepEqual(ctx.rows.items, before); + } +}); test('new creation enforces free-plan quota before writing', async () => { const ctx = makeCtx({ lists: Array.from({length:4}, (_,i) => ({_id:`extra-${i}`, ownerDid:OWNER})) }); await assert.rejects(create(ctx, args), /PLAN_LIMIT/); diff --git a/src/App.tsx b/src/App.tsx index 5b107f91..a8ac85af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,7 @@ import { CookieConsent } from './components/CookieConsent' import { useSwipeBack } from './hooks/useSwipeBack' import { useNativeLinks } from './hooks/useNativeLinks' import { initPushNotifications } from './lib/pushNotifications' -import { templateLoginDestination } from '../shared/templates' +import { templateLoginDestination } from '../convex/lib/templateCatalog' import { incrementMetric } from './lib/observability' // Lazy-loaded routes for better code splitting diff --git a/src/components/TemplatePickerModal.tsx b/src/components/TemplatePickerModal.tsx index ccbd98f3..2a2c11c3 100644 --- a/src/components/TemplatePickerModal.tsx +++ b/src/components/TemplatePickerModal.tsx @@ -12,7 +12,7 @@ import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; import { trackTemplate } from "../lib/analytics"; -import { getRunbook } from "../../shared/templates"; +import { getRunbook } from "../../convex/lib/templateCatalog"; import { createListAsset } from "../lib/originals"; import { listCreationErrorMessage } from "../lib/planLimit"; import { BUILTIN_TEMPLATES, type BuiltinTemplate } from "../lib/builtinTemplates"; diff --git a/src/lib/builtinTemplates.ts b/src/lib/builtinTemplates.ts index 5f69ca00..36b46ca5 100644 --- a/src/lib/builtinTemplates.ts +++ b/src/lib/builtinTemplates.ts @@ -1,2 +1,2 @@ -export { BUILTIN_TEMPLATES, AGENT_RUNBOOKS, getRunbook } from '../../shared/templates'; -export type { BuiltinTemplate, BuiltinTemplateItem } from '../../shared/templates'; +export { BUILTIN_TEMPLATES, AGENT_RUNBOOKS, getRunbook } from '../../convex/lib/templateCatalog'; +export type { BuiltinTemplate, BuiltinTemplateItem } from '../../convex/lib/templateCatalog'; diff --git a/src/lib/templateMetadata.ts b/src/lib/templateMetadata.ts index fac0fc8e..e6e37d79 100644 --- a/src/lib/templateMetadata.ts +++ b/src/lib/templateMetadata.ts @@ -1,4 +1,4 @@ -import type { BuiltinTemplate } from "../../shared/templates"; +import type { BuiltinTemplate } from "../../convex/lib/templateCatalog"; /** Restore the app shell on exit even when entry HTML was a prerendered runbook. */ export function applyTemplateMetadata(template?: BuiltinTemplate, unknownSlug = false) { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 675abe35..446eee49 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -14,7 +14,7 @@ import { useAuth } from "../hooks/useAuth"; import { OtpInput } from "../components/auth/OtpInput"; import { trackSignupStarted, trackSignupCompleted } from "../lib/analytics"; -import { templateLoginDestination } from "../../shared/templates"; +import { templateLoginDestination } from "../../convex/lib/templateCatalog"; type LoginStep = "email" | "otp"; diff --git a/src/pages/TemplateGallery.tsx b/src/pages/TemplateGallery.tsx index e5de0911..dd4549df 100644 --- a/src/pages/TemplateGallery.tsx +++ b/src/pages/TemplateGallery.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { AGENT_RUNBOOKS, getRunbook } from '../../shared/templates'; +import { AGENT_RUNBOOKS, getRunbook } from '../../convex/lib/templateCatalog'; import { useCurrentUser } from '../hooks/useCurrentUser'; import { useMutation, useQuery } from '../lib/authenticatedConvex'; import { api } from '../../convex/_generated/api'; diff --git a/src/pages/Templates.tsx b/src/pages/Templates.tsx index 3cf5777e..9ec73680 100644 --- a/src/pages/Templates.tsx +++ b/src/pages/Templates.tsx @@ -11,7 +11,7 @@ import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; import { trackTemplate } from "../lib/analytics"; -import { getRunbook } from "../../shared/templates"; +import { getRunbook } from "../../convex/lib/templateCatalog"; import { createListAsset } from "../lib/originals"; import { listCreationErrorMessage } from "../lib/planLimit"; import { BUILTIN_TEMPLATES, type BuiltinTemplate } from "../lib/builtinTemplates"; diff --git a/tests/fixtures/server.mjs b/tests/fixtures/server.mjs index 381c684f..b6dd22d6 100644 --- a/tests/fixtures/server.mjs +++ b/tests/fixtures/server.mjs @@ -2,7 +2,7 @@ // Runs beside Vite on loopback; each test owns a separate in-memory account. import { createServer } from 'vite'; import { randomUUID } from 'node:crypto'; -import { BUILTIN_TEMPLATES } from '../../shared/templates.ts'; +import { BUILTIN_TEMPLATES } from '../../convex/lib/templateCatalog.ts'; import { revision } from '../../shared/replay.ts'; const TS_ZERO = 'AAAAAAAAAAA='; diff --git a/tests/templates.e2e.ts b/tests/templates.e2e.ts index 43ab033c..e02ac47b 100644 --- a/tests/templates.e2e.ts +++ b/tests/templates.e2e.ts @@ -1,6 +1,6 @@ import { expect } from 'e2e'; import { test } from './fixtures/app'; -import { AGENT_RUNBOOKS } from '../shared/templates'; +import { AGENT_RUNBOOKS } from '../convex/lib/templateCatalog'; test('public gallery exposes all ten linked runbooks and handles unknown slugs', async ({ scenario, screen, app }) => { await scenario.open('/templates', { authenticated: false }); From e1d35936ccb59a0841bed60751c86d04bdb1df7f Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Sun, 4 Oct 2026 17:57:10 -0700 Subject: [PATCH 3/3] fix: register template catalog in generated API types --- convex/_generated/api.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index c3d4d6e6..7f7826d5 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -71,6 +71,7 @@ import type * as lib_planError from "../lib/planError.js"; import type * as lib_session from "../lib/session.js"; import type * as lib_sessionTokens from "../lib/sessionTokens.js"; import type * as lib_sitePlans from "../lib/sitePlans.js"; +import type * as lib_templateCatalog from "../lib/templateCatalog.js"; import type * as lib_turnkeyClient from "../lib/turnkeyClient.js"; import type * as lib_turnkeySigner from "../lib/turnkeySigner.js"; import type * as listGrants from "../listGrants.js"; @@ -174,6 +175,7 @@ declare const fullApi: ApiFromModules<{ "lib/session": typeof lib_session; "lib/sessionTokens": typeof lib_sessionTokens; "lib/sitePlans": typeof lib_sitePlans; + "lib/templateCatalog": typeof lib_templateCatalog; "lib/turnkeyClient": typeof lib_turnkeyClient; "lib/turnkeySigner": typeof lib_turnkeySigner; listGrants: typeof listGrants;