A signer is not a user: the token is the authorization
The document surface works, but every document is still private to its owner. This lesson opens it up: the owner invites signers by email, and the document moves from draft to sent. The design choice underneath it is the lesson.
A signer is not a user. A signer has no account, no password, no login. They were invited by email and they hold a link — that is all. Modelling them as users would be wrong (they never register), and modelling ownership the L9 way would be impossible (they own nothing). A signers row is the signer: which document, which email, and a token.
The token IS the authorization. Look at what a signer sends: GET /sign/<token>. There is no document id in the request. The token hashes to exactly one signers row (token_hash is UNIQUE), and that row names its one document. A signer literally cannot express "give me document X" — there is no X to put in the request. Compare that with L9, where you fetch a document by id and then guard it with AND owner_id = $me. Both are correct, but they differ in kind:
- L9 is defensive: the id is attacker-controlled, so you add a check and must never forget it.
- The token is structural: there is no id to attack. The whole class of "what if they pass someone else's id" bug cannot be written, because the request has no id field.
That is the stronger pattern — it removes the bug rather than guarding against it. A garbage or withdrawn token maps to no row: a clean 404.
Store the hash, never the token — for the third time. Sessions, password resets, and now signer links all follow the same rule: the raw token goes out (in the URL), and only its SHA-256 is stored. You have seen this shape three times now, so name it: a bearer secret is stored hashed, so a leaked database yields no usable secrets. auth.GenerateToken — the same generator from the reset lesson — returns the raw token for the link and the hash for the row.
Inviting is more than one write, so it is one transaction. Sending a document for signature does two things: create a signers row per email, and flip the document draft → sent. If the third of five inserts failed after you had already flipped the status, you would have a sent document with a broken signer list — a half-invite. So all of it runs inside a single Postgres transaction (pool.Begin → Queries.WithTx(tx) → Commit): either every signer is created and the document is sent, or nothing changes and it stays a draft. This is the first place the app needs h.Pool — the raw pool, to open a transaction the query methods run inside.
This lesson:
- The
signerstable + queries, and the transaction plumbing (h.Pool). InviteSigners— create signers and flip to sent in one transaction — plus the owner's invite form and the signer roster.
(The link lands in the signer's inbox — printed to your console by the dev mailer from lesson 7. The page it opens is next lesson; this lesson is the owner's side.)