| 1 | -- Publishing: a link is one document's read-only capability, held by whoever |
| 2 | -- has the URL rather than by anybody the app can name. |
| 3 | -- |
| 4 | -- This is the third rung of the suite doctrine's ruling 15 — private, granted, |
| 5 | -- and a read-only link — and the ruling's other half is why there is no role |
| 6 | -- column here. Every write has a named actor; a link holds no name, so a link |
| 7 | -- can only ever read. A `role` column would be a place for somebody to later |
| 8 | -- write "writer" into, and the answer to "should an anonymous link be able to |
| 9 | -- edit" is already no (Paul's own Sheets ruling 12). The column that cannot |
| 10 | -- exist is the cheapest way to keep that decision. |
| 11 | -- |
| 12 | -- `token_hash` is a SHA-256 of the capability, never the capability itself |
| 13 | -- (suite doctrine ruling 16, following Sheets spec §8.3). The token is 128 |
| 14 | -- bits from crypto/rand, base64url, never derived from doc_id, sqid, title or |
| 15 | -- clock — a guessable token is the whole feature failing silently, and a |
| 16 | -- derived one is guessable by anybody who reads this file. |
| 17 | -- |
| 18 | -- Stored as a hash because a token is a STANDING KEY where a document is a |
| 19 | -- snapshot. Leaked content is what it was on the day it leaked; a leaked token |
| 20 | -- keeps opening the live document, every later revision, until somebody |
| 21 | -- notices. Hashing turns a backup or a replica from an ongoing capability into |
| 22 | -- a one-time disclosure. That argument holds here even though this app stores |
| 23 | -- its content in plaintext, which is the tempting reason to skip it. |
| 24 | -- |
| 25 | -- The consequence is that the app cannot show the URL twice. It is shown once, |
| 26 | -- in the response to the publish that minted it, and after that the share page |
| 27 | -- can say only whether a link exists. An owner who loses it replaces it. |
| 28 | -- |
| 29 | -- Unsalted, deliberately: a salt defends a small input space and there is not |
| 30 | -- one here — the token's own 128 bits are the salt. SHA-256 rather than a |
| 31 | -- password hash for the same reason, and because a public page load should not |
| 32 | -- do key-stretching work. |
| 33 | -- |
| 34 | -- UNIQUE(doc_id): one live link per document, so "the link" is never an |
| 35 | -- ambiguous phrase in the UI or in an incident. Turning publishing off DELETEs |
| 36 | -- the row, and turning it back on mints a fresh token — which makes "off" a |
| 37 | -- revocation rather than a pause. Somebody who kept a URL from last March does |
| 38 | -- not get back in when the owner republishes for a different audience. |
| 39 | -- |
| 40 | -- No expiry column, deliberately: an unexpiring link the owner can see and |
| 41 | -- switch off is honest, while an expiry nobody is reminded of is a promise the |
| 42 | -- app makes on the owner's behalf and then keeps only by accident. |
| 43 | CREATE TABLE IF NOT EXISTS links ( |
| 44 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 45 | doc_id INTEGER NOT NULL, |
| 46 | token_hash TEXT NOT NULL, |
| 47 | created_at DATETIME |
| 48 | ); |
| 49 | |
| 50 | CREATE UNIQUE INDEX IF NOT EXISTS idx_links_doc ON links (doc_id); |
| 51 | |
| 52 | -- The public read path has only a token to go on, so the lookup that has to be |
| 53 | -- fast — and single-row — leads on its hash. Byte-equality on an indexed |
| 54 | -- column is not a timing concern at 128 bits of entropy. |
| 55 | CREATE UNIQUE INDEX IF NOT EXISTS idx_links_token_hash ON links (token_hash); |
| 56 | |