Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions SHARED_BACKEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ Unset → `/api/_data` is inert (404). The `documents` table ships with the stan
(`lib/frameChannel.ts`). Real Web Storage throws there, so the SDK installs in-memory
`localStorage`/`sessionStorage` that last as long as the page; nothing is stored server-side. It
also saves same-site `<a download>` links through a Blob URL, since the browser would otherwise
navigate them (`postplandb/sandbox.ts`).
Persistence is `postplan.db`, explicitly.
navigate them (`postplandb/sandbox.ts`). Persistence is explicit: `window.storage` (Claude
artifacts' API, minus shared keys: string keys private to each viewer in `site_kv`, served by the
`/api/_data/_kv` sub-app, terminal and with its own capability table — reads `read`, writes
`create`; 5 MB (UTF-8) and 1,000 keys per viewer per site, checked and written in one statement;
`list` is a case-sensitive prefix match) or `postplan.db`. Collection names starting with `_`
are reserved for these built-in routes.

- **Standalone tabs** — a site opened directly on the content origin has no parent frame, so
`postplan.db` calls fail with a clear "open this site through the Postplan app" error. By design
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ For any other update, first run `postplan read <space/site> --pull <dir>` and de
- Fork a site before changing someone else's work.
- Compare deployment versions and restore an older snapshot without deleting history.
- Use `postplan.db` for opt-in browser data and realtime updates.
- Pages run sandboxed on their own origin. `localStorage` works for the life of the page only; persist with `postplan.db`. `fetch` of the site's own files works.
- Pages run sandboxed on their own origin. `localStorage` works for the life of the page only; persist small values with `window.storage` (Claude artifacts API, private to each viewer; no shared keys) or data with `postplan.db`. `fetch` of the site's own files works.

## Safety

Expand Down
11 changes: 11 additions & 0 deletions packages/api/drizzle/0036_site_kv.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- window.storage for sites: each viewer's own keys, gone with the site or the user.
-- `bytes` is the UTF-8 size of key + value, so a viewer's quota is one indexed sum.
CREATE TABLE `site_kv` (
`siteId` text NOT NULL REFERENCES `sites`(`id`) ON DELETE cascade,
`userId` text NOT NULL REFERENCES `users`(`id`) ON DELETE cascade,
`key` text NOT NULL,
`value` text NOT NULL,
`bytes` integer NOT NULL,
`updatedAt` text NOT NULL,
PRIMARY KEY (`siteId`, `userId`, `key`)
);
4 changes: 2 additions & 2 deletions packages/api/src/agent-docs.ts

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,24 @@ export const documents = sqliteTable(
],
)

// `window.storage` for sites (lib/site-kv.ts): each viewer's own keys, written only when a page calls `set`.
export const siteKv = sqliteTable(
'site_kv',
{
siteId: text('siteId')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
value: text('value').notNull(),
bytes: integer('bytes').notNull(),
updatedAt: text('updatedAt').notNull(),
},
(t) => [primaryKey({ columns: [t.siteId, t.userId, t.key] })],
)

// Append-only per-site change stream for `postplan.db` realtime push. Every documents mutation
// writes exactly one row here in the SAME db.batch, so a client that reconnects — or notices a
// gap — replays from a cursor instead of going permanently stale (Cloudflare terminates every
Expand Down
52 changes: 52 additions & 0 deletions packages/api/src/lib/site-kv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { and, eq, gte, lt, sql } from 'drizzle-orm'
import type { DrizzleD1Database } from 'drizzle-orm/d1'
import { utf8Bytes } from '../../../shared/kv'
import { siteKv } from '../db/schema'

// Storage behind `window.storage` (the API Claude artifacts expose): string values under string keys,
// private to the viewer. Nothing is stored unless a page asks; rows go with the site and the user.

export const KV_QUOTA = { bytes: 5_000_000, keys: 1_000 }
const KV_LIST_LIMIT = 1_000

const scope = (siteId: string, userId: string) => and(eq(siteKv.siteId, siteId), eq(siteKv.userId, userId))

export async function getKv(db: DrizzleD1Database, siteId: string, userId: string, key: string) {
const [row] = await db
.select({ value: siteKv.value })
.from(siteKv)
.where(and(scope(siteId, userId), eq(siteKv.key, key)))
.limit(1)
return row?.value ?? null
}

export async function listKv(db: DrizzleD1Database, siteId: string, userId: string, prefix: string) {
// A case-sensitive range over the primary key; U+10FFFF sorts after any key with this prefix.
const rows = await db
.select({ key: siteKv.key })
.from(siteKv)
.where(and(scope(siteId, userId), gte(siteKv.key, prefix), lt(siteKv.key, `${prefix}\u{10FFFF}`)))
.orderBy(siteKv.key)
.limit(KV_LIST_LIMIT)
return rows.map((r) => r.key)
}

/** Upsert in ONE statement, so concurrent writes can't jointly pass the quota; false when over it. */
export async function setKv(db: DrizzleD1Database, siteId: string, userId: string, key: string, value: string) {
const bytes = utf8Bytes(key) + utf8Bytes(value)
const rows = await db.all<{ key: string }>(sql`
WITH others AS (
SELECT coalesce(sum(bytes), 0) AS bytes, count(*) AS keys FROM site_kv
WHERE siteId = ${siteId} AND userId = ${userId} AND key != ${key}
)
INSERT INTO site_kv (siteId, userId, key, value, bytes, updatedAt)
SELECT ${siteId}, ${userId}, ${key}, ${value}, ${bytes}, ${new Date().toISOString()}
FROM others WHERE others.bytes + ${bytes} <= ${KV_QUOTA.bytes} AND others.keys < ${KV_QUOTA.keys}
ON CONFLICT (siteId, userId, key) DO UPDATE SET value = excluded.value, bytes = excluded.bytes, updatedAt = excluded.updatedAt
RETURNING key`)
return rows.length === 1
}

export async function deleteKv(db: DrizzleD1Database, siteId: string, userId: string, key: string) {
await db.delete(siteKv).where(and(scope(siteId, userId), eq(siteKv.key, key)))
}
4 changes: 2 additions & 2 deletions packages/api/src/postplandb/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
// Run `bun run build:db` after changing postplandb/client.ts.
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: minified bundle carries template literals inside the string
export const POSTPLAN_DB_JS =
'(()=>{var M="postplan.db.v1";var I=new Set(["getItem","setItem","removeItem","clear","key","length"]);function S(){let e=new Map;return new Proxy({getItem:(n)=>e.get(String(n))??null,setItem(n,t){e.set(String(n),String(t))},removeItem(n){e.delete(String(n))},clear(){e.clear()},key:(n)=>[...e.keys()][n]??null,get length(){return e.size}},{get:(n,t)=>typeof t==="string"&&!I.has(t)?e.get(t)??void 0:Reflect.get(n,t),set(n,t,o){if(typeof t!=="string"||I.has(t))return Reflect.set(n,t,o);return n.setItem(t,o),!0},deleteProperty(n,t){if(typeof t==="string")n.removeItem(t);return!0},has:(n,t)=>typeof t==="string"&&e.has(t)||Reflect.has(n,t),ownKeys:()=>[...e.keys()],getOwnPropertyDescriptor:(n,t)=>typeof t==="string"&&e.has(t)?{value:e.get(t),writable:!0,enumerable:!0,configurable:!0}:void 0})}function R(){try{return!window.localStorage}catch{return!0}}function U(e,r){try{let n=new URL(e,r);return(n.protocol==="http:"||n.protocol==="https:")&&n.origin===new URL(r).origin}catch{return!1}}async function B(e,r){let n=await fetch(e);if(!n.ok)throw Error(`download failed (${n.status})`);let t=URL.createObjectURL(await n.blob()),o=document.createElement("a");o.href=t,o.download=r,o.click(),setTimeout(()=>URL.revokeObjectURL(t),60000)}var A=(e)=>`${e.type}|${e.collection}|${e.id}|${e.at}`;function P(e){let r=new Set,n=new Set,t=[],o=!1,i=null,s=null;function p(l){for(let u of[...r]){if(u.collection!==l.collection||u.type!==l.type)continue;try{u.cb(l)}catch{}}}function a(l){for(let u of l.events){let g=A(u);if(n.has(g))continue;if(n.add(g),t.push(g),t.length>500){let y=t.shift();n.delete(y)}p(u)}i=l.cursor}function w(){let l=s??[];s=null;for(let u of l)a(u)}async function k(){for(;;){let l=await e.catchUp(i);if(a(l),!l.more)return}}function h(){s=[],k().then(w,w)}return{on(l,u,g){let y={collection:l,type:u,cb:g};if(r.add(y),!o)o=!0,e.open({onOpen:h,onFrame:(x)=>s?s.push(x):a(x)});return()=>{if(r.delete(y),r.size===0&&o)o=!1,s=null,e.close?.()}}}}var H=5000,q=15000,c=window.__POSTPLAN_DB__,b=null,d=null,_=new Map,W=0,E=null;function O(e,r,n){let t=_.get(e);if(!t)return;if(_.delete(e),clearTimeout(t.timer),r==="resolve")t.resolve(n);else t.reject(n)}function F(e){if(b)return Promise.resolve(b);if(d)return d;return d=new Promise((r,n)=>{let t=new MessageChannel,o=setTimeout(()=>{d=null,n(Error("postplan.db: no broker answered — open this site through the Postplan app"))},H);t.port1.onmessage=(i)=>{let s=i.data;if(s?.type==="postplan:db-ready")clearTimeout(o),b=t.port1,r(b);else if(s?.type==="postplan:db-error")clearTimeout(o),d=null,n(Error(s.error||"postplan.db: broker refused the connection"));else if(s?.type==="postplan:db-event")E?.onFrame(i.data);else if(s?.type==="postplan:db-open")E?.onOpen();else if(typeof s?.id==="number")if(s.ok)O(s.id,"resolve",s.body);else O(s.id,"reject",Error(s.body?.error||`postplan: ${s.status}`))},window.parent.postMessage({type:"postplan:db-hello",nonce:c?.frameNonce??null},e,[t.port2])}),d}async function C(e,r){let n=await F(e),t=++W;return new Promise((o,i)=>{let s=setTimeout(()=>O(t,"reject",Error("postplan.db: request timed out")),q);_.set(t,{resolve:o,reject:i,timer:s}),n.postMessage({id:t,...r})})}var m=null,N=0;async function j(e,r){let n=await fetch(`/api/data-token/${e}/${r}`,{method:"POST"});if(!n.ok)throw Error(`postplan: could not mint data token (${n.status})`);let t=await n.json();return m=t.token,N=Date.now()+Math.max(0,t.expiresIn-30)*1000,m}async function L(e,r,n,t,o,i){let s=m&&Date.now()<N?m:await j(e,r),p=await fetch(`/api/_data${t}`,{method:n,headers:{Authorization:`Bearer ${s}`,"Content-Type":"application/json"},body:o!==void 0?JSON.stringify(o):void 0});if(p.status===401&&!i)return m=null,L(e,r,n,t,o,!0);if(p.status===204)return null;let a=await p.json().catch(()=>({}));if(!p.ok)throw Error(a.error||`postplan: ${p.status}`);return a}var D=3000,z=30000;function G(e,r){let n=null,t=!1;return{open(o){t=!1;let i=`${location.origin.replace(/^http/,"ws")}/api/_data/_sync/socket`,s=()=>j(e,r).then((p)=>{if(t)return;let a=new WebSocket(i,[M,p]);n=a;let w=setInterval(()=>a.readyState===1&&a.send("ping"),z);a.onopen=()=>o.onOpen(),a.onmessage=(k)=>{try{let h=JSON.parse(k.data);if(Array.isArray(h?.events))o.onFrame(h)}catch{}},a.onclose=()=>{if(clearInterval(w),!t)setTimeout(s,D)}}).catch(()=>{if(!t)setTimeout(s,D)});s()},close(){t=!0,n?.close(),n=null},catchUp:(o)=>L(e,r,"GET",`/_sync/changes${o?`?cursor=${encodeURIComponent(o)}`:""}`)}}function J(e){return{open(r){E=r,F(e).then(()=>r.onOpen(),()=>{})},close(){E=null,C(e,{op:"unsubscribe"}).catch(()=>{})},catchUp:(r)=>C(e,{op:"subscribe",cursor:r??void 0})}}var v=null;function T(){if(!v)if(c?.space&&c?.site)v=P(G(c.space,c.site));else if(c?.appOrigin&&window.parent!==window)v=P(J(c.appOrigin));else throw Error("postplan.db: not connected — open this site through the Postplan app, or set window.__POSTPLAN_DB__");return v}function f(e,r,n,t){if(c?.space&&c?.site){let o=n!==void 0?`/${r}/${encodeURIComponent(n)}`:`/${r}`,i=e==="create"?"POST":e==="put"?"PUT":e==="delete"?"DELETE":"GET";return L(c.space,c.site,i,o,t)}if(c?.appOrigin&&window.parent!==window)return C(c.appOrigin,{op:e,collection:r,docId:n,data:t});return Promise.reject(Error("postplan.db: not connected — open this site through the Postplan app, or set window.__POSTPLAN_DB__"))}function K(e){return{create:(r)=>f("create",e,void 0,r),get:(r)=>f("get",e,r),list:()=>f("list",e),put:(r,n)=>f("put",e,r,n),delete:(r)=>f("delete",e,r),onCreate:(r)=>T().on(e,"create",r),onUpdate:(r)=>T().on(e,"update",r),onDelete:(r)=>T().on(e,"delete",r)}}window.postplan={db:{collection:K}};if(R())Object.defineProperty(window,"localStorage",{value:S(),configurable:!0}),Object.defineProperty(window,"sessionStorage",{value:S(),configurable:!0}),document.addEventListener("click",(e)=>{if(e.defaultPrevented||e.button!==0)return;let r=e.target?.closest?.("a[download][href]");if(!r||!U(r.href,location.href))return;e.preventDefault(),B(r.href,r.getAttribute("download")||r.pathname.split("/").pop()||"download").catch((n)=>console.warn(`postplan: ${n.message}`))});})();\n'
export const POSTPLAN_DB_VERSION = 'fa5f6c1e'
'(()=>{var I="postplan.db.v1";var R=new Set(["getItem","setItem","removeItem","clear","key","length"]);function T(){let e=new Map;return new Proxy({getItem:(n)=>e.get(String(n))??null,setItem(n,r){e.set(String(n),String(r))},removeItem(n){e.delete(String(n))},clear(){e.clear()},key:(n)=>[...e.keys()][n]??null,get length(){return e.size}},{get:(n,r)=>typeof r==="string"&&!R.has(r)?e.get(r)??void 0:Reflect.get(n,r),set(n,r,o){if(typeof r!=="string"||R.has(r))return Reflect.set(n,r,o);return n.setItem(r,o),!0},deleteProperty(n,r){if(typeof r==="string")n.removeItem(r);return!0},has:(n,r)=>typeof r==="string"&&e.has(r)||Reflect.has(n,r),ownKeys:()=>[...e.keys()],getOwnPropertyDescriptor:(n,r)=>typeof r==="string"&&e.has(r)?{value:e.get(r),writable:!0,enumerable:!0,configurable:!0}:void 0})}function U(){try{return!window.localStorage}catch{return!0}}function j(e,t){try{let n=new URL(e,t);return(n.protocol==="http:"||n.protocol==="https:")&&n.origin===new URL(t).origin}catch{return!1}}async function B(e,t){let n=await fetch(e);if(!n.ok)throw Error(`download failed (${n.status})`);let r=URL.createObjectURL(await n.blob()),o=document.createElement("a");o.href=r,o.download=t,o.click(),setTimeout(()=>URL.revokeObjectURL(r),60000)}var H=(e)=>`${e.type}|${e.collection}|${e.id}|${e.at}`;function O(e){let t=new Set,n=new Set,r=[],o=!1,a=null,s=null;function p(c){for(let u of[...t]){if(u.collection!==c.collection||u.type!==c.type)continue;try{u.cb(c)}catch{}}}function l(c){for(let u of c.events){let g=H(u);if(n.has(g))continue;if(n.add(g),r.push(g),r.length>500){let b=r.shift();n.delete(b)}p(u)}a=c.cursor}function m(){let c=s??[];s=null;for(let u of c)l(u)}async function S(){for(;;){let c=await e.catchUp(a);if(l(c),!c.more)return}}function h(){s=[],S().then(m,m)}return{on(c,u,g){let b={collection:c,type:u,cb:g};if(t.add(b),!o)o=!0,e.open({onOpen:h,onFrame:(x)=>s?s.push(x):l(x)});return()=>{if(t.delete(b),t.size===0&&o)o=!1,s=null,e.close?.()}}}}var q=5000,K=15000,i=window.__POSTPLAN_DB__,y=null,d=null,C=new Map,W=0,E=null;function M(e,t,n){let r=C.get(e);if(!r)return;if(C.delete(e),clearTimeout(r.timer),t==="resolve")r.resolve(n);else r.reject(n)}function F(e){if(y)return Promise.resolve(y);if(d)return d;return d=new Promise((t,n)=>{let r=new MessageChannel,o=setTimeout(()=>{d=null,n(Error("postplan.db: no broker answered — open this site through the Postplan app"))},q);r.port1.onmessage=(a)=>{let s=a.data;if(s?.type==="postplan:db-ready")clearTimeout(o),y=r.port1,t(y);else if(s?.type==="postplan:db-error")clearTimeout(o),d=null,n(Error(s.error||"postplan.db: broker refused the connection"));else if(s?.type==="postplan:db-event")E?.onFrame(a.data);else if(s?.type==="postplan:db-open")E?.onOpen();else if(typeof s?.id==="number")if(s.ok)M(s.id,"resolve",s.body);else M(s.id,"reject",Error(s.body?.error||`postplan: ${s.status}`))},window.parent.postMessage({type:"postplan:db-hello",nonce:i?.frameNonce??null},e,[r.port2])}),d}async function P(e,t){let n=await F(e),r=++W;return new Promise((o,a)=>{let s=setTimeout(()=>M(r,"reject",Error("postplan.db: request timed out")),K);C.set(r,{resolve:o,reject:a,timer:s}),n.postMessage({id:r,...t})})}var w=null,N=0;async function A(e,t){let n=await fetch(`/api/data-token/${e}/${t}`,{method:"POST"});if(!n.ok)throw Error(`postplan: could not mint data token (${n.status})`);let r=await n.json();return w=r.token,N=Date.now()+Math.max(0,r.expiresIn-30)*1000,w}async function L(e,t,n,r,o,a){let s=w&&Date.now()<N?w:await A(e,t),p=await fetch(`/api/_data${r}`,{method:n,headers:{Authorization:`Bearer ${s}`,"Content-Type":"application/json"},body:o!==void 0?JSON.stringify(o):void 0});if(p.status===401&&!a)return w=null,L(e,t,n,r,o,!0);if(p.status===204)return null;let l=await p.json().catch(()=>({}));if(!p.ok)throw Error(l.error||`postplan: ${p.status}`);return l}var D=3000,z=30000;function G(e,t){let n=null,r=!1;return{open(o){r=!1;let a=`${location.origin.replace(/^http/,"ws")}/api/_data/_sync/socket`,s=()=>A(e,t).then((p)=>{if(r)return;let l=new WebSocket(a,[I,p]);n=l;let m=setInterval(()=>l.readyState===1&&l.send("ping"),z);l.onopen=()=>o.onOpen(),l.onmessage=(S)=>{try{let h=JSON.parse(S.data);if(Array.isArray(h?.events))o.onFrame(h)}catch{}},l.onclose=()=>{if(clearInterval(m),!r)setTimeout(s,D)}}).catch(()=>{if(!r)setTimeout(s,D)});s()},close(){r=!0,n?.close(),n=null},catchUp:(o)=>L(e,t,"GET",`/_sync/changes${o?`?cursor=${encodeURIComponent(o)}`:""}`)}}function J(e){return{open(t){E=t,F(e).then(()=>t.onOpen(),()=>{})},close(){E=null,P(e,{op:"unsubscribe"}).catch(()=>{})},catchUp:(t)=>P(e,{op:"subscribe",cursor:t??void 0})}}var v=null;function _(){if(!v)if(i?.space&&i?.site)v=O(G(i.space,i.site));else if(i?.appOrigin&&window.parent!==window)v=O(J(i.appOrigin));else throw Error("postplan.db: not connected — open this site through the Postplan app, or set window.__POSTPLAN_DB__");return v}function f(e,t,n,r){if(i?.space&&i?.site){let o=n!==void 0?`/${t}/${encodeURIComponent(n)}`:`/${t}`,a=e==="create"?"POST":e==="put"?"PUT":e==="delete"?"DELETE":"GET";return L(i.space,i.site,a,o,r)}if(i?.appOrigin&&window.parent!==window)return P(i.appOrigin,{op:e,collection:t,docId:n,data:r});return Promise.reject(Error("postplan.db: not connected — open this site through the Postplan app, or set window.__POSTPLAN_DB__"))}function V(e){return{create:(t)=>f("create",e,void 0,t),get:(t)=>f("get",e,t),list:()=>f("list",e),put:(t,n)=>f("put",e,t,n),delete:(t)=>f("delete",e,t),onCreate:(t)=>_().on(e,"create",t),onUpdate:(t)=>_().on(e,"update",t),onDelete:(t)=>_().on(e,"delete",t)}}window.postplan={db:{collection:V}};if(U())Object.defineProperty(window,"localStorage",{value:T(),configurable:!0}),Object.defineProperty(window,"sessionStorage",{value:T(),configurable:!0}),document.addEventListener("click",(e)=>{if(e.defaultPrevented||e.button!==0)return;let t=e.target?.closest?.("a[download][href]");if(!t||!j(t.href,location.href))return;e.preventDefault(),B(t.href,t.getAttribute("download")||t.pathname.split("/").pop()||"download").catch((n)=>console.warn(`postplan: ${n.message}`))});function k(e,t){if(t)return Promise.reject(Error("window.storage: shared keys are not supported; use postplan.db"));if(!i?.appOrigin||window.parent===window)return Promise.reject(Error("window.storage: open this site through the Postplan app"));return P(i.appOrigin,e)}Object.defineProperty(window,"storage",{value:{get:(e,t)=>k({op:"kv",action:"get",key:e},t),set:(e,t,n)=>k({op:"kv",action:"set",key:e,value:String(t)},n),delete:(e,t)=>k({op:"kv",action:"delete",key:e},t),list:(e="",t)=>k({op:"kv",action:"list",prefix:e},t)},configurable:!0});})();\n'
export const POSTPLAN_DB_VERSION = '40465900'
Loading
Loading