diff --git a/.changeset/invitation-status-canceled.md b/.changeset/invitation-status-canceled.md new file mode 100644 index 0000000000..9f24ec3b4a --- /dev/null +++ b/.changeset/invitation-status-canceled.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +--- + +fix(spec,platform-objects): `InvitationStatus` accepts `canceled`, the value cancel-invitation actually writes (#7726) + +The spec's `InvitationStatus` enum listed four values — +`pending | accepted | rejected | expired` — while the platform shipped a fifth. +`POST /api/v1/auth/organization/cancel-invitation` (better-auth's organization +plugin) writes `status: 'canceled'` onto the `sys_invitation` row, and +`sys_invitation` declared that value in its own select and filtered on it in its +"Expired / Canceled" listView. So an invitation the platform had just canceled +through its own UI failed validation against `InvitationSchema`, which composes +the enum. + +**The enum now accepts `canceled`.** This is a widening that reconciles the +contract to shipped behaviour rather than a new capability: the writer, the +route, the object's action and the listView all predate this change. Consumers +gain a value; none lose one. Nothing in the repo branches exhaustively over +`InvitationStatus`, so no consumer is broken by the fifth member — an +out-of-vocabulary value is still refused exactly as before. + +The vocabulary is the union of two upstreams, and the two halves come from +different places: better-auth contributes `canceled` and has no notion of +expiry, while `expired` is ObjectStack's own (driven by `expiresAt`). That is +why the divergence was possible at all. + +**The two definitions are now bound.** `sys_invitation.status` reads its select +options from `InvitationStatus` instead of repeating them as a literal — the +same shape the neighbouring `role` field already uses for the membership-role +vocabulary — and a parity test compares the object's declared options against +the enum, so a future divergence lands as a red test instead of as a row the +contract rejects. diff --git a/content/docs/references/identity/organization.mdx b/content/docs/references/identity/organization.mdx index 8e6d79d063..ad91c555c5 100644 --- a/content/docs/references/identity/organization.mdx +++ b/content/docs/references/identity/organization.mdx @@ -38,7 +38,7 @@ const result = InvitationSchema.parse(data); | **organizationId** | `string` | ✅ | Organization ID | | **email** | `string` | ✅ | Invitee email address | | **role** | `string` | ✅ | Role to assign upon acceptance (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) | -| **status** | `Enum<'pending' \| 'accepted' \| 'rejected' \| 'expired'>` | ✅ | Invitation status | +| **status** | `Enum<'pending' \| 'accepted' \| 'rejected' \| 'expired' \| 'canceled'>` | ✅ | Invitation status | | **expiresAt** | `string` | ✅ | Invitation expiry timestamp | | **inviterId** | `string` | ✅ | User ID of the inviter | | **createdAt** | `string` | ✅ | Invitation creation timestamp | @@ -55,6 +55,7 @@ const result = InvitationSchema.parse(data); * `accepted` * `rejected` * `expired` +* `canceled` --- diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 47b6f84e0b..3d921601d3 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -1,7 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS, MEMBERSHIP_ROLE_MEMBER } from '@objectstack/spec/identity'; +import { + BUILTIN_MEMBERSHIP_ROLE_OPTIONS, + InvitationStatus, + MEMBERSHIP_ROLE_MEMBER, +} from '@objectstack/spec/identity'; /** * sys_invitation — System Invitation Object @@ -211,7 +215,13 @@ export const SysInvitation = ObjectSchema.create({ defaultValue: MEMBERSHIP_ROLE_MEMBER, }), - status: Field.select(['pending', 'accepted', 'rejected', 'expired', 'canceled'], { + // [#7726] Same list as the spec's `InvitationStatus`, from that enum — the + // two definitions of this vocabulary had already drifted once (the object + // shipped `canceled`, which better-auth writes on cancel-invitation, while + // the enum stopped at four values and so rejected a canceled row). Reading + // the options from the enum makes a repeat impossible rather than merely + // discouraged; `sys-invitation.status-vocabulary.test.ts` pins the rest. + status: Field.select([...InvitationStatus.options], { label: 'Status', required: true, defaultValue: 'pending', diff --git a/packages/platform-objects/src/identity/sys-invitation.status-vocabulary.test.ts b/packages/platform-objects/src/identity/sys-invitation.status-vocabulary.test.ts new file mode 100644 index 0000000000..09abd9f0bc --- /dev/null +++ b/packages/platform-objects/src/identity/sys-invitation.status-vocabulary.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7726 — `sys_invitation.status` and the spec's `InvitationStatus` are two +// spellings of one vocabulary, and they had drifted by exactly one value: +// better-auth's organization plugin writes `status: 'canceled'` on +// `POST /organization/cancel-invitation` (the `cancel_invitation` action below +// targets that route, and the object's own `expired` listView filters on the +// value), while the spec enum stopped at four. Anything validating a row +// against `InvitationSchema` therefore rejected an invitation the platform had +// just canceled through its own UI. +// +// The load-bearing pin is the PARITY assertion: the two sides are compared to +// each other, never to a hand-copied literal — a literal would have to be +// edited on both sides anyway, which is the drift it is supposed to catch. The +// object now reads its options FROM the enum, so today parity is structural; +// the test is what makes a future re-literalization land as a red test rather +// than as a silent second divergence. +import { describe, expect, it } from 'vitest'; +import { InvitationStatus } from '@objectstack/spec/identity'; +import { SysInvitation } from './sys-invitation.object.js'; + +/** Declared option values of a select field, in declaration order. */ +function optionValues(object: unknown, field: string): string[] { + const f = (object as any).fields?.[field]; + expect(f, `${field} field exists`).toBeDefined(); + expect(f.type).toBe('select'); + return ((f.options ?? []) as Array<{ value: unknown }>).map((o) => String(o.value)); +} + +/** Filter values of a named listView's `status` clause, or `[]` when absent. */ +function listViewStatusValues(object: unknown, view: string): string[] { + const v = (object as any).listViews?.[view]; + expect(v, `listView ${view} exists`).toBeDefined(); + const clause = ((v.filter ?? []) as Array).find((c) => c?.field === 'status'); + if (!clause) return []; + return (Array.isArray(clause.value) ? clause.value : [clause.value]).map(String); +} + +describe('sys_invitation.status — vocabulary parity with the spec enum (#7726)', () => { + it('matches InvitationStatus — the spec enum is the authority on the vocabulary', () => { + // Order-sensitive: the options are rendered as a picklist, and the enum's + // order is the one the object used before the binding existed. + expect(optionValues(SysInvitation, 'status')).toEqual([...InvitationStatus.options]); + }); + + it('declares `canceled` — the terminal state cancel-invitation writes', () => { + // Spelled as its own case because THIS is the regression: the writer is + // upstream (better-auth), so dropping the value from either side breaks + // nothing at write time and reappears only as a rejected validation. + expect(optionValues(SysInvitation, 'status')).toContain('canceled'); + expect(InvitationStatus.options).toContain('canceled'); + }); + + it('keeps every value the expired/canceled listView filters on inside the vocabulary', () => { + // The listView is the read-side consumer that made the drift visible; a + // filter value outside the vocabulary silently matches nothing. + const filtered = listViewStatusValues(SysInvitation, 'expired'); + expect(filtered.length).toBeGreaterThan(0); + for (const value of filtered) { + expect(InvitationStatus.options).toContain(value); + } + }); + + it('keeps `pending` as the default, and the default is a declared value', () => { + const f = (SysInvitation as any).fields.status; + expect(f.defaultValue).toBe('pending'); + expect(optionValues(SysInvitation, 'status')).toContain(f.defaultValue); + expect(InvitationStatus.safeParse(f.defaultValue).success).toBe(true); + }); + + it('refuses a value outside the vocabulary', () => { + // The other half of a value-domain pin: a widening that accidentally + // relaxes the enum into a free string would satisfy every assertion above. + expect(InvitationStatus.safeParse('cancelled').success).toBe(false); + expect(optionValues(SysInvitation, 'status')).not.toContain('cancelled'); + }); +}); diff --git a/packages/spec/src/identity/organization.test.ts b/packages/spec/src/identity/organization.test.ts index b6b8774195..a4abce6567 100644 --- a/packages/spec/src/identity/organization.test.ts +++ b/packages/spec/src/identity/organization.test.ts @@ -160,16 +160,40 @@ describe('MemberSchema', () => { describe('InvitationStatus', () => { it('should accept valid invitation statuses', () => { - const statuses = ['pending', 'accepted', 'rejected', 'expired']; + const statuses = ['pending', 'accepted', 'rejected', 'expired', 'canceled']; statuses.forEach((status) => { expect(() => InvitationStatus.parse(status)).not.toThrow(); }); }); + // [#7726] The vocabulary itself, spelled out: `canceled` was missing here + // while `cancel-invitation` wrote it, so an invitation the platform had + // canceled failed its own contract. A value list is the one thing a + // "parses without throwing" loop cannot pin — it stays green when the enum + // is widened by accident just as readily as on purpose. + it('declares exactly the five shipped statuses, in order', () => { + expect(InvitationStatus.options).toEqual([ + 'pending', + 'accepted', + 'rejected', + 'expired', + 'canceled', + ]); + }); + it('should reject invalid status', () => { expect(() => InvitationStatus.parse('invalid')).toThrow(); }); + + it('still refuses an out-of-vocabulary value after the widening', () => { + // Asserted on the issue rather than on the throw: widening an enum is + // exactly the change that can slip into `z.string()` and keep every + // `toThrow()` test green by no longer rejecting anything at all. + const result = InvitationStatus.safeParse('cancelled'); // en-GB spelling — not the value + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.code).toBe('invalid_value'); + }); }); describe('InvitationSchema', () => { @@ -206,11 +230,14 @@ describe('InvitationSchema', () => { }); it('should accept all valid statuses', () => { - const statuses: Array<'pending' | 'accepted' | 'rejected' | 'expired'> = [ + const statuses: Array<'pending' | 'accepted' | 'rejected' | 'expired' | 'canceled'> = [ 'pending', 'accepted', 'rejected', 'expired', + // [#7726] The row `cancel-invitation` actually leaves behind — this is + // the case `InvitationSchema` used to reject. + 'canceled', ]; statuses.forEach((status) => { diff --git a/packages/spec/src/identity/organization.zod.ts b/packages/spec/src/identity/organization.zod.ts index a2fdb1e7eb..62975609b0 100644 --- a/packages/spec/src/identity/organization.zod.ts +++ b/packages/spec/src/identity/organization.zod.ts @@ -109,8 +109,22 @@ export type Member = z.input; /** * Invitation Status Enum + * + * [#7726] `canceled` is the ISSUER-side terminal state, and it is a shipped + * value rather than a speculative one: better-auth's organization plugin + * writes it on `POST /organization/cancel-invitation` (reachable from the + * `cancel_invitation` action on `sys_invitation`, and from the client SDK's + * `organizations.invitations.cancel`), and again when + * `cancelPendingInvitationsOnReInvite` supersedes a pending row. It is + * distinct from `rejected`, which the INVITEE writes. + * + * The vocabulary is therefore the union of two upstreams and must stay so: + * better-auth contributes `canceled` but has no `expired`, while expiry is + * ObjectStack's own (`expiresAt`). `sys_invitation.status` binds its select + * options to this enum — see `sys-invitation.status-vocabulary.test.ts`, + * which fails loudly if either side grows alone. */ -export const InvitationStatus = z.enum(['pending', 'accepted', 'rejected', 'expired']); +export const InvitationStatus = z.enum(['pending', 'accepted', 'rejected', 'expired', 'canceled']); export type InvitationStatus = z.input;