eleven / ocho Public

Package the mini-app authoring guide for external LLMs (#243)

* docs: package the mini-app authoring guide for external LLMs

A self-contained authoring guide (docs/building-apps.md) an external LLM can
read with no repo checkout: the event-fold model, defineApp/ctx reference,
hard constraints as numbered self-check rules, an interview script, the theme
CSS variables, a worked example, and the nine invariants.

Served from the binary at GET /api/app-guide as text/markdown (embedded like
the changelog; deliberately un-gated β€” static docs, not instance data), plus
a zero-dependency static validator (tools/check-app.mjs) that LLM output can
be iterated against, with tests wired into npm test.

Co-Authored-By: Claude <noreply@anthropic.com>

* server: serve the app checker at /api/check-app.mjs

The guide's delivery step told authors to run tools/check-app.mjs from a
repo checkout, which an external LLM following /api/app-guide won't have.
Embed the checker in the binary alongside the guide and serve it un-gated
at GET /api/check-app.mjs, and point the guide's final step at a curl of
that URL instead.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 90bf877e95aa6d54f20f6335e0d91bb2fcaa4f33 parent 1afb989
7 files changed, +1078 −1
  • changelog/2026-07-06-app-authoring-guide.md +1 −0
  • docs/building-apps.md +616 −0
  • handlers_test.go +47 −0
  • main.go +39 −0
  • package.json +1 −1
  • test/check-app.test.mjs +148 −0
  • tools/check-app.mjs +226 −0
diff --git a/changelog/2026-07-06-app-authoring-guide.md b/changelog/2026-07-06-app-authoring-guide.md
new file mode 100644
index 0000000..2960219
--- /dev/null
+++ b/changelog/2026-07-06-app-authoring-guide.md
@@ -0,0 +1 @@
+πŸ€– Want a mini-app of your very own β€” a tracker, a checklist, a little game? There's now a built-in guide any AI assistant can read: point it at `/api/app-guide` on your chat's web address and ask it to build you an app. It comes back with a single file your admin can install, and everything it does stays end-to-end encrypted, just like your messages.
diff --git a/docs/building-apps.md b/docs/building-apps.md
new file mode 100644
index 0000000..b35cffd
--- /dev/null
+++ b/docs/building-apps.md
@@ -0,0 +1,616 @@
+# πŸ€– Building a mini-app β€” a self-contained authoring guide
+
+This guide teaches you (a person, or an LLM asked to help) how to write a
+**mini-app** for an Eleven Messenger / lchat instance: a small client-side
+program that lives alongside chats β€” a to-do list, a poll, a tracker, a game.
+It is fully self-contained: you do not need the server's source code, a
+repository checkout, or any build tooling. Every instance serves this document
+at `GET /api/app-guide`.
+
+You will produce **exactly one JavaScript file** β€” a plain ES module β€” that the
+instance admin installs. Everything the app is (logic, rendering, CSS) ships in
+that one file.
+
+## The one idea
+
+An app has no server. It trades **typed events** over the same end-to-end
+encrypted message stream that chat uses, and derives its state by **folding**
+those events. The server only ever sees ciphertext, so it can't run or even
+recognise app logic β€” everything happens in the browser.
+
+> **The encrypted thread log IS the app's database.** You never store state; you
+> emit events and fold them back into state. Two devices that fold the same
+> events arrive at the same state by construction.
+
+An app is four small things:
+
+1. an **events map** β€” the event types it owns and how each folds into state,
+2. an **initialState** β€” the empty state to start from,
+3. **commands** and/or a **message action** β€” how a user creates events,
+4. a **surface** β€” how state renders (a full pane, or a panel above a chat).
+
+On top of that model the framework gives every app three capabilities for free
+(each has its own section below):
+
+- **Time** β€” `ctx.schedule(event, when)` has the *server* deliver an event into
+ the thread later, so things happen when no client is open (reminders that
+ actually fire). The server holds only ciphertext and a timestamp.
+- **Memory** β€” folded state is snapshotted (encrypted) to the server and
+ re-seeded on open, so an app's state survives beyond the loaded message
+ window. 1,000 items work as well as 10. Apps do nothing to opt in.
+- **Voice** β€” an event can carry a `notice` (or the app a `notify` hook) to
+ alert the user the moment it arrives: an in-app announcement, a system
+ notification when hidden, a push notification when closed. All wording stays
+ encrypted end-to-end.
+
+## Hard constraints β€” self-check every one before delivering
+
+An app that breaks any of these will be rejected or will misbehave. Check your
+finished file against each rule:
+
+1. **Exactly one file.** All logic, rendering, and CSS live in a single
+ `<name>.js` β€” no companion files, no assets, no build step.
+2. **A plain ES module.** No bundler output, no TypeScript, no JSX, no
+ `require()` β€” just standard `import`/`export` syntax a browser runs as-is.
+3. **Imports are allowed ONLY from `"../apps.js"` and `"../format.js"`.**
+ Nothing else β€” no other relative paths, no URLs, no npm packages, no dynamic
+ `import()`. The exact exports you may use are enumerated in
+ "Allowed imports" below.
+4. **No network calls.** Never `fetch`, `XMLHttpRequest`, or `WebSocket`. All
+ I/O flows through `ctx` (emit, schedule, snapshots) β€” if you're reaching for
+ the network, you've taken a wrong turn.
+5. **No browser storage.** Never `localStorage`, `indexedDB`, or
+ `document.cookie`. Durable state lives in the fold (it's encrypted and
+ synced for free); transient UI state (an expanded/collapsed toggle) lives in
+ module-level variables.
+6. **Never `innerHTML` user content.** Build DOM with `createElement` and set
+ text with `textContent`. `innerHTML` is acceptable only for static,
+ in-code string literals β€” never anything that includes user input.
+7. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.**
+ The framework stops `ctx.interval` tickers on redraw and thread reset; a
+ bare `setInterval` leaks one timer per redraw.
+8. **Every event type is prefixed with the app's `name`.** App `name: "note"`
+ β†’ types `note.add`, `note.remove`. Event types are globally unique across
+ all apps.
+9. **Reducers are pure, immutable, and idempotent by entity id.** Return new
+ state, never mutate the old; folding the same add twice must produce one
+ item (the collection helpers guarantee this).
+10. **Never set `name`, `ts`, or `uid` on an emitted event** β€” the transport
+ injects them.
+11. **Validate events in the reducer, not at the emitter.** Malformed or
+ hostile events must fold to a no-op, never a crash.
+12. **You never touch the server, keys, or tokens.** Persistence, scheduling,
+ and notifications all flow through `ctx`.
+
+## Interview β€” ask the user these first
+
+Before writing any code, ask the person who wants the app:
+
+1. **Personal or shared?** Is this *their own private tool* (a to-do list,
+ bookmarks β€” `scope: "personal"`, its own thread in the sidebar, full-pane)
+ or a *group tool everyone in a chat uses together* (a poll, a scoreboard β€”
+ `scope: "shared"`, a panel above the chat)?
+2. **Takeover or companion?** For a personal app: should its surface *be* the
+ whole thread (takeover β€” the default; right for boards, games, dashboards),
+ or should the thread also offer a Chat view where the app's actions read as
+ a conversation (companion β€” implement `chatMessage(event)`; right for
+ list-like tools where "added X" reads naturally as a chat line)?
+3. **Which slash commands, and what syntax?** e.g. `/note buy milk` β€” what's
+ the command name, what arguments does it take, and what should happen on
+ empty/bad input? (Reply with a usage line, never fail silently.)
+4. **Anything time-based?** Should something happen *later* β€” a reminder, an
+ expiry, a recurring nudge? That's `ctx.schedule`.
+5. **Should anything alert people?** Which events deserve a notification, and
+ what should it say? That's a `notice` on the event (or a `notify` hook for
+ state-dependent wording). Everything else stays silent.
+6. **What is the core entity, and what is its lifecycle?** A note: created,
+ removed. A task: created, completed, reopened, removed. A vote: cast,
+ changed. **Derive the events map directly from the answer** β€” one event
+ type per lifecycle transition, e.g. `task.add`, `task.done`, `task.reopen`,
+ `task.remove`.
+7. **How big can the list get?** If a surface can grow long (hundreds of
+ rows), plan for `incremental: true` + `keyedList` so redraws stay cheap.
+
+## The shape of an app: `defineApp(spec)`
+
+Your file calls `defineApp` once, at module load, and exports the result:
+
+```js
+import { defineApp } from "../apps.js";
+export const app = defineApp({ /* fields below */ });
+```
+
+| field | required | what it is |
+| -------------- | -------- | ---------- |
+| `name` | βœ“ | Unique lowercase id. Also the prefix for your event types. |
+| `title` | βœ“ | Human name (sidebar, header). |
+| `glyph` | βœ“ | One emoji/character used as the app's avatar. |
+| `scope` | βœ“ | `"personal"` or `"shared"` β€” see the interview above. |
+| `initialState` | βœ“ | `() => state`. A **factory**, called to seed each thread's empty state. |
+| `events` | βœ“ | `{ "<type>": (state, event, meta) => state }`. The fold. |
+| `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }` β€” `desc`/`args` feed the `/` typeahead. |
+| `render` | | `(state, el, ctx) => void`. Draws the surface. |
+| `chatMessage` | | `(event) => { text, action? } \| null`. Opting in makes the app a **companion**: projects each event into a Chat view (a bot bubble, or a `/me`-style action line via `{ text, action: true }`). No `chatMessage` β†’ the app **takes over** the thread's full surface. |
+| `chatIntro` | | The empty-state intro line shown the first time the companion Chat view opens. |
+| `messageAction`| | `(msg) => { label, glyph, run(ctx) } \| null`. Adds an item to the chat message menu. |
+| `renderEvent` | | `(event, meta) => Node \| null`. An inline line in the message feed. |
+| `notify` | | `(event, ctx) => { title, body } \| null`. Speak on a live event (see Voice). |
+| `ephemeral` | | `(event, ctx) => void`. Handle relay-only events (see Ephemeral events). |
+| `preview` | | `(event) => string \| null`. Sidebar preview line for an event (default: silent). |
+| `unread` | | `(event) => boolean`. Whether a live event lights the thread's unread dot (default: no). |
+| `snapshot` | | Set `false` to opt out of state snapshots (only for an app whose state IS the log). |
+| `incremental` | | Set `true` to keep the surface between renders and reconcile it yourself (see `keyedList`). |
+| `styles` | | A CSS string, injected once. Style with the theme variables (below). |
+
+`defineApp` returns the app object with `.types` (the owned event types) and
+`.reduce` (the synthesised fold function) derived from `events` β€” handy for
+testing the reducer in Node.
+
+## The events map & the reducer
+
+Each handler is `(state, event, meta) => newState`. Rules:
+
+- **Pure & immutable.** Return new state; never mutate `state`. Two devices
+ fold the same events and must converge; the UI also re-folds on reopen.
+- **No `default` case.** The framework returns state unchanged for any type
+ your app doesn't handle. Your map lists only what you own.
+- **Event types are globally unique.** Prefix every type with your app `name`
+ (`note.add`, `poll.vote`).
+- `meta` carries `{ id, ts }` where `id` is the server message id (used for
+ dedupe) and `ts` the sender's send time. You rarely need it.
+
+### Collection helpers
+
+Most apps fold into a list of `{ id, … }` items. Import these from
+`"../apps.js"` and your reducer is correct by construction β€” every op returns
+a **new array**, and `addItem` **dedupes by id**:
+
+```js
+addItem(list, item) // append unless the id is already present; always a fresh array
+updateItem(list, id, patch) // replace the matching item with { ...item, ...patch }
+removeItem(list, id) // drop the matching item
+```
+
+Because `addItem` always returns a fresh array, you can `.sort()` its result
+in place safely. Not every fold is a flat list β€” a per-entity map is fine too,
+as long as you update it immutably:
+
+```js
+"poll.vote": (s, e) =>
+ ({ polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: e.choice } } : p)) }),
+```
+
+## Emitting events β€” and the three free fields
+
+You create events with `ctx.emit(event)`. The event needs a `type` and
+whatever data your reducer reads. **Mint `ctx.id()` once for a new entity**
+and reuse that id in follow-up events (done / remove / vote):
+
+```js
+ctx.emit({ type: "note.add", id: ctx.id(), text }); // new entity β†’ new id
+ctx.emit({ type: "note.remove", id: item.id }); // follow-up β†’ the existing id
+```
+
+Three fields are **added for you** by the transport on every emit β€” never set
+them yourself, just read them in your reducer:
+
+| field | value |
+| -------- | ----- |
+| `e.name` | the sender's screen name |
+| `e.ts` | the send time (epoch ms) β€” use this for sorting, not `Date.now()` |
+| `e.uid` | the sender's user id β€” e.g. a poll keys votes by `e.uid` |
+
+Encryption is automatic: `emit` encrypts the event to the thread key and sends
+it as a normal message. You never touch keys, and never send plaintext to the
+server.
+
+## `ctx` β€” what a handler and a render get
+
+| member | in | what it does |
+| ---------------------------- | ------------- | ------------ |
+| `ctx.emit(event)` | anywhere | Emit a typed event (encrypted + sent). Returns `Promise<boolean>`. |
+| `ctx.schedule(event, when)` | anywhere | Have the **server** deliver `event` into the thread at `when` (epoch ms). Returns `Promise<sid \| null>`. |
+| `ctx.unschedule(sid)` | anywhere | Cancel a pending scheduled event. |
+| `ctx.emitEphemeral(event)` | anywhere | Relay-only delivery β€” never stored, never folded (see Ephemeral events). |
+| `ctx.id()` | anywhere | A fresh unique id for a new entity. |
+| `ctx.me` | anywhere | The current user's id. |
+| `ctx.meName` | anywhere | The current user's screen name. |
+| `ctx.getState()` | anywhere | The app's latest folded state for this thread. |
+| `ctx.threadId` | anywhere | The thread the app is acting in. |
+| `ctx.reply(spec)` | commands only | A private "Only visible to you" confirmation rendered where the user typed. `spec` is `{ text, short?, actions? }` β€” `short` is the terse one-liner it calms down to; `actions` is `[{ label, run }]` chips. |
+| `ctx.open()` | action chips | Navigate to the app's thread β€” offer it as an action chip; **never** call it as a command's side effect. |
+| `ctx.interval(fn, ms)` | render only | A ticker the framework stops for you on redraw / reset. |
+| `ctx.onCleanup(fn)` | render only | Run `fn` before the next redraw or on thread reset. |
+
+(`ctx.host` also exists β€” a narrow privileged bridge the host injects for
+built-in connected-service apps. Leave it alone.)
+
+### Commands answer in place
+
+Running a command produces a **reply in the current context** β€” not a UI
+change, not a navigation. On bad input, reply with a usage line:
+
+```js
+commands: {
+ "/note": {
+ desc: "File a note",
+ args: "<text>",
+ run: (args, ctx) => {
+ const text = args.trim();
+ if (!text) return ctx.reply({ text: "Usage: /note <text> β€” e.g. /note call the plumber" });
+ ctx.emit({ type: "note.add", id: ctx.id(), text });
+ ctx.reply({ text: `Filed β€œ${text}”.`, short: `filed β€œ${text}”`, actions: [{ label: "View notes", run: ctx.open }] });
+ },
+ },
+},
+```
+
+For a personal app, the event is routed into the user's own app-thread no
+matter which chat they typed in; for a shared app it targets the current chat.
+If a command should move the user somewhere, offer that as an **action chip**
+on the reply β€” leaving the current context is always the user's tap.
+
+## Time β€” scheduled events
+
+`ctx.schedule(event, whenMs)` is `ctx.emit` shifted into the future, executed
+by the server: the event is encrypted **now**, the server holds the
+ciphertext, and at the due moment it lands in the thread's message log and
+fans out exactly like a posted message β€” every device folds it, closed apps
+get a push, open apps see it live. The server learns only *(thread, time)*,
+never what was scheduled.
+
+```js
+// Schedule first β€” the sid is part of the reminder's record.
+const id = ctx.id();
+const sid = await ctx.schedule(
+ { type: "remind.fire", id, notice: { title: "⏰ Reminder", body: text } },
+ whenMs,
+);
+ctx.emit({ type: "remind.add", id, text, when: whenMs, sid: sid || "" });
+```
+
+Rules of thumb:
+
+- **Store the returned `sid` inside an emitted event.** It's how *any* device
+ β€” not just the one that scheduled β€” can later `ctx.unschedule(sid)`.
+- **Give a scheduled event a `notice`** (see Voice) β€” it's usually the whole
+ point of scheduling one.
+- **Handle `sid === null`** (offline / error): degrade gracefully.
+- **The scheduled event is still just an event.** Its fold handler must be
+ idempotent like any other β€” a cancel can race a delivery, so a fire folding
+ onto an already-removed id must be a no-op.
+- Cancelling an id that already fired succeeds quietly; that race is expected.
+
+## Memory β€” snapshots (automatic)
+
+The client only holds a window of a thread's messages (~100), but folded app
+state is **snapshotted automatically**: after new events fold, the framework
+saves the state β€” encrypted under the thread key β€” to the server, and re-seeds
+it when the thread opens, folding only newer events on top.
+
+You get this for free. It works *because* of the reducer rules: folds are pure
+and deterministic, so `snapshot + newer events ≑ folding all of history`. What
+it means for you:
+
+- **Don't bound your state by the window.** A 1,000-item list is fine.
+- **State is durable, so treat it with respect**: never put anything in
+ reducer state you wouldn't put in an event (it's all encrypted, but it's
+ persisted).
+- **Schema-evolve defensively.** A snapshot written by an old version of your
+ app will be seeded into a new version. Reducers and render must tolerate
+ missing fields (`e.note || ""` β€” the same rule as for old events).
+
+## Voice β€” talking to the user
+
+Apps can speak the moment something happens, wherever the user is:
+
+1. **`notice` on an event** β€” the simple, usual way. Any event (typically a
+ scheduled one) may carry `notice: { title, body }`. When it arrives live,
+ the framework announces it in-app and raises a system notification if the
+ page is hidden; when the app is **closed**, the service worker decrypts the
+ push and shows the same notice. One field, every situation, end-to-end
+ encrypted.
+
+2. **A `notify(event, ctx)` hook** β€” for wording that depends on state. Called
+ only for **live** events (never during history refolds β€” replaying the past
+ must not re-alert it). Return `{ title, body }` or `null` for silence:
+
+ ```js
+ notify(e, ctx) {
+ if (e.type !== "poll.create" || e.uid === ctx.me) return null;
+ return { title: "πŸ“Š New poll", body: e.q };
+ },
+ ```
+
+App events with neither are silent β€” an add from your other device never
+buzzes anyone.
+
+## Ephemeral events β€” presence without a trace
+
+`ctx.emitEphemeral(event)` relays an event to the thread's connected members
+and **forgets it**: never stored, never pushed, never folded. It's for
+presence traffic β€” typing indicators, live cursors β€” where persistence would
+be wrong, not just wasteful. Receivers handle it in the app's
+`ephemeral(event, ctx)` hook; senders get their own echo (check
+`e.uid !== ctx.me`).
+
+Two rules:
+
+- **Own the type.** Routing is by event type, so an ephemeral type must appear
+ in your `events` map β€” claim it with a no-op fold
+ (`"myapp.cursor": (s) => s`). That also makes it harmless if a hostile
+ client stores one as a real message.
+- **Never touch reducer state from the hook.** Ephemeral events are allowed to
+ differ between devices; folding one would break convergence. Keep transient
+ UI in module-level state and redraw it yourself.
+
+## Rendering a surface
+
+`render(state, el, ctx)` is called whenever state changes. The framework
+clears `el` first, so **always rebuild from `state`** β€” don't diff.
+
+- **Personal** apps render full-pane: `el` is the thread view. Usually an add
+ form on top, then the list.
+- **Shared** apps render a panel above the chat feed. **Render nothing when
+ state is empty** (just `return`) so the panel collapses until there's
+ something to show.
+- **Never `innerHTML` user content.** Build nodes with `createElement` and set
+ text with `textContent`.
+- Ship your CSS in the `styles` field and read the theme's variables so the
+ app follows light/dark and the user's chosen theme.
+
+### Theme CSS variables
+
+These custom properties are defined by every theme β€” style with them instead
+of hard-coded colours:
+
+| variable | what it is |
+| -------- | ---------- |
+| `--bg` | page background |
+| `--panel` | panel / card background |
+| `--sidebar` | sidebar background |
+| `--ink` | primary text colour |
+| `--muted` | secondary / de-emphasised text |
+| `--border` | hairline borders and dividers |
+| `--accent` | the theme's accent colour (buttons, highlights) |
+| `--accent-ink` | text colour that sits on `--accent` |
+| `--link` | link colour |
+| `--bubble-me` | own-message bubble background |
+| `--bubble-me-ink` | text colour on `--bubble-me` |
+| `--bubble-them` | others' bubble background β€” also the standard hover/selected tint |
+| `--danger` | destructive-action colour |
+| `--warn` | warning colour |
+| `--radius` | the standard corner radius |
+| `--shadow` | the standard shadow colour |
+| `--font-display` | the theme's display font stack |
+
+For anything clickable, use `cursor: var(--ui-cursor, pointer)` so the app
+respects the platform's cursor convention.
+
+## Incremental surfaces β€” `keyedList`
+
+By default the framework wipes and rebuilds an app's surface on every state
+change β€” the right default for small surfaces. For long lists set
+`incremental: true` (the framework stops wiping) and reconcile with
+`keyedList` from `"../apps.js"`:
+
+```js
+keyedList(listEl, state.items, {
+ key: (m) => m.id, // stable identity
+ rev: (m) => m.editedAt, // re-render an item only when this changes
+ render: (m) => buildRow(m) // build one item's DOM
+});
+```
+
+Unchanged items keep their exact DOM nodes β€” scroll position, focus, and
+loaded images survive; appends build only the new item. The container must be
+fully managed by `keyedList` (no other children). Build any once-only skeleton
+(a form, headers) on first render by checking whether it already exists.
+
+## Optional hooks
+
+### `messageAction(msg)`
+
+Contribute an item to a chat message's long-press / hover menu. Given the
+decrypted message `{ text, from }`, return `{ label, glyph, run(ctx) }` to
+offer the action, or `null` to sit this message out. The `run` gets a `ctx`
+already bound to your app-thread (for a personal app), so the action files
+into *your* app:
+
+```js
+import { firstLink } from "../format.js";
+// …
+messageAction(msg) {
+ const url = firstLink(msg.text || "");
+ if (!url) return null;
+ return {
+ label: "Save link",
+ glyph: "πŸ”–",
+ run(ctx) {
+ ctx.emit({ type: "bookmark.add", id: ctx.id(), url, from: msg.from || "" });
+ },
+ };
+},
+```
+
+### `renderEvent(event, meta)`
+
+Return a DOM node to show an inline line in the message feed for one of your
+events (e.g. "Poll created"), or `null` to keep it silent. Most apps return
+nothing and render only in their surface.
+
+### `chatMessage(event)` β€” companion presentation
+
+A personal app that implements `chatMessage` gets a **Chat view** alongside
+its **Interface view**, toggled in the thread header. Project each event into
+a chat line β€” return `{ text }` for a bot bubble, `{ text, action: true }` for
+a `/me`-style action line, or `null` to keep the event out of the chat:
+
+```js
+chatMessage(e) {
+ if (e.type === "todo.add") return { text: `added β€œ${e.text}”`, action: true };
+ return null;
+},
+```
+
+Both views fold from the same event log β€” one source of truth β€” so an action
+in one shows in the other. No `chatMessage` β†’ the app takes over the whole
+thread surface (the default).
+
+### `preview(event)` / `unread(event)`
+
+How your events appear in the chat list: `preview` returns the sidebar
+preview line (or `null` to leave the existing preview), `unread` says whether
+a live event lights the thread's unread dot. Both default to silent β€” opt in
+only for events a user would want surfaced (a new poll: yes; every vote: no).
+
+## Allowed imports
+
+Import **only** from these two modules, and only these exports.
+
+From `"../apps.js"`:
+
+| export | what it is |
+| ------ | ---------- |
+| `defineApp(spec)` | registers the app β€” call exactly once |
+| `addItem(list, item)` | immutable append, deduped by `id` |
+| `updateItem(list, id, patch)` | immutable in-place patch |
+| `removeItem(list, id)` | immutable removal |
+| `partitionByGrace(items, doneAtOf, now, graceMs?)` | split items into `{ active, grace, archived }` by a completion timestamp β€” for "linger then tuck away" UI |
+| `DONE_GRACE_MS` | the standard grace-period length (ms) |
+| `keyedList(el, items, opts)` | incremental DOM reconciliation for `incremental: true` surfaces |
+
+(`apps.js` exports other functions β€” `setEmitTransport`, `foldEvent`,
+`runCommand`, … β€” those are host/framework wiring. An app must not call them.)
+
+From `"../format.js"` (all pure text helpers):
+
+| export | what it is |
+| ------ | ---------- |
+| `tokenizeText(text)` | split text into `{ text }` / `{ text, href }` tokens β€” XSS-safe linkifying |
+| `richTokens(text)` | tokens with inline formatting too: `{ text, mark }`, mark ∈ `b`/`i`/`s`/`code` |
+| `blockTokens(text)` | block structure: `{ type: "p"\|"code"\|"quote", text }` |
+| `firstLink(text)` | the first http(s) link in a message, skipping code blocks |
+| `youtubeId(url)` | parse a YouTube URL into `{ id, start }`, or `null` |
+| `replySnippet(payload, max?)` | short plain-text preview of a message |
+| `emojiOnlyCount(text)` | how many emoji, if the text is emoji-only (else 0) |
+| `typingSummary(names)` | "X is typing…" one-liner |
+| `autoGroupTitle(names)` | member names joined into a group title |
+| `formatBytes(n)` | human-readable file size |
+
+Render tokens as DOM nodes (`document.createTextNode` for plain text, an
+element per marked token) β€” that's what keeps them XSS-safe.
+
+## A complete app
+
+A real, working app β€” copy it, rename it, and change the guts. It's a personal
+"Notes" list: `/note <text>` from any chat files a note; the app's own sidebar
+pane lists them.
+
+```js
+// note.js
+import { defineApp, addItem, removeItem } from "../apps.js";
+
+export const app = defineApp({
+ name: "note", // unique id, lowercase β€” also the event-type prefix
+ title: "Notes", // shown in the sidebar and header
+ glyph: "πŸ“", // avatar glyph
+ scope: "personal", // its own sidebar thread, rendered full-pane
+
+ // The empty state. A factory (not a value) so every thread gets its own.
+ initialState: () => ({ items: [] }),
+
+ // The events map: KEYS are the event types this app owns, VALUES fold each
+ // event into new state. Handlers must be PURE: return new state, never
+ // mutate the old. The collection helpers keep adds idempotent.
+ events: {
+ "note.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
+ "note.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
+ },
+
+ // A slash command a user can type in any chat. It emits an event; because
+ // this is a personal app, the event is routed into the user's own Notes
+ // thread wherever they typed it, with a private confirmation reply.
+ commands: {
+ "/note": {
+ desc: "File a note",
+ args: "<text>",
+ run: (args, ctx) => {
+ const text = args.trim();
+ if (!text) return ctx.reply({ text: "Usage: /note <text> β€” e.g. /note call the plumber" });
+ ctx.emit({ type: "note.add", id: ctx.id(), text });
+ ctx.reply({ text: `Filed β€œ${text}”.`, short: `filed β€œ${text}”` });
+ },
+ },
+ },
+
+ // The app's CSS travels with it β€” injected once by the framework. Read the
+ // theme's CSS variables so it follows the theme.
+ styles: `
+.note-item { display: flex; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); }
+.note-text { flex: 1 1 auto; overflow-wrap: anywhere; }
+`,
+
+ // Personal scope: `el` is the whole thread pane. Build DOM nodes and set
+ // text with textContent β€” NEVER innerHTML for anything a user typed.
+ render(state, el, ctx) {
+ for (const item of (state && state.items) || []) {
+ const row = document.createElement("div");
+ row.className = "note-item";
+ const text = document.createElement("span");
+ text.className = "note-text";
+ text.textContent = item.text; // user content β†’ textContent, never innerHTML
+ const rm = document.createElement("button");
+ rm.textContent = "Γ—";
+ rm.onclick = () => ctx.emit({ type: "note.remove", id: item.id });
+ row.append(text, rm);
+ el.appendChild(row);
+ }
+ },
+});
+```
+
+## Invariants (the things that fail silently if you skip them)
+
+1. **Idempotent fold.** The message window is re-folded from scratch on every
+ thread reopen, so folding the same add twice must produce one item.
+ `addItem` handles this by deduping on `id`; a custom fold must dedupe too.
+2. **Pure, immutable reducers.** Never mutate prior state. The collection
+ helpers return fresh arrays; if you go custom, spread (`{ ...obj }`,
+ `[...arr]`). This is also what makes snapshots correct.
+3. **Don't set `name` / `ts` / `uid` on emit** β€” the transport injects them.
+ Setting `ts: Date.now()` is dead code (the transport overwrites it).
+4. **Namespace event types** with your app `name`; they're globally unique.
+5. **Never `innerHTML` user content** β€” `textContent` / DOM nodes only.
+6. **Shared apps render nothing when empty** so their panel collapses.
+7. **You never touch the server or keys.** State persistence, scheduling, and
+ notifications all flow through `ctx` β€” if you find yourself reaching for
+ `fetch` or a key, you've taken a wrong turn.
+8. **Validate events in the reducer, not at the emitter.** Anything a thread
+ member (person, other device, bot) can send is a legal input to your fold β€”
+ malformed or hostile events must fold to a no-op, never a crash or a lie.
+9. **A scheduled event will arrive later, into different state.** Fold
+ handlers for scheduled types must tolerate their target being gone
+ (dismissed while the delivery was in flight).
+
+## Delivering the app
+
+Produce **one file**, named after the app (`note.js`, `poll.js`, …), passing
+every hard constraint above. Then hand it to the person who runs the instance:
+custom apps are installed by the **instance admin** (from the admin console's
+Apps section, where available) β€” you don't deploy anything yourself, and the
+file needs no changes to be installed.
+
+Before delivering, self-review against the hard-constraints list and the
+invariants. The instance also serves a static checker that verifies the
+mechanical rules (one `defineApp`, allowed imports only, no forbidden APIs,
+event-type prefixes, …). Download it from the same server this guide came
+from and run it with Node β€” it has zero dependencies:
+
+```sh
+curl -sO https://<instance>/api/check-app.mjs
+node check-app.mjs your-app.js
+```
+
+and iterate until it passes. (If you happen to have the lchat repository
+instead, the same file is `tools/check-app.mjs`.)
diff --git a/handlers_test.go b/handlers_test.go
index f629130..2a9e3ea 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -1311,3 +1311,50 @@ func TestCustomAppsRejectsInvalidNames(t *testing.T) {
}
}
}
+
+// The mini-app authoring guide is embedded in the binary and served as markdown
+// at /api/app-guide β€” deliberately un-gated (static docs, not instance data),
+// so an external LLM can be pointed straight at the URL.
+func TestAppGuide(t *testing.T) {
+ ts, _ := newTestServer(t)
+ resp, err := http.Get(ts.URL + "/api/app-guide")
+ if err != nil {
+ t.Fatalf("get app guide: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("want 200, got %d", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
+ t.Fatalf("want text/markdown content type, got %q", ct)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ body := string(b)
+ if !strings.Contains(body, "defineApp") {
+ t.Fatal("guide body missing defineApp β€” wrong document served?")
+ }
+ if !strings.Contains(body, "πŸ€–") {
+ t.Fatal("guide missing its πŸ€– authorship marker")
+ }
+}
+
+// The static app checker the guide points at is served un-gated too, so an
+// author following /api/app-guide can run it with no repo checkout.
+func TestAppChecker(t *testing.T) {
+ ts, _ := newTestServer(t)
+ resp, err := http.Get(ts.URL + "/api/check-app.mjs")
+ if err != nil {
+ t.Fatalf("get app checker: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("want 200, got %d", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
+ t.Fatalf("want text/javascript content type, got %q", ct)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(b), "defineApp") {
+ t.Fatal("checker body missing defineApp β€” wrong file served?")
+ }
+}
diff --git a/main.go b/main.go
index c0cbf2a..df863b6 100644
--- a/main.go
+++ b/main.go
@@ -58,6 +58,15 @@ var webFS embed.FS
//go:embed changelog/*.md
var changelogFS embed.FS
+// The mini-app authoring guide also ships inside the binary, served as plain
+// markdown at /api/app-guide β€” so a member can point any LLM at that URL and
+// have it learn how to build an app for this instance, with no repo checkout.
+// The static checker the guide's delivery step points at rides along the same
+// way (GET /api/check-app.mjs), so an author without the repo can still run it.
+//
+//go:embed docs/building-apps.md tools/check-app.mjs
+var appGuideFS embed.FS
+
type Server struct {
db *DB
hub *Hub
@@ -380,6 +389,11 @@ func (s *Server) handler() http.Handler {
// if it changed (auto-update). No auth: it's just a build hash.
mux.HandleFunc("GET /api/version", s.getVersion)
mux.HandleFunc("GET /api/changelog", s.getChangelog)
+ // The mini-app authoring guide. Static documentation compiled into the
+ // binary β€” never instance data β€” so, like /api/version, it isn't token-gated:
+ // the whole point is that an external LLM can be pointed straight at it.
+ mux.HandleFunc("GET /api/app-guide", s.getAppGuide)
+ mux.HandleFunc("GET /api/check-app.mjs", s.getAppChecker)
mux.HandleFunc("POST /api/admin/group", s.setGroup)
// Admin API (requires admin token)
@@ -596,6 +610,31 @@ func (s *Server) getChangelog(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"entries": s.changelog})
}
+// getAppGuide serves the embedded mini-app authoring guide as markdown. See the
+// appGuideFS comment for why this endpoint is deliberately public.
+func (s *Server) getAppGuide(w http.ResponseWriter, r *http.Request) {
+ b, err := appGuideFS.ReadFile("docs/building-apps.md")
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "guide unavailable")
+ return
+ }
+ w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
+ w.Write(b)
+}
+
+// getAppChecker serves the embedded static app checker, so an author following
+// the guide can run it without a repo checkout (curl -O, then node). Un-gated
+// for the same reason as the guide: static tooling, not instance data.
+func (s *Server) getAppChecker(w http.ResponseWriter, r *http.Request) {
+ b, err := appGuideFS.ReadFile("tools/check-app.mjs")
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "checker unavailable")
+ return
+ }
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ w.Write(b)
+}
+
// ---- chat API -----------------------------------------------------------
func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) {
diff --git a/package.json b/package.json
index ebf65f6..c02aa5c 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs"
}
}
diff --git a/test/check-app.test.mjs b/test/check-app.test.mjs
new file mode 100644
index 0000000..3a02914
--- /dev/null
+++ b/test/check-app.test.mjs
@@ -0,0 +1,148 @@
+// Tests for the mini-app static validator. node test/check-app.test.mjs
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const CHECKER = new URL("../tools/check-app.mjs", import.meta.url).pathname;
+const dir = mkdtempSync(join(tmpdir(), "check-app-"));
+let n = 0;
+
+// Write `source` to a temp app file and run the checker on it.
+function run(source) {
+ const file = join(dir, `app-${n++}.js`);
+ writeFileSync(file, source);
+ const res = spawnSync(process.execPath, [CHECKER, file], { encoding: "utf8" });
+ return { status: res.status, out: res.stdout + res.stderr };
+}
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " β€” " + (e && e.message));
+ failures++;
+ }
+}
+
+// A minimal valid app the good-path test uses; bad-path tests mutate it.
+const GOOD = `import { defineApp, addItem, removeItem } from "../apps.js";
+
+export const app = defineApp({
+ name: "note",
+ title: "Notes",
+ glyph: "πŸ“",
+ scope: "personal",
+ initialState: () => ({ items: [] }),
+ events: {
+ "note.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s),
+ "note.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
+ },
+ render(state, el, ctx) {
+ const d = document.createElement("div");
+ d.textContent = "static label";
+ d.innerHTML = "<b>static markup is fine</b>";
+ el.appendChild(d);
+ ctx.interval(() => {}, 1000);
+ },
+});
+`;
+
+await test("a valid app passes", () => {
+ const r = run(GOOD);
+ assert.equal(r.status, 0, r.out);
+ assert.ok(r.out.includes("all checks passed"), r.out);
+});
+
+await test("a syntax error fails the parse check", () => {
+ const r = run("import { defineApp } from \"../apps.js\";\nconst x = {;\n");
+ assert.notEqual(r.status, 0);
+ assert.ok(r.out.includes("does not parse"), r.out);
+});
+
+await test("zero and two defineApp calls both fail", () => {
+ const none = run("export const x = 1;\n");
+ assert.notEqual(none.status, 0);
+ assert.ok(none.out.includes("found 0"), none.out);
+ const twice = run(GOOD + "\ndefineApp({});\n");
+ assert.notEqual(twice.status, 0);
+ assert.ok(twice.out.includes("found 2"), twice.out);
+});
+
+await test("a missing required field fails", () => {
+ const r = run(GOOD.replace('glyph: "πŸ“",\n', ""));
+ assert.notEqual(r.status, 0);
+ assert.ok(r.out.includes('required field "glyph" missing'), r.out);
+});
+
+await test("scope must be personal or shared", () => {
+ const r = run(GOOD.replace('scope: "personal"', 'scope: "system"'));
+ assert.notEqual(r.status, 0);
+ assert.ok(r.out.includes('scope must be "personal" or "shared"'), r.out);
+});
+
+await test("event types must carry the app-name prefix", () => {
+ const r = run(GOOD.replace('"note.remove"', '"other.remove"'));
+ assert.notEqual(r.status, 0);
+ assert.ok(r.out.includes("other.remove"), r.out);
+});
+
+await test("fetch is forbidden", () => {
+ const r = run(GOOD.replace("el.appendChild(d);", 'fetch("/api/x");'));
+ assert.notEqual(r.status, 0);
+ assert.ok(r.out.includes("fetch("), r.out);
+});
+
+await test("localStorage and document.cookie are forbidden", () => {
+ const r = run(GOOD.replace("el.appendChild(d);", 'localStorage.setItem("a", "b"); const c = document.cookie;'));
+ assert.notEqual(r.status, 0);
+ assert.ok(r.out.includes("localStorage"), r.out);
+ assert.ok(r.out.includes("document.cookie"), r.out);
+});
+
+await test("bare setInterval is flagged; ctx.interval is not", () => {
+ const good = run(GOOD); // GOOD uses ctx.interval
+ assert.equal(good.status, 0, good.out);
+ const bad = run(GOOD.replace("ctx.interval(() => {}, 1000);", "setInterval(() => {}, 1000);"));
+ assert.notEqual(bad.status, 0);
+ assert.ok(bad.out.includes("setInterval"), bad.out);
+});
+
+await test("a forbidden token in a comment or string does not fail", () => {
+ const r = run(GOOD.replace("el.appendChild(d);", 'el.appendChild(d); // never call setInterval( or fetch(\nconst hint = "no localStorage here";'));
+ assert.equal(r.status, 0, r.out);
+});
+
+await test("innerHTML of dynamic content is flagged; a static literal is not", () => {
+ const good = run(GOOD); // GOOD assigns a static literal
+ assert.equal(good.status, 0, good.out);
+ const bad = run(GOOD.replace('d.innerHTML = "<b>static markup is fine</b>";', "d.innerHTML = item.text;"));
+ assert.notEqual(bad.status, 0);
+ assert.ok(bad.out.includes("innerHTML"), bad.out);
+ const tpl = run(GOOD.replace('d.innerHTML = "<b>static markup is fine</b>";', "d.innerHTML = `<b>${state.x}</b>`;"));
+ assert.notEqual(tpl.status, 0);
+ assert.ok(tpl.out.includes("innerHTML"), tpl.out);
+});
+
+await test("imports outside ../apps.js and ../format.js are flagged", () => {
+ const format = run(GOOD.replace('from "../apps.js";', 'from "../apps.js";\nimport { firstLink } from "../format.js";'));
+ assert.equal(format.status, 0, format.out);
+ const bad = run(GOOD.replace('from "../apps.js";', 'from "../apps.js";\nimport { x } from "./helpers.js";'));
+ assert.notEqual(bad.status, 0);
+ assert.ok(bad.out.includes("./helpers.js"), bad.out);
+});
+
+await test("dynamic import() is flagged", () => {
+ const r = run(GOOD.replace("el.appendChild(d);", 'import("../shell.js");'));
+ assert.notEqual(r.status, 0);
+ assert.ok(r.out.includes("dynamic import"), r.out);
+});
+
+if (failures) {
+ console.error(`\n${failures} test(s) failed`);
+ process.exit(1);
+}
+console.log("\nAll check-app tests passed");
diff --git a/tools/check-app.mjs b/tools/check-app.mjs
new file mode 100644
index 0000000..cf4f8cd
--- /dev/null
+++ b/tools/check-app.mjs
@@ -0,0 +1,226 @@
+#!/usr/bin/env node
+// check-app.mjs β€” static validator for a single-file mini-app.
+//
+// node check-app.mjs path/to/app.js
+//
+// Lives at tools/check-app.mjs in the repo, and every instance also serves it
+// at GET /api/check-app.mjs so authors following the guide can curl it down
+// with no checkout.
+//
+// Verifies the mechanical rules from docs/building-apps.md (served at
+// /api/app-guide) so an LLM-authored app can be iterated against a clear
+// pass/fail list before anyone installs it. Checks are static and best-effort:
+// a PASS here doesn't prove the app is correct, but a FAIL is always a real
+// rule violation. Zero dependencies; exits non-zero on any failure.
+
+import { readFileSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+
+const path = process.argv[2];
+if (!path) {
+ console.error("usage: node check-app.mjs path/to/app.js");
+ process.exit(2);
+}
+
+let src;
+try {
+ src = readFileSync(path, "utf8");
+} catch (e) {
+ console.error(`FAIL cannot read ${path}: ${e.message}`);
+ process.exit(1);
+}
+
+let failures = 0;
+const pass = (msg) => console.log("PASS " + msg);
+const fail = (msg) => {
+ console.log("FAIL " + msg);
+ failures++;
+};
+
+// strip walks the source once, dropping comments and (optionally) string
+// contents, so token scans don't trip on prose like "never call setInterval".
+// Replaced spans keep their length/newlines so any offsets stay meaningful.
+// Best-effort: template-literal ${} nesting is not fully parsed.
+function strip(code, { strings = false } = {}) {
+ let out = "";
+ let i = 0;
+ const n = code.length;
+ while (i < n) {
+ const c = code[i];
+ const c2 = code[i + 1];
+ if (c === "/" && c2 === "/") {
+ while (i < n && code[i] !== "\n") i++;
+ continue;
+ }
+ if (c === "/" && c2 === "*") {
+ i += 2;
+ while (i < n && !(code[i] === "*" && code[i + 1] === "/")) {
+ if (code[i] === "\n") out += "\n";
+ i++;
+ }
+ i += 2;
+ continue;
+ }
+ if (c === '"' || c === "'" || c === "`") {
+ const quote = c;
+ out += c;
+ i++;
+ while (i < n && code[i] !== quote) {
+ if (code[i] === "\\") {
+ if (!strings) out += code[i] + (code[i + 1] || "");
+ i += 2;
+ continue;
+ }
+ if (strings) {
+ if (code[i] === "\n") out += "\n";
+ } else {
+ out += code[i];
+ }
+ i++;
+ }
+ out += quote;
+ i++;
+ continue;
+ }
+ out += c;
+ i++;
+ }
+ return out;
+}
+
+const code = strip(src); // comments removed, strings kept (keys, literals)
+const bare = strip(src, { strings: true }); // comments AND string contents removed
+
+// ---- 1. parses ------------------------------------------------------------
+// Piped with --input-type=module: a bare `node --check <file>` silently exits 0
+// on files it detects as ES modules (Node 22), so it would miss real syntax
+// errors β€” the stdin form parses the source as a module for real.
+const check = spawnSync(process.execPath, ["--input-type=module", "--check"], { encoding: "utf8", input: src });
+if (check.status === 0) {
+ pass("parses (node --check)");
+} else {
+ fail("does not parse: " + (check.stderr || "").trim().split("\n")[0]);
+}
+
+// ---- 2. exactly one defineApp call -----------------------------------------
+const defineCalls = (bare.match(/\bdefineApp\s*\(/g) || []).length;
+if (defineCalls === 1) pass("exactly one defineApp( call");
+else fail(`expected exactly one defineApp( call, found ${defineCalls}`);
+
+// ---- 3. required fields -----------------------------------------------------
+for (const field of ["name", "title", "glyph", "scope", "initialState", "events"]) {
+ if (new RegExp(`\\b${field}\\s*:`).test(code)) pass(`required field "${field}" present`);
+ else fail(`required field "${field}" missing`);
+}
+
+// ---- 4. scope is personal|shared --------------------------------------------
+const scopeM = code.match(/\bscope\s*:\s*["'`]([^"'`]*)["'`]/);
+if (!scopeM) {
+ fail('scope must be a string literal: "personal" or "shared"');
+} else if (scopeM[1] === "personal" || scopeM[1] === "shared") {
+ pass(`scope is "${scopeM[1]}"`);
+} else {
+ fail(`scope must be "personal" or "shared", got "${scopeM[1]}"`);
+}
+
+// ---- 5. event types prefixed with the app name -------------------------------
+// Extract the app name, then walk the events object (brace-matched from
+// `events:`) collecting quoted keys at its top level; each must start "<name>.".
+const nameM = code.match(/\bname\s*:\s*["']([^"']+)["']/);
+const eventsAt = code.search(/\bevents\s*:\s*\{/);
+if (!nameM) {
+ fail("cannot find the app name (name: \"…\") to check event-type prefixes");
+} else if (eventsAt === -1) {
+ fail("cannot find the events map (events: { … })");
+} else {
+ const name = nameM[1];
+ const open = code.indexOf("{", eventsAt);
+ let depth = 0;
+ let end = open;
+ for (let i = open; i < code.length; i++) {
+ if (code[i] === "{" || code[i] === "(" || code[i] === "[") depth++;
+ else if (code[i] === "}" || code[i] === ")" || code[i] === "]") {
+ depth--;
+ if (depth === 0) {
+ end = i;
+ break;
+ }
+ }
+ }
+ const block = code.slice(open, end + 1);
+ // Quoted keys at the top level of the events object only (depth 1).
+ const keys = [];
+ let d = 0;
+ for (let i = 0; i < block.length; i++) {
+ const ch = block[i];
+ if (ch === "{" || ch === "(" || ch === "[") d++;
+ else if (ch === "}" || ch === ")" || ch === "]") d--;
+ else if (d === 1 && (ch === '"' || ch === "'")) {
+ const close = block.indexOf(ch, i + 1);
+ if (close === -1) break;
+ const rest = block.slice(close + 1).match(/^\s*:/);
+ if (rest) keys.push(block.slice(i + 1, close));
+ i = close;
+ }
+ }
+ if (keys.length === 0) {
+ fail("no event types found in the events map");
+ } else {
+ const badKeys = keys.filter((k) => !k.startsWith(name + "."));
+ if (badKeys.length === 0) pass(`all ${keys.length} event type(s) prefixed "${name}."`);
+ else fail(`event type(s) not prefixed "${name}.": ${badKeys.join(", ")}`);
+ }
+}
+
+// ---- 6. forbidden APIs -------------------------------------------------------
+const FORBIDDEN = [
+ [/\bfetch\s*\(/, "fetch( β€” no network calls; all I/O goes through ctx"],
+ [/\bXMLHttpRequest\b/, "XMLHttpRequest β€” no network calls"],
+ [/\bnew\s+WebSocket\b/, "new WebSocket β€” no network calls"],
+ [/\blocalStorage\b/, "localStorage β€” no browser storage; state lives in the fold"],
+ [/\bindexedDB\b/i, "indexedDB β€” no browser storage; state lives in the fold"],
+ [/\bdocument\s*\.\s*cookie\b/, "document.cookie β€” no browser storage"],
+ [/\bsetInterval\s*\(/, "setInterval( β€” use ctx.interval so the framework stops the timer"],
+ [/\beval\s*\(/, "eval( β€” never"],
+ [/\bimport\s*\(/, "dynamic import( β€” imports must be static, from ../apps.js or ../format.js"],
+];
+for (const [re, why] of FORBIDDEN) {
+ if (re.test(bare)) fail("forbidden: " + why);
+ else pass("no " + why.split(" β€” ")[0]);
+}
+
+// ---- 7. innerHTML only from plain string literals ----------------------------
+// Scan the comment-stripped source line by line: `.innerHTML =` is allowed only
+// when the right-hand side is a single plain string literal (no ${} templates,
+// no concatenation, no variables). `+=` is never allowed.
+{
+ let bad = 0;
+ const lines = code.split("\n");
+ for (let ln = 0; ln < lines.length; ln++) {
+ const m = lines[ln].match(/\.innerHTML\s*(\+?=)([^=].*)$/);
+ if (!m) continue;
+ const rhs = m[2].trim().replace(/;\s*$/, "");
+ const plain =
+ m[1] === "=" &&
+ (/^"([^"\\]|\\.)*"$/.test(rhs) || /^'([^'\\]|\\.)*'$/.test(rhs) || (/^`[^`]*`$/.test(rhs) && !rhs.includes("${")));
+ if (!plain) {
+ fail(`innerHTML assignment on line ${ln + 1} is not a plain string literal β€” build DOM nodes / use textContent`);
+ bad++;
+ }
+ }
+ if (!bad) pass("no innerHTML assignments of dynamic content");
+}
+
+// ---- 8. imports only from ../apps.js and ../format.js ------------------------
+{
+ const ALLOWED = new Set(["../apps.js", "../format.js"]);
+ const specs = [];
+ for (const m of code.matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']/gm)) specs.push(m[1]);
+ for (const m of code.matchAll(/^\s*import\s+["']([^"']+)["']/gm)) specs.push(m[1]);
+ const badImports = specs.filter((s) => !ALLOWED.has(s));
+ if (badImports.length === 0) pass("imports only from ../apps.js / ../format.js");
+ else fail(`imports not allowed: ${badImports.join(", ")} (only ../apps.js and ../format.js)`);
+}
+
+console.log(failures ? `\n${failures} check(s) failed` : "\nall checks passed");
+process.exit(failures ? 1 : 0);