/** * Canonical bridge/manifest types for a Caspian canvas. Dependency-free (no Node/Electron/ * React imports) so it can be bundled straight into a sandboxed iframe with no special * privileges. Copy this file into your canvas's own sdk/ folder alongside bridge.ts. */ // --------------------------------------------------------------------------- // Capability model // --------------------------------------------------------------------------- export const ALL_CAPABILITIES = [ 'read-frontmatter', 'write-frontmatter', 'create-notes', 'delete-notes', 'network', 'bundled-assets', 'canvas-sidecar', 'backend', 'users', 'spaces', 'camera' ] as const export type Capability = (typeof ALL_CAPABILITIES)[number] // --------------------------------------------------------------------------- // Canvas manifest (canvas.json) // --------------------------------------------------------------------------- export type SchemaFieldType = 'string' | 'number' | 'boolean' | 'enum' | 'id' | 'id-list' export interface SchemaField { type: SchemaFieldType values?: string[] // for enum default?: unknown } export type CanvasSchema = Record /** One optional, visitor-facing feature of a canvas: a control, panel, or mode * that a publisher can switch off for the public copy (a "Pull bones apart" * slider, an info sidebar, a "Reset view" button). Declared by the canvas itself * in canvas.json, tagged when the canvas is authored or changed, never inferred * at publish time. The publish dialog turns this list into checkboxes; unchecked * ids are passed to the published renderer via `DISABLED_FEATURES_GLOBAL`, which * the renderer honours by removing that feature's controls and holding it at its * default state. */ export interface CanvasFeature { /** Stable kebab-case id: the publish on/off choice and the renderer's * disabled-list check are both keyed by this. */ id: string /** Short, control-style name, e.g. "Pull bones apart". */ label: string /** One line: what a visitor does with it. */ description?: string } /** Global the published renderer reads on startup: a `string[]` of * `CanvasFeature` ids the publisher turned OFF. Absent/empty = everything on. * Injected into the published dist/renderer.html at publish time. */ export const DISABLED_FEATURES_GLOBAL = '__CASPIAN_DISABLED_FEATURES__' /** One page of a canvas. A canvas with no `pages` has exactly one: its `renderer`. * Each page is its own HTML file in dist/, so the host can refuse to serve a page that * needs a signed-in user. See https://caspian.md/docs/#pages. */ export interface CanvasPage { /** Stable kebab-case id, used by `page.open`. */ id: string /** Shown to people, e.g. on the sign-in page. */ title: string /** HTML file inside dist/, e.g. "account.html". */ file: string /** Only a signed-in user may open this page (the project has "User registration" on and * the canvas has the "users" capability). */ requiresAuth?: boolean /** The canvas's own sign-in page: where someone opening a requiresAuth page signed out * is sent, with ?next=. At most one; never requiresAuth. */ signIn?: boolean } export interface CanvasManifest { id: string name: string version: string author: string apiVersion: string renderer: string description?: string schema: CanvasSchema capabilities: Capability[] /** Publisher-toggleable features this canvas declares. Absent on canvases * authored before the feature manifest existed. See `sdk/features.ts`. */ features?: CanvasFeature[] /** The canvas's pages. Absent = one page, `renderer`. */ pages?: CanvasPage[] /** How the sidecar is published. Absent = "whole": visitors can read all of it. * "split": only the sidecar's `published` section ({ public, members, byEmail }) is * uploaded; see https://caspian.md/docs/#split-sidecar. */ publishedSidecar?: 'whole' | 'split' } /** `sidecar.read`'s data on a published site whose canvas uses "split" publishing. */ export interface PublishedSidecarView { public: unknown /** Null when nobody is signed in. */ members: unknown /** This visitor's own `byEmail` record; null when signed out, unconfirmed or absent. */ own: unknown } /** Bridge/manifest contract version this build of the host implements. */ export const HOST_API_VERSION = '1' // --------------------------------------------------------------------------- // Frontmatter / notes // --------------------------------------------------------------------------- /** Engine-owned frontmatter keys, namespaced so they never collide with user fields. */ export interface CaspianCoreFrontmatter { caspian_id: string caspian_canvas: string caspian_type?: string } export type FrontmatterValue = string | number | boolean | string[] | null export type NoteFrontmatter = CaspianCoreFrontmatter & Record export interface NoteRecord { id: string path: string // path relative to vault root, posix-style ('/'-separated) frontmatter: NoteFrontmatter /** Derived from the note's first `#` heading (falling back to its filename). Read-only, * never written back: a display convenience so canvases don't need a full body fetch * just to label a node. */ title: string /** true if the file failed to parse; frontmatter will be a best-effort empty shell */ parseError?: string /** Last-modified time (epoch ms), for the explorer's "sort by modified". */ mtime: number } export interface NoteContent { id: string path: string title: string frontmatter: NoteFrontmatter body: string } /** * What actually crosses the bridge into a sandboxed canvas. No `path`: canvases * address and receive notes by `caspian_id` only, never by filesystem location. */ export interface CanvasNoteView { id: string title: string frontmatter: NoteFrontmatter body: string } // --------------------------------------------------------------------------- // Bridge API: the fixed verb set a canvas renderer may call // --------------------------------------------------------------------------- export interface AnchorClickParams { id: string } export interface AnchorCreateParams { id?: string // if omitted, host generates one initialFrontmatter: Record title?: string } export interface AnchorDeleteParams { id: string } export interface NoteGetMetaParams { id: string } export interface NoteSetMetaParams { id: string patch: Record } export interface SidecarReadParams { // no params: always the calling canvas's own sidecar } export interface SidecarWriteParams { data: unknown } /** * Asks the user for a single line of text, via a dialog the *trusted host* renders and * shows, never the canvas's own sandboxed iframe. Two reasons it has to work this way * rather than a canvas just building its own `` overlay (which map/webpage/etc. * already do for things like a pin's title): the iframe is sandboxed with * `allow-scripts` only, so even the browser's native `window.prompt()` is unavailable * inside it; and a value like an API key deserves a dialog the user can trust wasn't * drawn by arbitrary third-party canvas code. The host always labels the dialog with the * requesting canvas's name so it's never mistaken for a message from Caspian itself. */ export interface HostPromptParams { title: string body?: string placeholder?: string /** Masks the input, for secrets like API keys. */ secret?: boolean confirmLabel?: string } /** What a canvas reports back each time the user clicks something while `pick.mode` is * active. Call it once per click; the user may mark several things before sending * their message, so the host accumulates every report rather than keeping only the * last one. See `BridgeEventPayloadMap['pick.mode']` and the `'pick.report'` method. */ export interface PickReportParams { /** Short, human-readable label for whatever was clicked (a row's name, a pin's title, * a 3D part, "the current view" for a canvas with nothing more specific). Shown to * the user as a chip and given to the model as context, verbatim. */ label: string /** This element's note id, if it already has exactly one note attached, so the host * can pull that note's own content in as extra context. Omit if it has none or * several (picking a specific note among several isn't this affordance's job). */ noteId?: string } /** A registered user as a canvas sees them: never the password. */ export interface PublicUser { id: string email: string name: string createdAt: string /** Confirmed with an emailed code (published sites). Always true inside Caspian. */ emailVerified: boolean /** Values for the extra sign-up fields declared in database.json. */ fields: Record } export interface AuthRegisterParams { email: string password: string name?: string /** Values for database.json's extra sign-up fields, keyed by field id. */ fields?: Record } export interface AuthSignInParams { email: string password: string } export interface PageSummary { id: string title: string requiresAuth: boolean signIn: boolean } /** A space: a group visitors create and run themselves (e.g. one road association on a * shared site). See https://caspian.md/docs/#spaces. */ export type SpaceRole = 'admin' | 'member' export interface SpaceSummary { id: string slug: string name: string /** admin: created it or was added by email. member: has a byEmail entry. */ role: SpaceRole createdAt: string } export interface SpaceWriteParams { id: string name?: string /** Admins only. */ data?: unknown /** Anyone, by slug (space.public). */ public?: unknown /** Admins and members. */ members?: unknown /** { email: record }: each person reads only their own. Keys are lower-cased. */ byEmail?: Record } export interface BackendCallParams { fn: string args?: unknown } export interface BackendCallResult { result: unknown } export interface BridgeMethodMap { 'anchor.click': { params: AnchorClickParams; result: { note: CanvasNoteView | null } } 'anchor.create': { params: AnchorCreateParams; result: { note: CanvasNoteView } } 'anchor.delete': { params: AnchorDeleteParams; result: { ok: true } } 'note.getMeta': { params: NoteGetMetaParams; result: { frontmatter: NoteFrontmatter | null } } 'note.setMeta': { params: NoteSetMetaParams; result: { frontmatter: NoteFrontmatter } } 'note.list': { params: Record; result: { notes: NoteRecord[] } } /** Inside Caspian: the whole sidecar. On a published "split" site: { data: PublishedSidecarView, published: true }. */ 'sidecar.read': { params: SidecarReadParams; result: { data: unknown; published?: boolean } } 'sidecar.write': { params: SidecarWriteParams; result: { ok: true } } 'asset.import': { params: { kind?: 'image' | 'model' } result: { assetId: string; url: string } | null } 'asset.url': { params: { assetId: string }; result: { url: string | null } } /** `value` is `null` if the user cancelled, never an empty string (the host dialog * won't confirm an empty input). Handled entirely by the trusted host, before the * request ever reaches the main process. See HostPromptParams' own comment. */ 'host.prompt': { params: HostPromptParams; result: { value: string | null } } /** Calls a handler registered with `caspian.handle()` in the canvas's local backend. */ 'backend.call': { params: BackendCallParams; result: BackendCallResult } /** Called by the canvas right after the user clicks something while `pick.mode` is * active (see that event), reporting what they pointed at so Copilot can be told * "the user means this" without them describing it in words. Handled entirely by the * trusted host (like `host.prompt`): no vault access, no capability needed, works * even before the canvas is approved. */ 'pick.report': { params: PickReportParams; result: { ok: true } } /** The canvas's pages and which one is showing. Needs no capability. */ 'page.list': { params: Record; result: { pages: PageSummary[]; current: string | null } } /** Go to another page. A requiresAuth page sends a signed-out visitor to the sign-in * page instead. Plain links between pages get the same check. Needs no capability. */ 'page.open': { params: { id: string }; result: { ok: true } } /** The signed-in user, or null. Needs "users". */ 'auth.currentUser': { params: Record; result: { user: PublicUser | null } } /** Creates an account and signs it in. Needs "users". */ 'auth.register': { params: AuthRegisterParams; result: { user: PublicUser } } /** Needs "users". A wrong email or password rejects with one generic message. */ 'auth.signIn': { params: AuthSignInParams; result: { user: PublicUser } } /** Needs "users". */ 'auth.signOut': { params: Record; result: { ok: true } } /** Confirms the signed-in user's email with the emailed code (published sites; inside * Caspian everyone is already confirmed). Needs "users". */ 'auth.verifyEmail': { params: { code: string }; result: { user: PublicUser } } /** Emails a new code (published sites; a no-op inside Caspian). Needs "users". */ 'auth.resendVerification': { params: Record; result: { ok: true } } /** Spaces: all need "spaces" (and "users", with User registration on). Everything but * space.public needs a signed-in user with a confirmed email. */ 'space.public': { params: { slug: string }; result: { space: { id: string; slug: string; name: string }; public: unknown } } 'space.create': { params: { name: string }; result: { space: SpaceSummary } } 'space.list': { params: Record; result: { spaces: SpaceSummary[] } } /** data is null unless you're an admin; own is your byEmail entry (or null). */ 'space.read': { params: { id: string }; result: { space: SpaceSummary; data: unknown; public: unknown; members: unknown; own: unknown } } /** Admins only. Parts left out stay as they are. */ 'space.write': { params: SpaceWriteParams; result: { ok: true } } 'space.admins': { params: { id: string }; result: { admins: { email: string; joined: boolean; you: boolean }[] } } /** Admins only. On a published site the new admin is emailed an invitation. */ 'space.addAdmin': { params: { id: string; email: string }; result: { ok: true } } /** Admins only; a space always keeps at least one admin. */ 'space.removeAdmin': { params: { id: string; email: string }; result: { ok: true } } /** Admins only. */ 'space.delete': { params: { id: string }; result: { ok: true } } } export type BridgeMethod = keyof BridgeMethodMap export const BRIDGE_CHANNEL = 'caspian-bridge' export interface BridgeRequestMessage { channel: typeof BRIDGE_CHANNEL type: 'request' id: string method: M params: BridgeMethodMap[M]['params'] } export interface BridgeResponseMessage { channel: typeof BRIDGE_CHANNEL type: 'response' id: string ok: boolean result?: BridgeMethodMap[M]['result'] error?: string } export type BridgeEventName = | 'ready' | 'note.changed' | 'anchor.selected' | 'capabilities' | 'canvasBackendStatus' | 'pick.mode' | 'auth.changed' /** Payload shape for each host-pushed event, mirroring `BridgeMethodMap`'s per-method * typing, giving both the host and `bridge.ts` real types instead of `unknown`. */ export interface BridgeEventPayloadMap { ready: Record 'note.changed': Record /** Sent when the host wants this canvas to reveal a specific note's anchor, e.g. after * "Go to file" picks a note this canvas owns. */ 'anchor.selected': { id: string } capabilities: { capabilities: Capability[] } canvasBackendStatus: { status: 'installing' | 'running' | 'crashed' | 'error'; message?: string } /** The user toggled Copilot's "mark something on the canvas" pencil. `active: true`: * enter picking mode and STAY there. Every click on something meaningfully clickable * calls `pick.report` instead of its normal action, and the user can mark several * things in a row (one call each) before writing their message, so don't exit picking * mode after just one report. `active: false`: the user turned picking off (pencil * again, Escape, or sent their message), so restore normal click behaviour. */ 'pick.mode': { active: boolean } /** Someone signed in or out (including from Caspian's Database tab). */ 'auth.changed': { user: PublicUser | null } } export interface BridgeEventMessage { channel: typeof BRIDGE_CHANNEL type: 'event' event: E payload: BridgeEventPayloadMap[E] } export type BridgeMessage = BridgeRequestMessage | BridgeResponseMessage | BridgeEventMessage /** Maps each bridge method to the capability required to call it. `null` = always allowed. */ export const METHOD_CAPABILITY: Record = { 'anchor.click': null, 'anchor.create': 'create-notes', 'anchor.delete': 'delete-notes', 'note.getMeta': 'read-frontmatter', 'note.setMeta': 'write-frontmatter', 'note.list': 'read-frontmatter', 'sidecar.read': 'canvas-sidecar', 'sidecar.write': 'canvas-sidecar', 'asset.import': 'canvas-sidecar', 'asset.url': 'canvas-sidecar', // Shows a dialog and returns text the user typed; no vault access, so (like // anchor.click) it needs no capability and works even before the canvas is approved. 'host.prompt': null, 'backend.call': 'backend', 'pick.report': null, 'page.list': null, 'page.open': null, 'auth.currentUser': 'users', 'auth.register': 'users', 'auth.signIn': 'users', 'auth.signOut': 'users', 'auth.verifyEmail': 'users', 'auth.resendVerification': 'users', 'space.public': 'spaces', 'space.create': 'spaces', 'space.list': 'spaces', 'space.read': 'spaces', 'space.write': 'spaces', 'space.admins': 'spaces', 'space.addAdmin': 'spaces', 'space.removeAdmin': 'spaces', 'space.delete': 'spaces' }