Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/24082-invite-role-cap-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/spec": minor
---

feat(spec): register the invitation role cap's three refusal codes under `@objectstack/plugin-auth`

Clause-②: yes (widening)

The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains three `@objectstack/plugin-auth` codes, one per refusal the invitation role cap can return. Today the cap answers all three as a bare `403 FORBIDDEN`, so a client such as the Invite User dialog cannot tell them apart:

- `INVITE_ROLE_ABOVE_ISSUER` — the requested role grades above the issuer's own; an invitation never confers authority above its issuer.
- `INVITE_ISSUER_MEMBER_ONLY` — an issuer below admin grade may invite only as a plain `member`; the requested role may carry capability of its own.
- `INVITE_ISSUER_UNVERIFIED` — fail-closed: the issuer's organization membership could not be established, so the invitation may confer nothing beyond `member`.

Registration only: `@objectstack/plugin-auth` still throws `FORBIDDEN` until #23945 stamps these codes on its refusals. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver).
5 changes: 4 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +332 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +335 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -219,7 +219,10 @@ const result = ApiErrorSchema.parse(data);
* `INVALID_SIGNATURE`
* `INVALID_STATE`
* `INVITE_EMAIL_FAILED`
* `INVITE_ISSUER_MEMBER_ONLY`
* `INVITE_ISSUER_UNVERIFIED`
* `INVITE_REQUIRES_EMAIL`
* `INVITE_ROLE_ABOVE_ISSUER`
* `INVITE_SMS_FAILED`
* `IP_NOT_ALLOWED`
* `ITEM_LOCKED`
Expand Down
3 changes: 3 additions & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ const result = ErrorCode.parse(data);
* `INVALID_SIGNATURE`
* `INVALID_STATE`
* `INVITE_EMAIL_FAILED`
* `INVITE_ISSUER_MEMBER_ONLY`
* `INVITE_ISSUER_UNVERIFIED`
* `INVITE_REQUIRES_EMAIL`
* `INVITE_ROLE_ABOVE_ISSUER`
* `INVITE_SMS_FAILED`
* `IP_NOT_ALLOWED`
* `ITEM_LOCKED`
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/src/api/error-code-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ describe('ErrorCode (standard ∪ registered)', () => {
expect(standardSynonymOf('FLOW_INPUT_SCHEMA_INVALID')).toBeUndefined();
});

it('accepts the invitation role cap refusal codes, registered ahead of their plugin-auth producer', () => {
// #24082 is the spec half of #23945's split: the cap's three refusals are
// a bare `FORBIDDEN` today, so a client cannot tell them apart. #23945
// stamps them and asserts these exact strings by value — pinned here too.
for (const code of ['INVITE_ROLE_ABOVE_ISSUER', 'INVITE_ISSUER_MEMBER_ONLY', 'INVITE_ISSUER_UNVERIFIED']) {
expect(ErrorCode.parse(code)).toBe(code);
expect(ERROR_CODE_LEDGER['@objectstack/plugin-auth'], `${code} registered under plugin-auth`).toContain(code);
// INVITE is a token no standard member carries — no waiver needed.
expect(standardSynonymOf(code), `${code} needs no waiver`).toBeUndefined();
}
});

it('accepts the nine-code batch — every code that ships in dist, door or no door (ruled: the ledger is the published face)', () => {
// The #16404 ruling (director seat, decision batch #62, 2026-09-07, option
// D): the published face is this ledger, so a code that ships in `dist` is
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,13 @@ export const ERROR_CODE_LEDGER = {
'INVALID_EMAIL',
'INVALID_PHONE',
'INVITE_EMAIL_FAILED',
// [#24082] The invitation role cap's three refusals (`invitation-role-cap.ts`,
// thrown 403 from `beforeCreateInvitation`), registered AHEAD of their
// producer: today the cap throws a bare `FORBIDDEN`, and #23945 stamps these.
'INVITE_ISSUER_MEMBER_ONLY', // [#24082] 403: an issuer below admin grade may invite only as a plain `member`; the requested role does not outrank theirs but may carry capability of its own
'INVITE_ISSUER_UNVERIFIED', // [#24082] 403, fail-closed: the issuer's `sys_member` role could not be established (no row, or the lookup did not answer), so the invitation may confer nothing beyond `member`
'INVITE_REQUIRES_EMAIL',
'INVITE_ROLE_ABOVE_ISSUER', // [#24082] 403: the requested role grades above the issuer's own (an invitation never confers authority above its issuer)
'INVALID_REQUEST',
'INVITE_SMS_FAILED',
'IP_NOT_ALLOWED',
Expand Down
Loading