| 1 | package idear |
| 2 | |
| 3 | import "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. |
| 26 | var ( |
| 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. |
| 111 | type classErr struct { |
| 112 | msg string |
| 113 | class error |
| 114 | } |
| 115 | |
| 116 | func (e classErr) Error() string { return e.msg } |
| 117 | func (e classErr) Unwrap() error { return e.class } |
| 118 | |