Skip to content
Draft
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
34 changes: 34 additions & 0 deletions .changeset/invitation-status-canceled.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/identity/organization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| **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 |
Expand All @@ -55,6 +55,7 @@ const result = InvitationSchema.parse(data);
* `accepted`
* `rejected`
* `expired`
* `canceled`


---
Expand Down
14 changes: 12 additions & 2 deletions packages/platform-objects/src/identity/sys-invitation.object.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any>).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');
});
});
31 changes: 29 additions & 2 deletions packages/spec/src/identity/organization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) => {
Expand Down
16 changes: 15 additions & 1 deletion packages/spec/src/identity/organization.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,22 @@ export type Member = z.input<typeof MemberSchema>;

/**
* 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<typeof InvitationStatus>;

Expand Down
Loading