| 1 | package slides |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // Models is every model the schema generator manages. Keep it in step |
| 6 | // with the structs below: `rastrillo migration generate` reads |
| 7 | // it to work out what the database should look like, and |
| 8 | // `rastrillo migration check` fails CI when it and the |
| 9 | // migrations disagree. |
| 10 | var Models = []any{ |
| 11 | &User{}, |
| 12 | &Deck{}, |
| 13 | &DeckUpdate{}, |
| 14 | &DeckSnapshot{}, |
| 15 | &DeckAsset{}, |
| 16 | &AssetUpload{}, |
| 17 | &DeckGrant{}, |
| 18 | &ShareLink{}, |
| 19 | &Presentation{}, |
| 20 | &Agent{}, |
| 21 | } |
| 22 | |
| 23 | // User is a person who has signed in here — spec §6.1's |
| 24 | // `users(id, email, name, colour)`, created on first sign-in by |
| 25 | // auth.go's ensureUser. |
| 26 | // |
| 27 | // It exists because under rastrillo/auth the session Subject is the |
| 28 | // verified email address, not a numeric id (SKILL.md §5's warning: |
| 29 | // sessions.UserID answers (0, false) and would scope every owned query |
| 30 | // to user_id = 0). Every later model's owner column points at User.ID, |
| 31 | // so the address has to become a row before anything can be scoped. |
| 32 | // |
| 33 | // Email carries the unique index because the address is the identity: |
| 34 | // two rows for one address would mean one person owning two disjoint |
| 35 | // sets of decks depending on which row a request happened to find. |
| 36 | // Stored normalized (auth.go's normalizeAddress) so "Paul@Example.test" |
| 37 | // and "paul@example.test" are the same person, which is what the |
| 38 | // allowlist already assumes. |
| 39 | type User struct { |
| 40 | ID int64 |
| 41 | Email string `gorm:"uniqueIndex"` |
| 42 | // Name is what other people see beside this person's cursor and in |
| 43 | // a deck's activity list. Empty until there is a screen to set it; |
| 44 | // callers fall back to Email. |
| 45 | Name string |
| 46 | // Colour is this person's presence colour (spec §3.2's coloured |
| 47 | // outline and cursor label). Assigned at creation from a fixed |
| 48 | // palette so two people in one deck are told apart by more than |
| 49 | // their name — and never by colour alone, per the app's own WCAG |
| 50 | // commitment. |
| 51 | Colour string |
| 52 | CreatedAt time.Time |
| 53 | } |
| 54 | |
| 55 | // Deck is one presentation: the row that owns a Y.Doc, not the document |
| 56 | // itself. The content lives in DeckUpdate's log (spec §2.1); what is on |
| 57 | // this row is everything a list, a link or a permission check needs |
| 58 | // without opening the document at all. |
| 59 | // |
| 60 | // Sqid is the deck's public name — ten random characters, unique, |
| 61 | // minted at creation and never derived from ID (decks.go's newSqid). It |
| 62 | // is also the Y.Doc's guid, so a document and its URL cannot drift |
| 63 | // apart. |
| 64 | // |
| 65 | // Title and Theme are duplicated: they are meta.title and meta.theme |
| 66 | // inside the document too, and both move together on every write. The |
| 67 | // copy exists so the deck list can render fifty rows without |
| 68 | // instantiating fifty wasm modules. |
| 69 | // |
| 70 | // Seq is the log's high-water mark and the optimistic lock every write |
| 71 | // takes: an insert at Seq+1 lands only if the row still reads Seq (see |
| 72 | // decks.go's applyOps). It is not a version anybody sees. |
| 73 | type Deck struct { |
| 74 | ID int64 |
| 75 | Sqid string `gorm:"uniqueIndex"` |
| 76 | // Title is the deck's name, and the only free text on this row. |
| 77 | Title string |
| 78 | // OwnerID is the creator — spec §5's private rung, and the one |
| 79 | // person who may change settings or delete. |
| 80 | OwnerID int64 `gorm:"index"` |
| 81 | // Rung is who may open this deck: "private", "granted" or "team" |
| 82 | // (suite ruling 15, spec §5). Every deck starts private. This |
| 83 | // slice's UI offers private and team only; granted needs a screen |
| 84 | // for naming people, which has not been built — but DeckGrant and |
| 85 | // decks.go's visibleDecks already honour it. |
| 86 | Rung string |
| 87 | // Theme is the theme id (spec §2.1's meta.theme). |
| 88 | Theme string |
| 89 | Seq int64 |
| 90 | CreatedAt time.Time |
| 91 | UpdatedAt time.Time |
| 92 | } |
| 93 | |
| 94 | // DeckUpdate is one entry in a deck's update log: the Yjs update bytes |
| 95 | // one write produced. Replaying every row of a deck in Seq order |
| 96 | // reproduces the document exactly (decks.go's openDeck) — there are no |
| 97 | // snapshots yet. |
| 98 | // |
| 99 | // Actor is spec §6.2's actor string, "person:<user id>" or |
| 100 | // "agent:<agent id>". It lives on this row rather than in the CRDT |
| 101 | // deliberately: the document is what every peer converges on, and who |
| 102 | // wrote which byte of it is this instance's bookkeeping, not shared |
| 103 | // content. |
| 104 | type DeckUpdate struct { |
| 105 | ID int64 |
| 106 | // DeckID and Seq carry one composite unique index rather than an |
| 107 | // index each. That index is what makes "the update after seq n" a |
| 108 | // single insert that either lands or violates, with no |
| 109 | // read-then-write window; and being leftmost in it, DeckID alone is |
| 110 | // still indexed, so a second index on DeckID would only cost |
| 111 | // writes. |
| 112 | DeckID int64 `gorm:"uniqueIndex:idx_deck_updates_deck_seq"` |
| 113 | Seq int64 `gorm:"uniqueIndex:idx_deck_updates_deck_seq"` |
| 114 | Actor string |
| 115 | Bytes []byte |
| 116 | CreatedAt time.Time |
| 117 | } |
| 118 | |
| 119 | // DeckSnapshot is one deck's whole document at one seq, stored as a |
| 120 | // single Yjs update (core.Handle.State) so a later open can start from |
| 121 | // it instead of from the first row of the log. |
| 122 | // |
| 123 | // It is DERIVED DATA, and every decision here follows from that (Docs' |
| 124 | // snapshots ruling, adopted). The log is what the deck is: delete every |
| 125 | // row of this table and nothing is lost but time. So a snapshot is |
| 126 | // written best-effort AFTER the update that crossed the boundary has |
| 127 | // committed (snapshot.go's maybeSnapshot), a failure to write one is |
| 128 | // logged and never fails the request, and a snapshot that cannot be |
| 129 | // read is skipped in favour of replaying the plain log. |
| 130 | // |
| 131 | // Seq is the highest seq the bytes contain, and openDeck trusts it: it |
| 132 | // replays the rows at seq > Seq and no others. Nothing here can check |
| 133 | // that claim, so the ONE caller allowed to write a row is the one |
| 134 | // holding a handle it knows the seq of. |
| 135 | // |
| 136 | // Bytes rather than a path: a deck's state is kilobytes, this is one |
| 137 | // row per two hundred updates, and superseded rows are deleted as each |
| 138 | // new one lands, so the table holds one row per deck in the steady |
| 139 | // state. |
| 140 | // |
| 141 | // (deck_id, seq) is deliberately NOT unique. Two writers crossing the |
| 142 | // boundary together produce equivalent rows at the same seq, and that |
| 143 | // is an ordinary outcome rather than a collision to resolve. |
| 144 | type DeckSnapshot struct { |
| 145 | ID int64 |
| 146 | DeckID int64 `gorm:"index"` |
| 147 | Seq int64 |
| 148 | Bytes []byte |
| 149 | // CreatedAt is when the snapshot was taken, not when the deck last |
| 150 | // moved: a snapshot is bookkeeping, and the deck's own UpdatedAt is |
| 151 | // the column anything user-facing reads. |
| 152 | CreatedAt time.Time |
| 153 | } |
| 154 | |
| 155 | // DeckAsset records that one ref was VERIFIED for one deck: read back |
| 156 | // out of the store, sniffed as an image, sanitised if an SVG |
| 157 | // (assets.go's registerAsset). It is the half of the asset gate the |
| 158 | // document cannot carry. |
| 159 | // |
| 160 | // The deck's own `assets` map says which refs a deck refers to, and |
| 161 | // serveAsset checks it before serving a byte. But that map is written |
| 162 | // by `register_asset`, an op any member can apply with any ref — and a |
| 163 | // presigned PUT lands whatever bytes the browser sent under whatever |
| 164 | // hash it named, with nothing in between looking at them. So a member |
| 165 | // could PUT an unsanitised SVG and name its ref in an op, and the deck |
| 166 | // would refer to it and this app would serve it under its own origin. |
| 167 | // A row here is written only by registerAsset, after the bytes were |
| 168 | // checked, so serveAsset requiring one (as well as the map) means the |
| 169 | // map alone can never make an unverified object servable. |
| 170 | // |
| 171 | // Ref is the store's content address, which is what the served bytes |
| 172 | // are named by; (deck_id, ref) is unique because verifying the same |
| 173 | // bytes for the same deck twice is one fact, not two. Deleting the deck |
| 174 | // deletes its rows (decks.go's deleteDeck): a verification is about a |
| 175 | // deck, and nothing else ever reads one. |
| 176 | type DeckAsset struct { |
| 177 | ID int64 |
| 178 | DeckID int64 `gorm:"uniqueIndex:idx_deck_assets_deck_ref"` |
| 179 | Ref string `gorm:"uniqueIndex:idx_deck_assets_deck_ref"` |
| 180 | CreatedAt time.Time |
| 181 | } |
| 182 | |
| 183 | // AssetUpload is the RECEIPT for one upload this app itself signed — |
| 184 | // spec §2.7 as amended by codex 1 and codex 2. It is what makes an |
| 185 | // upload a thing that happened rather than a ref somebody knows. |
| 186 | // |
| 187 | // Before it existed, `POST /d/{sqid}/uploads` signed a PUT for whatever |
| 188 | // address the browser named and `POST /d/{sqid}/assets` verified |
| 189 | // whatever address the browser named. Two consequences, each a hole: |
| 190 | // a member could sign a PUT for an address another deck's picture was |
| 191 | // already served from and replace the bytes under it (codex 1), and a |
| 192 | // member who merely KNEW another deck's ref could register it into a |
| 193 | // deck of their own and pass both of serveAsset's checks (codex 2) — |
| 194 | // which defeated the whole "a content address is not an authorisation" |
| 195 | // rule the asset routes are built on. |
| 196 | // |
| 197 | // So the browser never names an address again. signUpload mints a |
| 198 | // STAGING key — a hash of this deck, this actor and 32 fresh random |
| 199 | // bytes, so the address is a function of the principal and is not |
| 200 | // guessable or reachable from outside — writes this row, and signs a PUT |
| 201 | // for that key alone. Registration will only look at bytes a row here |
| 202 | // says THIS principal staged for THIS deck; it then reads them, hashes |
| 203 | // what it actually read, and promotes them to a final object keyed by |
| 204 | // that hash. Nothing ever signs a PUT for a final address, so a |
| 205 | // verified object is immutable by construction rather than by nobody |
| 206 | // having tried. |
| 207 | // |
| 208 | // StagingKey carries the unique index because it is what a PUT and a |
| 209 | // registration are both resolved by. ObjectKey is where the bytes |
| 210 | // actually are: the staging key itself on a presigning store (the |
| 211 | // browser wrote them there), or the content address blobs.Dir chose in |
| 212 | // development, where the PUT comes through this app and the store is |
| 213 | // content-addressed with no way to write a caller's key. ConsumedAt |
| 214 | // makes a receipt single-use, so one signed upload is one registration. |
| 215 | type AssetUpload struct { |
| 216 | ID int64 |
| 217 | DeckID int64 `gorm:"index"` |
| 218 | // Actor is who asked for the URL, in DeckUpdate.Actor's form. Half |
| 219 | // of the pair registration matches on: an upload one member staged |
| 220 | // is not another member's to register, and an upload staged for one |
| 221 | // deck is not registrable into another. |
| 222 | Actor string |
| 223 | // StagingKey is the address this app told the browser to write to, |
| 224 | // and the only address it will ever sign a PUT for. |
| 225 | StagingKey string `gorm:"uniqueIndex"` |
| 226 | // ObjectKey is where the staged bytes really are once the PUT has |
| 227 | // landed. Empty until then on the development path. |
| 228 | ObjectKey string |
| 229 | // DeclaredHash is the SHA-256 the browser said the file has. Never |
| 230 | // trusted for anything — the final address comes from bytes this |
| 231 | // app read — but checked against what landed on the development |
| 232 | // path, where this app does the hashing anyway. |
| 233 | DeclaredHash string |
| 234 | // ContentType is what the browser said it was uploading, kept so |
| 235 | // the PUT can be signed with the header the bucket will store. |
| 236 | ContentType string |
| 237 | Size int64 |
| 238 | CreatedAt time.Time |
| 239 | ConsumedAt *time.Time |
| 240 | } |
| 241 | |
| 242 | // DeckGrant names one person who may open one deck — spec §5's |
| 243 | // `granted` rung. |
| 244 | // |
| 245 | // No screen creates one yet. The model and visibleDecks' join exist |
| 246 | // from the start anyway, because the alternative is a visibility seam |
| 247 | // that quietly means "owner-or-team" long after grants land: the day |
| 248 | // the screen arrives, nothing about who can read what has to be |
| 249 | // rewritten. |
| 250 | type DeckGrant struct { |
| 251 | ID int64 |
| 252 | DeckID int64 `gorm:"index"` |
| 253 | UserID int64 `gorm:"index"` |
| 254 | // Actor is who granted it, in DeckUpdate.Actor's form. |
| 255 | Actor string |
| 256 | CreatedAt time.Time |
| 257 | } |
| 258 | |
| 259 | // ShareLink is one read-only capability for one deck — spec §5's share |
| 260 | // link, calendar's feed pattern (its calendar design §6). |
| 261 | // |
| 262 | // Only the hash is stored. A plaintext token in this table would be a |
| 263 | // live, unauthenticated read capability for somebody's whole deck, |
| 264 | // sitting in a file the platform replicates to object storage. The |
| 265 | // consequence is that the URL cannot be shown again, so the share page |
| 266 | // shows it at the moment of minting and says so. |
| 267 | // |
| 268 | // TokenHash carries the unique index because it is what a request is |
| 269 | // resolved by: a single indexed read on /s/{token}, and no two rows |
| 270 | // can ever claim one URL. |
| 271 | // |
| 272 | // Views is the honest part, calendar's reason: a link that has been |
| 273 | // opened four hundred times is a different thing to hold than one |
| 274 | // nobody has ever used, and the share page can only say so if the |
| 275 | // number is kept. |
| 276 | // |
| 277 | // RevokedAt rather than a delete, also calendar's: the share page keeps |
| 278 | // showing that the link existed and that it is off, and a URL still |
| 279 | // pasted in somebody's chat window stops working rather than silently |
| 280 | // matching a later link that happened to reuse the row. |
| 281 | type ShareLink struct { |
| 282 | ID int64 |
| 283 | DeckID int64 `gorm:"index"` |
| 284 | // TokenHash is the SHA-256 hex of the token (share.go's |
| 285 | // hashShareToken). The token itself is never written anywhere. |
| 286 | TokenHash string `gorm:"uniqueIndex"` |
| 287 | // Actor is who minted it, in DeckUpdate.Actor's form. |
| 288 | Actor string |
| 289 | Views int64 |
| 290 | CreatedAt time.Time |
| 291 | RevokedAt *time.Time |
| 292 | } |
| 293 | |
| 294 | // Presentation is one live talk: which deck, who is presenting, which |
| 295 | // slide is on the screen, and the two capabilities that reach it — |
| 296 | // spec §3.4's session row, and ruling 5's "presenting is a server-side |
| 297 | // presentation session". |
| 298 | // |
| 299 | // It is the one record the presenter view, the phone remote and every |
| 300 | // following audience member all read and write, which is what makes |
| 301 | // them agree: there is no second place where "the deck is on slide |
| 302 | // four" is written down, so there is nothing for them to disagree |
| 303 | // about. |
| 304 | // |
| 305 | // Sqid is the session's public name and a VIEWING capability: anybody |
| 306 | // who has it may watch the fullscreen surface, exactly as a share link |
| 307 | // works. It is not a driving capability — that is RemoteTokenHash. |
| 308 | // |
| 309 | // Code is the six characters a person reads aloud or types into a |
| 310 | // phone, from an alphabet with no O/0 and no I/1 for the same reason |
| 311 | // the deck sqid's has none. It is unique among LIVE sessions only, and |
| 312 | // enforced in Go rather than by an index, because a code that could |
| 313 | // never be reused would make a long-lived instance run out of the |
| 314 | // short, sayable ones. It is not a capability on its own: pairing |
| 315 | // needs the code AND the token (presentation.go's remotePair). |
| 316 | // |
| 317 | // RemoteTokenHash is the SHA-256 hex of the remote's capability, the |
| 318 | // share link's construction and the share link's reason (share.go): |
| 319 | // only the hash is stored, so the URL can be shown exactly once, and |
| 320 | // resolving one is a constant-time compare rather than a lookup. |
| 321 | // |
| 322 | // Slide is a 0-based index into the deck's slides. Every URL and every |
| 323 | // stream event carries the 1-based number a person sees instead |
| 324 | // (presentation.go's eventFor) — the two are one conversion in one |
| 325 | // place, and this column is the index because that is what indexes |
| 326 | // into the document. |
| 327 | // |
| 328 | // EndedAt rather than a delete, and StartedAt read as a 12-hour |
| 329 | // deadline: a session is over when it is ended or when it is old, and |
| 330 | // both answers are 410 rather than 404, because a talk that finished |
| 331 | // is a different thing from a link that was never real. |
| 332 | type Presentation struct { |
| 333 | ID int64 |
| 334 | Sqid string `gorm:"uniqueIndex"` |
| 335 | DeckID int64 `gorm:"index"` |
| 336 | // Actor is who is presenting, in DeckUpdate.Actor's form. |
| 337 | Actor string |
| 338 | Slide int |
| 339 | Blackout bool |
| 340 | Code string `gorm:"index"` |
| 341 | RemoteTokenHash string |
| 342 | StartedAt time.Time |
| 343 | EndedAt *time.Time |
| 344 | } |
| 345 | |
| 346 | // Agent is one non-human principal that may drive this instance's |
| 347 | // decks — spec §7.1, and suite ruling 9 as this app reads it: an agent |
| 348 | // is its OWN principal with an operator behind it, never a borrowed |
| 349 | // session belonging to the person who minted it. |
| 350 | // |
| 351 | // The consequence lands on DeckUpdate.Actor. A write an agent makes is |
| 352 | // recorded "agent:<id>", so a deck's history says which agent wrote a |
| 353 | // slide and, through OperatorID, on whose authority — two facts a |
| 354 | // shared session would have collapsed into one wrong one. |
| 355 | // |
| 356 | // Name is the short half of the principal (`claude`, `codex`), held to |
| 357 | // lowercase letters, digits and hyphens so the principal string |
| 358 | // `<name>~<operator email>` has exactly one separator in it and reads |
| 359 | // the same everywhere it is shown. It is unique among one person's |
| 360 | // LIVE agents, enforced in Go rather than by an index, because a name |
| 361 | // that could never be reused would mean revoking `claude` costs you |
| 362 | // the name forever. |
| 363 | // |
| 364 | // TokenHash is the SHA-256 hex of the token and carries the unique |
| 365 | // index — share.go's construction and share.go's reason: only the |
| 366 | // fingerprint is stored, so a bearer resolves in one indexed read and |
| 367 | // the token itself can be shown exactly once. The screen that mints |
| 368 | // one says so at the moment it shows it. |
| 369 | // |
| 370 | // RevokedAt rather than a delete, ShareLink's reason again: the page |
| 371 | // keeps showing that the agent existed and that it is off, the actor |
| 372 | // strings already written into deck histories still resolve to a name, |
| 373 | // and a token still sitting in some machine's environment stops |
| 374 | // working rather than silently matching a later agent that happened to |
| 375 | // reuse the row. |
| 376 | type Agent struct { |
| 377 | ID int64 |
| 378 | Name string |
| 379 | // OperatorID is the person who minted this agent and whose |
| 380 | // visibility it borrows: an agent reads and writes exactly the |
| 381 | // decks its operator can, never more. |
| 382 | OperatorID int64 `gorm:"index"` |
| 383 | TokenHash string `gorm:"uniqueIndex"` |
| 384 | CreatedAt time.Time |
| 385 | RevokedAt *time.Time |
| 386 | } |
| 387 | |