| 1 | package sheets |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // Member is one person who may hold a session on this instance, and the |
| 6 | // stable uint64 an edit records as its actor. |
| 7 | // |
| 8 | // Address is plaintext, deliberately. Spec §2.2 lists "membership, |
| 9 | // grants, and roles — including readable email addresses" among the |
| 10 | // things this design does NOT seal, and publishes that in the README |
| 11 | // rather than leaving it to be discovered. Sealing it would also make |
| 12 | // the uniqueIndex below impossible: seal uses a random nonce per call, |
| 13 | // so the same address seals to different ciphertext every time and no |
| 14 | // index could ever match one. |
| 15 | // |
| 16 | // This table exists because the session carries an address and every |
| 17 | // authored column — Sheet.CreatedBy, Cell.UpdatedBy, Event.ActorID — is |
| 18 | // a uint64. sessions.UserID cannot bridge the two: it returns |
| 19 | // (0, false) for an email Subject, and dropping that ok scopes every |
| 20 | // query to uid 0 (AGENTS.md / Mechanical rules). |
| 21 | type Member struct { |
| 22 | ID uint64 `gorm:"primaryKey"` |
| 23 | Address string `gorm:"uniqueIndex;size:320"` |
| 24 | // TeamRole is this person's standing on the instance. It defaults |
| 25 | // to guest for anyone who arrives by being granted a single sheet; |
| 26 | // SHEETS_OWNERS promotes to owner at first sight. A guest is the |
| 27 | // safe default because the wrong direction is unrecoverable in the |
| 28 | // way that matters: a guest wrongly made a member sees every sheet. |
| 29 | TeamRole string `gorm:"size:16"` |
| 30 | CreatedAt time.Time |
| 31 | } |
| 32 | |
| 33 | // TeamRole is a person's standing on this instance as a whole. |
| 34 | // |
| 35 | // Spec §8.2: owner, member, guest. A guest holds no team-wide rights — |
| 36 | // they see exactly the sheets granted to them and learn nothing about |
| 37 | // the team beyond §9.5's disclosure. That is the whole point of the |
| 38 | // distinction, and it is why a guest cannot be given a team role by |
| 39 | // accident: guests arrive by being granted one sheet. |
| 40 | type TeamRole string |
| 41 | |
| 42 | const ( |
| 43 | TeamOwner TeamRole = "owner" |
| 44 | TeamMember TeamRole = "member" |
| 45 | TeamGuest TeamRole = "guest" |
| 46 | ) |
| 47 | |
| 48 | // SheetRole is a person's standing on one sheet. |
| 49 | // |
| 50 | // Spec §8.2: owner, editor, commenter, viewer. Ordered, and the order |
| 51 | // is load-bearing — see Allows, which is how every route asks its |
| 52 | // question. |
| 53 | type SheetRole string |
| 54 | |
| 55 | const ( |
| 56 | SheetOwner SheetRole = "owner" |
| 57 | SheetEditor SheetRole = "editor" |
| 58 | SheetCommenter SheetRole = "commenter" |
| 59 | SheetViewer SheetRole = "viewer" |
| 60 | ) |
| 61 | |
| 62 | // rank orders sheet roles from most to least able. Unknown roles rank |
| 63 | // below every real one, so a role this build does not recognise — a row |
| 64 | // written by a newer version — grants nothing rather than everything. |
| 65 | func (r SheetRole) rank() int { |
| 66 | switch r { |
| 67 | case SheetOwner: |
| 68 | return 4 |
| 69 | case SheetEditor: |
| 70 | return 3 |
| 71 | case SheetCommenter: |
| 72 | return 2 |
| 73 | case SheetViewer: |
| 74 | return 1 |
| 75 | } |
| 76 | return 0 |
| 77 | } |
| 78 | |
| 79 | // Allows reports whether this role is at least as able as want. |
| 80 | // |
| 81 | // Every authorization question in the app is this one function, so |
| 82 | // there is one place to read and one place to get it wrong. Callers ask |
| 83 | // `role.Allows(SheetEditor)`, never `role == SheetEditor` — the second |
| 84 | // spelling silently excludes owners, and would do it on whichever route |
| 85 | // somebody forgot. |
| 86 | func (r SheetRole) Allows(want SheetRole) bool { return r.rank() >= want.rank() } |
| 87 | |
| 88 | // Valid reports whether a role is one this build knows. |
| 89 | func (r SheetRole) Valid() bool { return r.rank() > 0 } |
| 90 | |
| 91 | // Grant is one person's access to one sheet. |
| 92 | // |
| 93 | // This table is the authority. Spec §9.1.1: every instance re-checks |
| 94 | // authorization on every request, whatever the directory says — the |
| 95 | // directory answers "where might I have things", never "may I have |
| 96 | // this". |
| 97 | // |
| 98 | // Plaintext, per spec §2.2, which lists membership, grants and roles |
| 99 | // among what this design deliberately does not seal. |
| 100 | // |
| 101 | // (SheetID, MemberID) is a uniqueIndex: a person has one role on a |
| 102 | // sheet. Two rows would make "what may they do" depend on which the |
| 103 | // query happened to return first. |
| 104 | type Grant struct { |
| 105 | ID uint64 `gorm:"primaryKey"` |
| 106 | SheetID uint64 `gorm:"uniqueIndex:idx_grant_sheet_member;index"` |
| 107 | MemberID uint64 `gorm:"uniqueIndex:idx_grant_sheet_member"` |
| 108 | Role string `gorm:"size:16"` |
| 109 | GrantedBy uint64 |
| 110 | CreatedAt time.Time |
| 111 | UpdatedAt time.Time |
| 112 | } |
| 113 | |
| 114 | // ShareLink is a capability: whoever holds the token may read the |
| 115 | // sheet, with no account and no session. |
| 116 | // |
| 117 | // **TokenHash, never the token.** Spec §8.3, following Pegamento's |
| 118 | // service contract: stored plaintext, the replicated database becomes a |
| 119 | // bundle of live read capabilities to sealed sheets — §0 defeated |
| 120 | // through a side door for the second time. So the token is shown once, |
| 121 | // at creation, and never again; what is stored is only enough to |
| 122 | // recognise it coming back. |
| 123 | // |
| 124 | // The hash is SHA-256 rather than bcrypt or argon2, and that is a |
| 125 | // deliberate difference from how a password would be stored. A password |
| 126 | // is low-entropy and needs a slow hash to survive being guessed. This |
| 127 | // token is 32 bytes from crypto/rand, so guessing is not a threat model |
| 128 | // and a slow hash would buy nothing while making every page load do |
| 129 | // key-stretching work. The property that matters here — the stored form |
| 130 | // does not yield the token — a fast hash gives just as completely. |
| 131 | // |
| 132 | // RevokedAt rather than a delete, because "this link was turned off" |
| 133 | // and "this link never existed" are different answers to give somebody |
| 134 | // holding a stale URL, and the first is the kinder one. |
| 135 | type ShareLink struct { |
| 136 | ID uint64 `gorm:"primaryKey"` |
| 137 | SheetID uint64 `gorm:"index"` |
| 138 | TokenHash string `gorm:"uniqueIndex;size:64"` |
| 139 | // Role is viewer or commenter, never editor (ruling 12). Every |
| 140 | // version row carries an actor, and an anonymous editor would write |
| 141 | // unattributed rows into version history. |
| 142 | Role string `gorm:"size:16"` |
| 143 | CreatedBy uint64 |
| 144 | CreatedAt time.Time |
| 145 | RevokedAt *time.Time |
| 146 | } |
| 147 | |
| 148 | // Comment is a note somebody wrote about one cell. |
| 149 | // |
| 150 | // **Anchored on stable row and column ids, never on A1 coordinates.** |
| 151 | // Spec §4.2 names this as one of the three things stable identity is |
| 152 | // load-bearing for: a comment stays attached to the cell it was written |
| 153 | // about when a row is inserted above it. An anchor of "B7" would slide |
| 154 | // onto a different cell the first time anybody inserted a row. |
| 155 | // |
| 156 | // BodySealed because comment text is user content — spec §2.1 lists it |
| 157 | // beside cell values and sheet titles. |
| 158 | // |
| 159 | // A thread is every comment sharing a cell, ordered by id. That is the |
| 160 | // **local** implementation §13 pre-decided as the fallback: comments |
| 161 | // were to consume Docs' comment-thread primitive, Docs owns that design |
| 162 | // because its anchoring case is harder, and it has not shipped. Deciding |
| 163 | // the fallback in advance is what makes this a choice rather than a |
| 164 | // scramble; adopting theirs later replaces the rendering, not the |
| 165 | // anchor. |
| 166 | // |
| 167 | // ResolvedAt marks a thread dealt with. Nulled rather than deleted: |
| 168 | // "we discussed this and settled it" is worth keeping, and a resolved |
| 169 | // thread is the most useful thing to read when the same question comes |
| 170 | // back. |
| 171 | type Comment struct { |
| 172 | ID uint64 `gorm:"primaryKey"` |
| 173 | TabID uint64 `gorm:"index"` |
| 174 | RowID, ColID uint64 |
| 175 | AuthorID uint64 |
| 176 | BodySealed []byte // seal purpose "comment.body" |
| 177 | CreatedAt time.Time |
| 178 | ResolvedAt *time.Time |
| 179 | ResolvedBy uint64 |
| 180 | } |
| 181 | |
| 182 | // TabView is a tab's persisted sort and filter. |
| 183 | // |
| 184 | // SpecSealed, because a filter value IS a cell value — spec §2.1 lists |
| 185 | // "persisted sort and filter criteria" among what this design seals, |
| 186 | // for exactly that reason. A filter reading `> 50000` against a salary |
| 187 | // column is the number it is filtering on. |
| 188 | // |
| 189 | // One row per tab (uniqueIndex): a tab has one view, and two rows would |
| 190 | // make "how is this sorted" depend on which the query returned first. |
| 191 | type TabView struct { |
| 192 | ID uint64 `gorm:"primaryKey"` |
| 193 | TabID uint64 `gorm:"uniqueIndex"` |
| 194 | SpecSealed []byte // seal purpose "tab.view" |
| 195 | UpdatedBy uint64 |
| 196 | UpdatedAt time.Time |
| 197 | } |
| 198 | |