# Template library, phase 2: files, links, versions, authors — Design Document **Status:** Draft **Last updated:** 2026-09-20 **Scope:** Lets a template leave the server it was saved on (as a file, or by a link), lets it improve over time without losing its history, and gives authors a page. Builds on [template-library.md](template-library.md) (phase 1, shipped) and [template-closeout.md](template-closeout.md) (phases A and B, shipped). Touches the template model and API, the template page, project creation, and adds the first signed-out data page. --- ## 0. Problem / motivating requirements Phase 1 made a library inside one server. Three things are still missing. - **R1. Hand a plan to someone on another server.** An expert who ran a launch well should be able to give the plan to anyone, with no account on the expert's server. A file is the floor; a link is the convenience. - **R2. Take a plan in safely.** A template file comes from a stranger. Importing one must not be able to harm the server, and the person importing must see what they are getting first. - **R3. A plan gets better without starting over.** "Save it again if you learned something" is in the README, but saving again today makes a *separate* template with no votes, no comments and no track record. A better version must keep all three. - **R4. Lessons turn into the next version.** Close-out lessons already reach the template's owner. They should be in front of the owner at the moment they write "what I learned". - **R5. Know which version a run used,** so a template can say whether version 3 runs better than version 2 (close-out phase C). - **R6. Authors are people.** Someone who shares good plans should have a page: who they are, a link, their plans. It must not undo the rule that the people directory only shows people you already work with. - **R7. Nothing here needs the internet, and nothing here makes the server fetch an address a user typed.** (See 3.3: this removes the riskiest piece of the original sketch.) Not in scope: the community gallery (phase 3), paid or licensed-for-a-fee templates, editing a template's events in place, merging two versions, comments on versions. ## 1. Current state (grounding) - **A template is already a portable document.** `ProjectTemplate.categories` and `.tasks` are plain JSON in the shape `spec_from_project` writes (`backend/projects/templates.py`): relative offsets, durations, notes, key-milestone flags, dependencies by index, to-dos with day offsets. Nothing in it refers to a database row. - **What other people get is filtered at read time** (`library.Resolved.spec`): the owner's `share_notes` / `share_todos` switches apply to every path. An export must go through the same door. - **`GET /api/templates//`** already returns the full plan to anyone allowed to see it. There is no export, no import, and no way to create a template except "save from a project" and "fork". - **The API accepts JSON only** (`DEFAULT_PARSER_CLASSES` is `JSONParser`), and the frontend already saves server-made files (`saveBlob` in `ExportModal.jsx`). So a file import needs no upload handling: the browser reads the file and posts its JSON. - **The server has no HTTP client** (`requests` is not installed) and has never fetched a URL. - **Every data endpoint requires sign-in.** The only public endpoints are register, sign-in, password reset and health. There is no Content-Security-Policy, so the browser may fetch from other origins. - **No versions.** `ProjectTemplate` has `updated_at` and nothing else; `Project.source_template_key` and `source_template_span` exist, `source_template_version` does not. Track records (`library.track_records`) group by key only. - **No author identity beyond `username`.** The user model has no display name, bio or link. The people directory (`directory.py`) deliberately shows only people you share a project or team with; a published template shows its author's username only if they chose `author_display = name`. - **`origin_project`** (close-out phase B) and **`forked_from_key`** already record where a template came from *inside* a server. Nothing records where an *imported* one came from. ## 2. Prior art / research *From working knowledge, not fresh testing.* - **Grafana dashboards, Home Assistant blueprints, n8n workflows:** the artifact is a JSON or YAML file; you share the file or a URL to it; the app has "import" and shows what it found before saving. Home Assistant's "import blueprint" button on a web page is the model for a link that opens *your own* server's import screen. - **Browser-side fetch versus server-side fetch.** Tools that fetch a user-supplied URL from the server have a long history of server-side request forgery (reaching cloud metadata addresses and internal services). Tools that let the *browser* fetch it have none of that class of bug: the browser can only reach what the user could already reach, and cross-origin rules apply. GitHub raw files and gists are served with open cross-origin headers, which covers the realistic cases. - **Versioning in template galleries** (Figma community files, Notion templates, VS Code extensions): one listing, many versions, a changelog line per version, reviews and installs stay with the listing. Nobody makes you start from zero to publish an improvement. - **Author pages** (Figma, Printables, Observable): name, short bio, one link, their work. The useful number is about the work ("12 plans, run 140 times"), not about the person. - **Licences:** galleries that skip the question end up with ambiguity; the ones that ask offer two or three well-known choices, not a text box. ## 3. Proposed design ### 3.1 The file `.plan.json`, UTF-8, one template: ``` { "format": "plan-template", "version": 1, "name", "summary", "description", "group", "tags": [...], "author": "name or null", "license": "CC-BY-4.0" | "CC0-1.0" | null, "template_version": 3, "version_note": "what I learned", "categories": [...], "tasks": [...], // exactly the stored shape "source": { "exported_at": "...", "track_record": { ...totals as shown on the page... } } } ``` - **Export goes through the same filter as viewing.** A non-owner gets the plan as they see it. The **owner** exporting their own template gets the same review step as publishing (the list of every note and to-do title, the warnings, the three switches), because a file travels further than any share setting. - The file carries **totals only** in `source.track_record`, under the display rules already in force (minimums, median, rounding). An importing server shows them as **"At its source: …"**, clearly separate from its own record, because it cannot verify them. No lessons, no comments, no votes, no usernames except the author's chosen display name. ### 3.2 Import - **Templates → Import** takes a file (read in the browser, posted as JSON to `POST /api/templates/import/`) and shows a **preview before saving** (`dry_run`): name, author, licence, the plan drawing, counts, and anything that looks like a link or an address in its text. - **The server treats the document as hostile:** at most 1 MB; `format` and `version` must match; known keys only, each with a type and a length limit; at most 500 events, 40 tracks, 2,000 to-dos; offsets and durations within sane bounds (no plan longer than 20 years); dependency indexes in range, **no loops** (the same check the event API uses); colours must be hex; all text is stored as text and rendered as text (React already escapes; no HTML, no Markdown). - An imported template is **private**, owned by the importer, with an `imported_from` record (author as stated, licence, source totals, file or link, when). Its page says "Imported · by *author* · *licence*". Its own track record starts at zero. - Importing is throttled per user. ### 3.3 Links, without the server fetching anything Two kinds of link, and **the server never requests a user-supplied address** (R7): 1. **Import from a link.** The import screen has a URL box. The **browser** fetches it and posts the JSON through the same import endpoint and the same checks. Works for anything served with open cross-origin headers (GitHub raw files and gists, and public template links from 3.4). If the browser cannot fetch it, the message says so and suggests downloading the file instead. 2. **"Open in my server" links.** `https:///templates/import?url=
` opens the import screen with the box filled in, so a web page can offer an "Import this plan" button (the Home Assistant pattern). Nothing is fetched until the person clicks, and nothing is saved until they have seen the preview. This replaces the server-side fetch in the original sketch. It removes the whole class of server-side request forgery, needs no HTTP client on the server, and works on an air-gapped server as far as that server's users can reach anything at all. ### 3.4 Public template links (optional, off by default) - A template's owner can turn on **"Anyone with the link can view"**, which mints an unguessable link: `/t/`. It shows a signed-out page with the name, summary, description, the plan drawing, the track record totals and the author's chosen display name. **No comments, no usernames, no votes list.** "Use this template" leads to sign-in. - The same token serves the file at `/t/.plan.json` with open cross-origin headers, which is what makes 3.3 work between two servers. - Turning it off, unpublishing, or deleting kills the link. The publish review applies first. - **Server setting `TEMPLATE_PUBLIC_LINKS`** (`off` by default). This is the app's **first signed-out data page**, so it is opt-in per server as well as per template, throttled per address, read-only, and served by one narrow view that can return nothing but that document. ### 3.5 Versions - **`TemplateVersion`**: `template`, `number`, `categories`, `tasks`, `note`, `created_at`. The template row keeps holding the current plan, so nothing that reads a template changes. - **Saving a new version:** "Save as Template" gains a choice when you own templates: *a new template*, or *a new version of …* (preselected when the project was started from one of yours). It asks for **"What I learned"**, and shows the **lessons received since the last version** beside the box (close-out phase B), so they can be folded in (R4). - Votes, comments, the track record, sharing settings and links all **stay with the template** (R3). - The template page gets a **Versions** list: number, date, note, and how many runs used it. Older versions can be previewed; using or exporting always takes the latest. Restoring an old version is "save it as the newest" and is left for later. - **`Project.source_template_version`** is set at instantiation (R5). Existing projects count as version 1. - **Close-out phase C:** the track record keeps its overall totals and adds the same totals for the **current version** once it has enough runs of its own, under the same minimums, so the page can say "version 3: typically runs 2% long (overall 7%)". - A fork records `forked_from_version`. ### 3.6 Author pages - **`AuthorProfile`** (one per user, optional): `display_name`, `bio` (500 characters), `link` (https only, shown with `rel="nofollow noopener"`). - **`/people/`** exists **only for someone who has shared a template under their name**, and shows only the named templates **the viewer is allowed to see**, plus their combined totals ("6 plans · started 140 times · 96 finished"). If the viewer can see none, the page is a 404, the same as for a user who does not exist. Templates shared without a name are never listed. - This keeps the directory rule intact: an author page reveals nothing that the templates the viewer can already see did not reveal, and it cannot be used to look people up. - On a public template link (3.4) the author is a display name, not a link to a page. ### 3.7 Licence A `license` field on a template: **not stated** (default), **CC BY 4.0** (reuse with credit), or **CC0** (no conditions). Shown on the template page, asked when sharing to the whole server or exporting, carried in the file, and kept on import. No free-text licences. ### 3.8 Permissions impact | Action | Who | |---|---| | Export a template | anyone who can see it (they get what they can see); owners get the review step | | Import | any signed-in user; the result is private to them | | Save a new version, write its note, turn a public link on or off, set the licence | the template's owner | | See versions and their notes | anyone who can see the template | | View a public link | anyone with the token, if the server allows public links | | Edit an author profile | that user | | See an author page | signed-in users who can see at least one of that author's named templates | All decided on the server from the token and the database, as in [PERMISSIONS.md](../PERMISSIONS.md). The file's `author` field is a claim, displayed as a claim; it grants nothing. ## 4. Alternatives considered - **Server-side "import from URL"** (the original sketch). It works for any address regardless of cross-origin headers, and it is the classic route to server-side request forgery; defending it properly (address filtering after DNS resolution, redirect handling, timeouts, size caps) is a permanent maintenance burden for a convenience. Rejected in favour of the browser fetching. - **Multipart file upload.** Unnecessary: the browser reads a small JSON file and posts JSON, which the API already accepts. No new parser, no temp files. - **A new template per version, linked in a chain.** Simple to build, and it is exactly the problem being solved: votes, comments and history scatter across the chain. - **Readable public links** (`/t/launch-playbook`). Nicer to look at, guessable, and they invite name squatting. A random token is a capability: having it is the permission. - **Public author pages.** Better for the authors, and the first step to the app becoming a people directory. Kept signed-in and scoped to what the viewer can already see. - **Carry votes and comments in the file.** They are about a community on one server; exported they are unverifiable noise with usernames attached. - **Sign the files.** Would let an importing server trust `author` and the source totals. Real value only once there is a gallery to trust; revisit with phase 3. ## 5. Phasing Each step ships by itself. - **2a — the file.** Export (with the owner's review step), import from a file with preview and the hostile-document checks, `imported_from`, the licence field. *Satisfies R1 (the floor) and R2. This is the step that lets a plan be handed to anyone.* - **2b — versions.** `TemplateVersion`, "save as a new version" with lessons alongside, the Versions list, `source_template_version`, per-version totals. *Satisfies R3, R4, R5 and close-out phase C.* - **2c — author pages.** `AuthorProfile`, the page, links from template cards. *Satisfies R6.* - **2d — links.** Import from a link via the browser, "open in my server" links, and public template links behind `TEMPLATE_PUBLIC_LINKS`. *Completes R1. Last because it adds the first signed-out data page.* - **Later / maybe:** restoring and comparing versions; signed files; following a template for new versions; "import" buttons on the project's website for the built-in plans. ## 6. Cost & risk - **Effort:** 2a **M**, 2b **M**, 2c **S to M**, 2d **M**. Together about the size of library phase 1. - **Migration / data risk:** additive. Existing templates become version 1; existing projects are treated as having used version 1. - **Security.** Import is the app's first path where a stranger's document becomes stored data: it gets a strict schema and its own adversarial test file (oversized, deeply nested, wrong types, dependency loops, absurd offsets, markup in text) before any UI. Dropping the server-side fetch removes the largest risk in the original sketch. 2d adds a signed-out page: one narrow view, throttled, off by default, tokens unguessable and revocable. - **Privacy.** A file outlives every share setting, hence the review step on the owner's export. The file carries totals only, never lessons, comments or usernames. Author pages are scoped to what the viewer can already see. - **Trust in imported numbers.** "At its source" totals are claims. They are labelled as such and never mixed into the local record. - **Perf / UX:** versions add one row per save; per-version totals reuse the grouped track-record query with one more grouping key. No timeline impact. - **Blast radius:** the instantiate path gains a version number; "Save as Template" gains a choice; the rest is new surface. ## 7. Open questions / decisions needed - [ ] **Order:** 2a, 2b, 2c, 2d as proposed? (2b first would suit "improve my own plans" more than "hand a plan to someone".) - [ ] **Browser-side link import instead of a server-side fetch** (proposed). It will not work for a host that blocks cross-origin requests; the fallback is downloading the file. - [ ] **Public template links:** build them at all in this phase? If so, on for the demo and **off on the production server** unless you say otherwise. - [ ] **Licence choices:** not stated / CC BY 4.0 / CC0 (proposed), and should sharing to the whole server *require* a choice, or merely offer it? - [ ] **Source totals in the file:** include them, labelled "at its source" (proposed), or leave the file as the plan alone? - [ ] **Author pages:** signed-in and scoped as proposed, or not needed yet? - [ ] **File extension and format name:** `.plan.json` / `plan-template` (neutral on purpose, so it survives any future change to the product's name).