oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import "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).
21type 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.
40type TeamRole string
41
42const (
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.
53type SheetRole string
54
55const (
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.
65func (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.
86func (r SheetRole) Allows(want SheetRole) bool { return r.rank() >= want.rank() }
87
88// Valid reports whether a role is one this build knows.
89func (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.
104type 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.
135type 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.
171type 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.
191type 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