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