oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

Sign-in, and documents worth looking at — Implementation Plan

> 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.

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.

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.

Tech Stack: Go 1.26, rastrillo v0.22.0 (auth, sessions, csrf, scope), internal/core (wazero + Rust CRDT), SQLite.

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).

Why this plan exists

Paul 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.

The 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.

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.

Global Constraints

  • CGO_ENABLED=0 everywhere. No cgo.
  • rastrillo pinned to v0.22.0.
  • 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.
  • *Bare `rst-` attribute grammar.** A test walks our view FS and enforces it.
  • Every user-visible string is a catalog key.
  • 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.
  • No secret, token or address is ever logged. Magic-link tokens are 256-bit random, stored only as SHA-256.
  • Work on a branch; push regular commits; squash-merge when done, approved, and CI green.

---

Task 1: Sign-in with a magic link

Files:

  • Create: internal/docs/auth.go, internal/docs/auth_test.go
  • Modify: internal/docs/app.go, cmd/docs/main.go, internal/docs/migrations/

Interfaces:

  • Produces: GET /signin (the form), POST /signin (sends the link), GET /signin/{token} (completes), POST /signout. A signed-in person is readable by handlers.
  • [ ] Step 1: Read the plugin's contract first
cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.22.0/SKILL.md"

Find 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.

Note 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.

  • [ ] Step 2: Write the failing test
func TestSignInRequiredForEverything(t *testing.T) {
	app, err := docs.NewForTest(t)
	if err != nil {
		t.Fatal(err)
	}
	for _, path := range []string{"/", "/d/anything"} {
		req := httptest.NewRequest(http.MethodGet, path, nil)
		rec := httptest.NewRecorder()
		app.Handler().ServeHTTP(rec, req)
		if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
			t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", path, rec.Code)
		}
	}
}

func TestMagicLinkSignsSomebodyIn(t *testing.T) {
	app, err := docs.NewForTest(t)
	if err != nil {
		t.Fatal(err)
	}
	link, err := app.TestSignIn(t, "reader@example.com")
	if err != nil {
		t.Fatalf("TestSignIn: %v", err)
	}
	// Following the link must establish a session.
	req := httptest.NewRequest(http.MethodGet, link, nil)
	rec := httptest.NewRecorder()
	app.Handler().ServeHTTP(rec, req)
	if len(rec.Result().Cookies()) == 0 {
		t.Fatal("following the magic link set no session cookie")
	}
}

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.

  • [ ] Step 3: Run it and watch it fail. CGO_ENABLED=0 go test ./internal/docs/ -run 'TestSignIn|TestMagicLink'
  • [ ] Step 4: Implement

Mount 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.

Mail 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.

  • [ ] Step 5: Run and watch pass.
  • [ ] Step 6: Commit
git add internal/docs cmd
git commit -m "app: sign in with a magic link

rastrillo's auth plugin, magic link only and no passkey second factor --
a passkey would enrol a credential whose relying party is this instance's
host, and a person in several teams would enrol several. Magic link
enrols nothing, so the one-relying-party ruling is untouched.

Per spec §8.1 this is the single-instance shape's real answer, not a
placeholder: a lone instance has no one-person-many-teams problem, which
is the problem the home exists to solve."

---

Task 2: Documents belong to people

Files:

  • Create: internal/docs/migrations/0002_owners.sql
  • Modify: internal/docs/store.go, internal/docs/models.go, internal/docs/handlers.go, internal/docs/store_test.go

Interfaces:

  • Consumes: the signed-in person from Task 1.
  • Produces: Store.Create/List/Get all taking an owner; a document not yours 404s.
  • [ ] Step 1: Write the failing test
func TestADocumentYouDoNotOwnIsNotThere(t *testing.T) {
	app, _ := docs.NewForTest(t)
	ctx := context.Background()

	alice := app.TestPerson(t, "alice@example.com")
	bob := app.TestPerson(t, "bob@example.com")

	doc, err := app.Store().Create(ctx, alice, "Alice's private notes")
	if err != nil {
		t.Fatal(err)
	}

	// Bob must not be able to fetch it, and must not be told it exists.
	if _, err := app.Store().Get(ctx, bob, doc.Sqid); err == nil {
		t.Fatal("Bob fetched Alice's document")
	}

	list, err := app.Store().List(ctx, bob)
	if err != nil {
		t.Fatal(err)
	}
	if len(list) != 0 {
		t.Errorf("Bob's list has %d documents, want 0", len(list))
	}
}

func TestUnownedDocumentOverHTTPIs404Not403(t *testing.T) {
	app, _ := docs.NewForTest(t)
	ctx := context.Background()
	alice := app.TestPerson(t, "alice@example.com")
	doc, _ := app.Store().Create(ctx, alice, "Alice's private notes")

	req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)
	rec := httptest.NewRecorder()
	app.Handler().ServeHTTP(rec, req)

	if rec.Code != http.StatusNotFound {
		t.Errorf("status %d, want 404 — a document you cannot see does not exist (spec §4.2)", rec.Code)
	}
}

TestPerson and TestRequest are scaffolding in testing.go: one creates a person, the other builds a request already carrying that person's session.

  • [ ] Step 2: Run and watch fail.
  • [ ] Step 3: Implement

Add 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.

403 must never appear. A document you cannot see returns 404, so a stranger cannot learn a sqid exists by probing.

  • [ ] Step 4: Run and watch pass.
  • [ ] Step 5: Commit
git add internal/docs
git commit -m "app: documents belong to people

Every read and write scoped by owner, through one function rather than
scattered WHERE clauses. Not scope.Owned: verified in plan 1 that it is
Where(\"user_id = ?\") and returns a short list rather than an error on a
shared table, which looks like an empty library rather than a bug.

A document you cannot see 404s and never 403s, so a stranger cannot
learn a sqid exists by probing it."

---

Task 3: Sample documents worth reading

Files:

  • Create: internal/docs/seed.go, internal/docs/seed_test.go, internal/docs/seeddata/
  • Modify: cmd/docs/main.go, scripts/run-dev.sh

Interfaces:

  • Produces: docs.Seed(ctx, app, owner) error, and a -seed flag that populates a fresh database.

The reading view currently shows two words. Sample content is what makes the canvas, the typography and the empty-versus-full states worth looking at.

  • [ ] Step 1: Write the failing test
func TestSeedProducesReadableDocuments(t *testing.T) {
	app, _ := docs.NewForTest(t)
	ctx := context.Background()
	owner := app.TestPerson(t, "seed@example.com")

	if err := docs.Seed(ctx, app, owner); err != nil {
		t.Fatalf("Seed: %v", err)
	}

	list, err := app.Store().List(ctx, owner)
	if err != nil {
		t.Fatal(err)
	}
	if len(list) < 4 {
		t.Fatalf("seeded %d documents, want at least 4", len(list))
	}

	// Every seeded document must have real content, through the CRDT.
	for _, d := range list {
		text, err := app.Store().Text(ctx, d)
		if err != nil {
			t.Fatalf("Text(%q): %v", d.Title, err)
		}
		if len(text) < 200 {
			t.Errorf("%q has %d characters — seed content should be substantial", d.Title, len(text))
		}
	}
}
  • [ ] Step 2: Run and watch fail.
  • [ ] Step 3: Write the content

Four 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.

Suggested 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.

Content lives in seeddata/ as plain .txt files embedded with go:embed — editable without touching Go.

  • [ ] Step 4: Apply it through the CRDT, not the database

Each document's text becomes a Yjs update applied via Store.ApplyUpdate. It must arrive the same way a person's typing will.

There 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:

  1. 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).
  2. 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.

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.

  • [ ] Step 5: Run and watch pass.
  • [ ] Step 6: Wire -seed and check it looks right

Add -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?

  • [ ] Step 7: Commit
git add internal/docs cmd scripts
git commit -m "app: sample documents with real content

Four to six documents of plausible English prose rather than lorem
ipsum, because Latin filler hides the line-length and rhythm problems
the canvas exists to get right.

Applied as Yjs updates through the CRDT, the same route a person's
typing will take -- not rows written into a table."

---

Task 4: Take it live

Files:

  • Modify: .amadan/README.md, AGENTS.md

Docs can now go on a public URL, because every route requires a signed-in person and documents are scoped to their owner.

  • [ ] Step 1: Check what deployment needs

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.

  • [ ] Step 2: Verify the guards hold

Corrected 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):

  • an unset $DOCS_ORIGIN refuses to start;
  • unset mail configuration ($DOCS_SMTP_HOST/$DOCS_SMTP_FROM) refuses to start;
  • $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.

Paste the refusals — all three.

  • [ ] Step 3: Ship
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o docs-linux-arm64 ./cmd/docs
carlos ship --account bds --app docs --kind binary --target linux-arm64 --label "first live" docs-linux-arm64

No -tags release: that tag has no meaning left in this codebase (see Step 2).

Then 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.

  • [ ] Step 4: Prove it from outside

Fetch 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.

  • [ ] Step 5: Update the warnings

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.

---

Self-Review

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.

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.

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.

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.