oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1# Sign-in, and documents worth looking at — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Real per-person sign-in at the instance, so Docs can go live on a public URL — and sample documents with enough content that the reading view is worth looking at.
6
7**Architecture:** rastrillo's `auth` plugin: magic link, auto-upgrading to keymail when an address resolves to a claimed inbox. Documents become owned, and every route requires a signed-in person. Seed content is real prose applied as Yjs updates through the CRDT, not rows in a table.
8
9**Tech Stack:** Go 1.26, rastrillo `v0.22.0` (`auth`, `sessions`, `csrf`, `scope`), `internal/core` (wazero + Rust CRDT), SQLite.
10
11**Spec:** `docs/superpowers/specs/2026-08-30-docs-design.md` — §8.1 (the two deployment shapes), §8.2 (sharing), §4.2 (the 404-not-403 contract), §6.2 (where zero-JS stops).
12
13## Why this plan exists
14
15Paul asked for Docs live at `console.carloku.com/a/bds/apps/docs`. It cannot go on a public URL as it stands: no route requires sign-in, so it is a public read-and-write documents endpoint.
16
17The home would solve it and is weeks away — verified, not assumed: `oficina/home` is a skeleton that boots and serves one page saying it is not built. Nothing about assertions exists anywhere.
18
19**But §8.1 was reframed on 2026-08-31 and this is no longer a stopgap.** A single instance signs people in itself; the home is what a *suite* adds. So this plan builds the single-instance path, which is a real deployment shape with a permanent answer, and the suite path arrives later as an addition rather than a replacement.
20
21## Global Constraints
22
23- **`CGO_ENABLED=0` everywhere.** No cgo.
24- **rastrillo pinned to `v0.22.0`.**
25- **Read `SKILL.md` from the module before writing app code.** It carries the identity plugin's contract, the scoping rules, and the mass-assignment rule. Where it contradicts this plan, **SKILL.md wins** — say so in the report.
26- **Bare `rst-*` attribute grammar.** A test walks our view FS and enforces it.
27- **Every user-visible string is a catalog key.**
28- **`scope.Owned` must NOT be used for documents.** Verified in plan 1: it is `Where("user_id = ?")`, and applied to a shared table it returns a short list rather than an error — a library that looks empty rather than broken. Docs scopes over its own ownership join, preserving 404-not-403.
29- **No secret, token or address is ever logged.** Magic-link tokens are 256-bit random, stored only as SHA-256.
30- Work on a branch; push regular commits; squash-merge when done, approved, and CI green.
31
32---
33
34### Task 1: Sign-in with a magic link
35
36**Files:**
37- Create: `internal/docs/auth.go`, `internal/docs/auth_test.go`
38- Modify: `internal/docs/app.go`, `cmd/docs/main.go`, `internal/docs/migrations/`
39
40**Interfaces:**
41- Produces: `GET /signin` (the form), `POST /signin` (sends the link), `GET /signin/{token}` (completes), `POST /signout`. A signed-in person is readable by handlers.
42
43- [ ] **Step 1: Read the plugin's contract first**
44
45```bash
46cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.22.0/SKILL.md"
47```
48
49Find what `auth` requires: how it is mounted, what it needs from `sessions`, how it delivers mail, and what it expects of the app. **Quote the relevant part in your report** — later tasks depend on you having read it rather than inferred it.
50
51Note especially: read the viewer with `auth.From(r)` or `sessions.Current(r)`, **never `sessions.UserID`** — it returns `(0, false)` for an email subject, and dropping that `ok` scopes every query to uid 0.
52
53- [ ] **Step 2: Write the failing test**
54
55```go
56func TestSignInRequiredForEverything(t *testing.T) {
57 app, err := docs.NewForTest(t)
58 if err != nil {
59 t.Fatal(err)
60 }
61 for _, path := range []string{"/", "/d/anything"} {
62 req := httptest.NewRequest(http.MethodGet, path, nil)
63 rec := httptest.NewRecorder()
64 app.Handler().ServeHTTP(rec, req)
65 if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
66 t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", path, rec.Code)
67 }
68 }
69}
70
71func TestMagicLinkSignsSomebodyIn(t *testing.T) {
72 app, err := docs.NewForTest(t)
73 if err != nil {
74 t.Fatal(err)
75 }
76 link, err := app.TestSignIn(t, "reader@example.com")
77 if err != nil {
78 t.Fatalf("TestSignIn: %v", err)
79 }
80 // Following the link must establish a session.
81 req := httptest.NewRequest(http.MethodGet, link, nil)
82 rec := httptest.NewRecorder()
83 app.Handler().ServeHTTP(rec, req)
84 if len(rec.Result().Cookies()) == 0 {
85 t.Fatal("following the magic link set no session cookie")
86 }
87}
88```
89
90`TestSignIn` mints a link without sending mail and returns its path — put it in `internal/docs/testing.go`. It is test scaffolding for later tasks, which all need a signed-in request.
91
92- [ ] **Step 3: Run it and watch it fail.** `CGO_ENABLED=0 go test ./internal/docs/ -run 'TestSignIn|TestMagicLink'`
93
94- [ ] **Step 4: Implement**
95
96Mount rastrillo's `auth` plugin per SKILL.md. **Magic link only — do not enable a passkey second factor.** §8.1 explains why: a passkey enrols a credential whose relying party is this instance's host, and a person in several teams would enrol several. Magic link enrols nothing.
97
98Mail delivery in development must not require a mail server: write the link to the log, clearly marked, so `run-dev.sh` remains one command. In any other environment, real delivery is required and its absence is a startup refusal — the same fail-closed shape as the origin check.
99
100- [ ] **Step 5: Run and watch pass.**
101
102- [ ] **Step 6: Commit**
103
104```bash
105git add internal/docs cmd
106git commit -m "app: sign in with a magic link
107
108rastrillo's auth plugin, magic link only and no passkey second factor --
109a passkey would enrol a credential whose relying party is this instance's
110host, and a person in several teams would enrol several. Magic link
111enrols nothing, so the one-relying-party ruling is untouched.
112
113Per spec §8.1 this is the single-instance shape's real answer, not a
114placeholder: a lone instance has no one-person-many-teams problem, which
115is the problem the home exists to solve."
116```
117
118---
119
120### Task 2: Documents belong to people
121
122**Files:**
123- Create: `internal/docs/migrations/0002_owners.sql`
124- Modify: `internal/docs/store.go`, `internal/docs/models.go`, `internal/docs/handlers.go`, `internal/docs/store_test.go`
125
126**Interfaces:**
127- Consumes: the signed-in person from Task 1.
128- Produces: `Store.Create/List/Get` all taking an owner; a document not yours 404s.
129
130- [ ] **Step 1: Write the failing test**
131
132```go
133func TestADocumentYouDoNotOwnIsNotThere(t *testing.T) {
134 app, _ := docs.NewForTest(t)
135 ctx := context.Background()
136
137 alice := app.TestPerson(t, "alice@example.com")
138 bob := app.TestPerson(t, "bob@example.com")
139
140 doc, err := app.Store().Create(ctx, alice, "Alice's private notes")
141 if err != nil {
142 t.Fatal(err)
143 }
144
145 // Bob must not be able to fetch it, and must not be told it exists.
146 if _, err := app.Store().Get(ctx, bob, doc.Sqid); err == nil {
147 t.Fatal("Bob fetched Alice's document")
148 }
149
150 list, err := app.Store().List(ctx, bob)
151 if err != nil {
152 t.Fatal(err)
153 }
154 if len(list) != 0 {
155 t.Errorf("Bob's list has %d documents, want 0", len(list))
156 }
157}
158
159func TestUnownedDocumentOverHTTPIs404Not403(t *testing.T) {
160 app, _ := docs.NewForTest(t)
161 ctx := context.Background()
162 alice := app.TestPerson(t, "alice@example.com")
163 doc, _ := app.Store().Create(ctx, alice, "Alice's private notes")
164
165 req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)
166 rec := httptest.NewRecorder()
167 app.Handler().ServeHTTP(rec, req)
168
169 if rec.Code != http.StatusNotFound {
170 t.Errorf("status %d, want 404 — a document you cannot see does not exist (spec §4.2)", rec.Code)
171 }
172}
173```
174
175`TestPerson` and `TestRequest` are scaffolding in `testing.go`: one creates a person, the other builds a request already carrying that person's session.
176
177- [ ] **Step 2: Run and watch fail.**
178
179- [ ] **Step 3: Implement**
180
181Add an owner to `documents` and scope every read and write by it. **Do not use `scope.Owned`** — see Global Constraints. Write the scoping as one function that every query goes through, and test that function directly.
182
183**403 must never appear.** A document you cannot see returns 404, so a stranger cannot learn a sqid exists by probing.
184
185- [ ] **Step 4: Run and watch pass.**
186
187- [ ] **Step 5: Commit**
188
189```bash
190git add internal/docs
191git commit -m "app: documents belong to people
192
193Every read and write scoped by owner, through one function rather than
194scattered WHERE clauses. Not scope.Owned: verified in plan 1 that it is
195Where(\"user_id = ?\") and returns a short list rather than an error on a
196shared table, which looks like an empty library rather than a bug.
197
198A document you cannot see 404s and never 403s, so a stranger cannot
199learn a sqid exists by probing it."
200```
201
202---
203
204### Task 3: Sample documents worth reading
205
206**Files:**
207- Create: `internal/docs/seed.go`, `internal/docs/seed_test.go`, `internal/docs/seeddata/`
208- Modify: `cmd/docs/main.go`, `scripts/run-dev.sh`
209
210**Interfaces:**
211- Produces: `docs.Seed(ctx, app, owner) error`, and a `-seed` flag that populates a fresh database.
212
213The reading view currently shows two words. Sample content is what makes the canvas, the typography and the empty-versus-full states worth looking at.
214
215- [ ] **Step 1: Write the failing test**
216
217```go
218func TestSeedProducesReadableDocuments(t *testing.T) {
219 app, _ := docs.NewForTest(t)
220 ctx := context.Background()
221 owner := app.TestPerson(t, "seed@example.com")
222
223 if err := docs.Seed(ctx, app, owner); err != nil {
224 t.Fatalf("Seed: %v", err)
225 }
226
227 list, err := app.Store().List(ctx, owner)
228 if err != nil {
229 t.Fatal(err)
230 }
231 if len(list) < 4 {
232 t.Fatalf("seeded %d documents, want at least 4", len(list))
233 }
234
235 // Every seeded document must have real content, through the CRDT.
236 for _, d := range list {
237 text, err := app.Store().Text(ctx, d)
238 if err != nil {
239 t.Fatalf("Text(%q): %v", d.Title, err)
240 }
241 if len(text) < 200 {
242 t.Errorf("%q has %d characters — seed content should be substantial", d.Title, len(text))
243 }
244 }
245}
246```
247
248- [ ] **Step 2: Run and watch fail.**
249
250- [ ] **Step 3: Write the content**
251
252Four to six documents of genuinely plausible prose — several paragraphs each, varied lengths so the list shows range. **Write real sentences, not lorem ipsum**: the point is to see how the canvas reads, and Latin filler hides line-length and rhythm problems that English exposes.
253
254Suggested shapes, all fictional: a project proposal, meeting notes with attendees and actions, a short piece of documentation, a letter, and one deliberately long document so the canvas can be judged at length.
255
256Content lives in `seeddata/` as plain `.txt` files embedded with `go:embed` — editable without touching Go.
257
258- [ ] **Step 4: Apply it through the CRDT, not the database**
259
260Each document's text becomes a **Yjs update applied via `Store.ApplyUpdate`**. It must arrive the same way a person's typing will.
261
262There is no Go-side Yjs *encoder* — `internal/core` applies updates and reads text, and the ABI exposes no insert. **Resolve this and report which route you took:**
2631. Add an ABI function that inserts text into a document and returns the resulting update — a real ABI change, and the honest one if editing needs it anyway (it does; the next plan writes it).
2642. Generate the updates offline with the Node fixture generator (`fixtures/generate.mjs` already uses real `yjs`), commit the `.bin` files as seed fixtures, and have the seeder apply them.
265
266**Route 2 is likely right for this plan** — it reuses machinery that exists, keeps the ABI stable, and the fixtures are honest artefacts of real `yjs`. Route 1 is a bigger change belonging to the editing plan. If you choose route 1 anyway, say why.
267
268- [ ] **Step 5: Run and watch pass.**
269
270- [ ] **Step 6: Wire `-seed` and check it looks right**
271
272Add `-seed` to `cmd/docs/main.go` and use it in `run-dev.sh` so one command gives a populated app. Then run it, curl the list and two documents, and **paste the rendered HTML**. Judge it: does the canvas read well at these lengths? Is the list's grouping doing anything visible with varied dates?
273
274- [ ] **Step 7: Commit**
275
276```bash
277git add internal/docs cmd scripts
278git commit -m "app: sample documents with real content
279
280Four to six documents of plausible English prose rather than lorem
281ipsum, because Latin filler hides the line-length and rhythm problems
282the canvas exists to get right.
283
284Applied as Yjs updates through the CRDT, the same route a person's
285typing will take -- not rows written into a table."
286```
287
288---
289
290### Task 4: Take it live
291
292**Files:**
293- Modify: `.amadan/README.md`, `AGENTS.md`
294
295Docs can now go on a public URL, because every route requires a signed-in person and documents are scoped to their owner.
296
297- [ ] **Step 1: Check what deployment needs**
298
299`carlos email enable` for account `bds`, since magic links need outbound mail. **If email is not available, stop and report** — a sign-in that cannot send its link is not sign-in, and a preview with mail stubbed out is a public app with extra steps.
300
301- [ ] **Step 2: Verify the guards hold**
302
303Corrected in a fix round on Task 1: there is no `-tags release` build and no dev-auth flag to exclude any more — the development-only stand-in identity that check used to verify the *absence* of was deleted outright once real sign-in made it dead code (`internal/docs/devauth.go` and both `cmd/docs/devauth_flag*.go` are gone; `release` is now an undefined build tag, so building with it would silently compile the ordinary binary and verify nothing). Verify instead, against a plain build (`CGO_ENABLED=0 go build ./cmd/docs`):
304
305- an unset `$DOCS_ORIGIN` refuses to start;
306- unset mail configuration (`$DOCS_SMTP_HOST`/`$DOCS_SMTP_FROM`) refuses to start;
307- `$DOCS_ENV=development` with **no** `-env` flag does **not** produce a development app — it must still refuse on the two points above, since `chooseEnv` (`cmd/docs/main.go`) never honours `"development"` from an environment variable, only from the literal `-env` flag. This is the property fix round 2 added: platform instance configuration is environment variables, so `$DOCS_ENV=development` sitting beside `$DOCS_ORIGIN`/`$DOCS_INSTANCE_KEY`/`$DOCS_SMTP_*` would otherwise be a production-reachable path to the public dev `InstanceKey` and to every magic link, token included, landing in the instance's own log.
308
309Paste the refusals — all three.
310
311- [ ] **Step 3: Ship**
312
313```bash
314CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o docs-linux-arm64 ./cmd/docs
315carlos ship --account bds --app docs --kind binary --target linux-arm64 --label "first live" docs-linux-arm64
316```
317
318No `-tags release`: that tag has no meaning left in this codebase (see Step 2).
319
320Then promote and create an instance per the platform's own flow — read `carlos deploy -h` and `carlos instances -h` rather than guessing, and **report each command's real output**.
321
322- [ ] **Step 4: Prove it from outside**
323
324Fetch the public URL and confirm: signed out redirects to sign-in; the sign-in form renders; `/api/version` reports the shipped sha. **Do not paste a working magic link into the report** — it is a credential.
325
326- [ ] **Step 5: Update the warnings**
327
328`AGENTS.md` currently says this app has no authentication and must not be deployed anywhere reachable. **That statement is now false and must change** — say what is true instead: every route requires sign-in, documents are owner-scoped, and the suite path (§8.1.1) is still to come.
329
330---
331
332## Self-Review
333
334**Spec coverage.** Implements §8.1's single-instance shape, §8.2's ownership, §4.2's 404-not-403. Does **not** implement: sharing with guests or links (§8.2's later half), the suite assertion path (§8.1.1), editing, the layout engine, sealing.
335
336**Placeholder scan:** none. Task 3 step 4 names an unresolved choice with a recommendation and requires the implementer to report which route they took — that is a decision point, not a placeholder.
337
338**Type consistency:** `TestSignIn`, `TestPerson` and `TestRequest` are introduced in Tasks 1–2 and used throughout; `Store.Create/List/Get` gain an owner parameter in Task 2 and Task 3's seeder uses the new signature.
339
340**Known risk carried forward:** `internal/core` caps its update log at 4096 with no state export. Seed documents are single updates so this plan will not approach it, but the editing plan will and must not paper over it.
341