rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package idear
2
3import (
4 "errors"
5 "fmt"
6)
7
8// The store's sentinels, and the two CLASSES a handler switches on.
9//
10// Every Roster method that refuses returns one of these, wrapped with
11// context where a reason helps a log line. Callers test with errors.Is,
12// never by comparing strings.
13//
14// The classes exist because a handler needs a status code, not a
15// diagnosis, and because a sentinel that belongs to no class falls
16// through to the default arm of that switch — which is a 500. That is
17// how a refusal ends up rendered as "the server is broken". Every
18// sentinel here is therefore reachable by errors.Is from exactly one
19// of:
20//
21// ErrInvalid the caller sent something malformed → 400
22// ErrForbidden the caller may not do this → 403
23// ErrNotFound no such member → 404
24// ErrNoInvitation, ErrOwnerExists flow-specific, rendered by the
25// flow that produces them
26//
27// Adding a sentinel to this file means deciding which class it is in.
28// A bare errors.New here is a 500 waiting to happen.
29var (
30 // ErrOwnerExists is Claim's refusal: this instance already has a
31 // roster, so the claim is closed. It is what the losers of a
32 // concurrent first-signup race receive — see Claim, and the
33 // reconciliation path in the design spec §5, which is how such a
34 // loser is healed rather than stranded.
35 ErrOwnerExists = errors.New("idear: this instance already has an owner")
36
37 // ErrNoInvitation is the single answer to every unusable
38 // invitation: no such token, already accepted, revoked, or
39 // expired. It is deliberately one error and not four — the caller
40 // holding a token is not entitled to learn WHICH of those is true,
41 // and a handler that rendered the distinction would turn the
42 // public GET /invitations/{token} route into an oracle.
43 ErrNoInvitation = errors.New("idear: no valid invitation for that address")
44
45 // ErrNotFound is a lookup miss: no member with that id or subject.
46 // Handlers answer it with the app's own 404 renderer, never with a
47 // message that distinguishes it from a permission refusal.
48 ErrNotFound = errors.New("idear: no such member")
49
50 // ErrInvalid is the CLASS of every malformed-argument refusal: a
51 // role that is not a role, a blank address, a blank subject. A
52 // handler renders the whole class 400. Match the class when you
53 // want a status code and the specific sentinel when you want to
54 // name the field that was wrong.
55 ErrInvalid = errors.New("idear: invalid argument")
56
57 // ErrRemovedInvitee refuses an invitation to an address that
58 // belongs to a DEACTIVATED member. Admission refuses such a
59 // member whatever invitation they hold — readmission is Restore,
60 // by an admin, so that being removed cannot be undone by the
61 // removed person from their inbox — and an invitation that can
62 // never be redeemed is a dead end for both people. It is a
63 // member of the ErrInvalid class: the address was the wrong
64 // argument, and the right one is the Restore button.
65 ErrRemovedInvitee = fmt.Errorf("%w: that address belongs to a member who was removed; restore them instead", ErrInvalid)
66
67 // ErrLastOwner refuses any change that would leave the instance
68 // without exactly one active Owner: deactivating the Owner, or
69 // changing the Owner's role by any route other than Transfer.
70 //
71 // It is checked BEFORE the full MayActOn matrix, and deliberately:
72 // "the owner cannot be removed" is true for every actor at every
73 // rank, so returning ErrForbidden instead would tell an Admin that
74 // some higher-ranked actor could do this — which is false.
75 //
76 // It nevertheless UNWRAPS to ErrForbidden, because it is a refusal
77 // and a handler must render it 403. Left as a bare errors.New it
78 // belonged to no class, and the obvious handler taxonomy
79 // (ErrInvalid 400, ErrForbidden 403, ErrNotFound 404, default 500)
80 // would have rendered this package's most security-relevant
81 // refusal as a server error. Code that wants the SPECIFIC reason
82 // still gets it: errors.Is(err, ErrLastOwner) stays true, and
83 // stays FALSE for an ordinary ErrForbidden.
84 ErrLastOwner error = classErr{"idear: the owner cannot be removed", ErrForbidden}
85
86 // ErrInvalidRole is a role argument that is not one of the three
87 // known roles. It is a malformed argument, not a refusal of
88 // authority — 400, not 403. Refusing RoleOwner is NOT this error:
89 // owner is a perfectly valid role, and Invite and SetRole refuse
90 // it with ErrForbidden because ownership moves only by Transfer.
91 ErrInvalidRole error = classErr{"idear: not a role", ErrInvalid}
92
93 // ErrInvalidEmail is a blank or whitespace-only address where one
94 // is required. It is the sibling of ErrInvalidRole on the same
95 // form: the role field and the address field of one invitation
96 // must classify the same way, or a mistyped address renders 500
97 // while a mistyped role renders 400.
98 ErrInvalidEmail error = classErr{"idear: not an email address", ErrInvalid}
99
100 // ErrInvalidID is an id that is not a positive decimal integer —
101 // a {id} wildcard carrying "1; DROP", "-1", or nothing at all. It
102 // is malformed input and renders 400, like its siblings above: a
103 // handler that let it through would hand the store a zero id and
104 // get ErrNotFound, rendering a typo as "no such member".
105 ErrInvalidID error = classErr{"idear: not an id", ErrInvalid}
106
107 // ErrInvalidSubject is a blank session Subject where one is
108 // required. Subject is the join to the app's own identity, so an
109 // empty one is a caller bug — usually idear's middleware mounted
110 // OUTSIDE the app's session guard, which is the misconfiguration
111 // the design spec §5 warns about.
112 ErrInvalidSubject error = classErr{"idear: not a subject", ErrInvalid}
113)
114
115// classErr is a sentinel that also belongs to a class: errors.Is finds
116// it by identity (the struct is comparable, and no two sentinels here
117// share a message), and unwraps past it to the class.
118//
119// One mechanism rather than four hand-written types, because four
120// sentinels need exactly this shape and a fifth will too — and because
121// the mistake it exists to prevent is systematic, not local. Declared
122// as `error` rather than as the concrete type so no caller can grow a
123// dependency on classErr itself.
124type classErr struct {
125 msg string
126 class error
127}
128
129func (e classErr) Error() string { return e.msg }
130func (e classErr) Unwrap() error { return e.class }
131