package idear import "errors" // The store's sentinels, and the two CLASSES a handler switches on. // // Every Roster method that refuses returns one of these, wrapped with // context where a reason helps a log line. Callers test with errors.Is, // never by comparing strings. // // The classes exist because a handler needs a status code, not a // diagnosis, and because a sentinel that belongs to no class falls // through to the default arm of that switch — which is a 500. That is // how a refusal ends up rendered as "the server is broken". Every // sentinel here is therefore reachable by errors.Is from exactly one // of: // // ErrInvalid the caller sent something malformed → 400 // ErrForbidden the caller may not do this → 403 // ErrNotFound no such member → 404 // ErrNoInvitation, ErrOwnerExists flow-specific, rendered by the // flow that produces them // // Adding a sentinel to this file means deciding which class it is in. // A bare errors.New here is a 500 waiting to happen. var ( // ErrOwnerExists is Claim's refusal: this instance already has a // roster, so the claim is closed. It is what the losers of a // concurrent first-signup race receive — see Claim, and the // reconciliation path in the design spec §5, which is how such a // loser is healed rather than stranded. ErrOwnerExists = errors.New("idear: this instance already has an owner") // ErrNoInvitation is the single answer to every unusable // invitation: no such token, already accepted, revoked, or // expired. It is deliberately one error and not four — the caller // holding a token is not entitled to learn WHICH of those is true, // and a handler that rendered the distinction would turn the // public GET /invitations/{token} route into an oracle. ErrNoInvitation = errors.New("idear: no valid invitation for that address") // ErrNotFound is a lookup miss: no member with that id or subject. // Handlers answer it with the app's own 404 renderer, never with a // message that distinguishes it from a permission refusal. ErrNotFound = errors.New("idear: no such member") // ErrInvalid is the CLASS of every malformed-argument refusal: a // role that is not a role, a blank address, a blank subject. A // handler renders the whole class 400. Match the class when you // want a status code and the specific sentinel when you want to // name the field that was wrong. ErrInvalid = errors.New("idear: invalid argument") // ErrLastOwner refuses any change that would leave the instance // without exactly one active Owner: deactivating the Owner, or // changing the Owner's role by any route other than Transfer. // // It is checked BEFORE the full MayActOn matrix, and deliberately: // "the owner cannot be removed" is true for every actor at every // rank, so returning ErrForbidden instead would tell an Admin that // some higher-ranked actor could do this — which is false. // // It nevertheless UNWRAPS to ErrForbidden, because it is a refusal // and a handler must render it 403. Left as a bare errors.New it // belonged to no class, and the obvious handler taxonomy // (ErrInvalid 400, ErrForbidden 403, ErrNotFound 404, default 500) // would have rendered this package's most security-relevant // refusal as a server error. Code that wants the SPECIFIC reason // still gets it: errors.Is(err, ErrLastOwner) stays true, and // stays FALSE for an ordinary ErrForbidden. ErrLastOwner error = classErr{"idear: the owner cannot be removed", ErrForbidden} // ErrInvalidRole is a role argument that is not one of the three // known roles. It is a malformed argument, not a refusal of // authority — 400, not 403. Refusing RoleOwner is NOT this error: // owner is a perfectly valid role, and Invite and SetRole refuse // it with ErrForbidden because ownership moves only by Transfer. ErrInvalidRole error = classErr{"idear: not a role", ErrInvalid} // ErrInvalidEmail is a blank or whitespace-only address where one // is required. It is the sibling of ErrInvalidRole on the same // form: the role field and the address field of one invitation // must classify the same way, or a mistyped address renders 500 // while a mistyped role renders 400. ErrInvalidEmail error = classErr{"idear: not an email address", ErrInvalid} // ErrInvalidID is an id that is not a positive decimal integer — // a {id} wildcard carrying "1; DROP", "-1", or nothing at all. It // is malformed input and renders 400, like its siblings above: a // handler that let it through would hand the store a zero id and // get ErrNotFound, rendering a typo as "no such member". ErrInvalidID error = classErr{"idear: not an id", ErrInvalid} // ErrInvalidSubject is a blank session Subject where one is // required. Subject is the join to the app's own identity, so an // empty one is a caller bug — usually idear's middleware mounted // OUTSIDE the app's session guard, which is the misconfiguration // the design spec §5 warns about. ErrInvalidSubject error = classErr{"idear: not a subject", ErrInvalid} ) // classErr is a sentinel that also belongs to a class: errors.Is finds // it by identity (the struct is comparable, and no two sentinels here // share a message), and unwraps past it to the class. // // One mechanism rather than four hand-written types, because four // sentinels need exactly this shape and a fifth will too — and because // the mistake it exists to prevent is systematic, not local. Declared // as `error` rather than as the concrete type so no caller can grow a // dependency on classErr itself. type classErr struct { msg string class error } func (e classErr) Error() string { return e.msg } func (e classErr) Unwrap() error { return e.class }