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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,12 @@ organizations:
- email: employee@acme.com
role: member
status: active
groups:
# Groups belong to this org; members reference a membership by the same
# email join key `memberships` use above.
- name: Engineering
description: The engineering team
members: [employee@acme.com]

roles:
- slug: admin
Expand Down
7 changes: 4 additions & 3 deletions SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Supported Features

The emulator implements **131 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**61.8%**).
The emulator implements **140 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**66.0%**).

Endpoint coverage says whether a route exists, not whether a
feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is
Expand All @@ -22,12 +22,13 @@ answers "can I actually emulate this?".
| Organizations | ✅ 5/5 | ✅ 6/6 | ✅ seed `organizations` | |
| User Management | ✅ 8/8 | ⚠️ 7/9 | ✅ seed `users` | Email-change confirm/send endpoints are not implemented. |
| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. |
| Organization Memberships | ⚠️ 2/3 | ✅ 5/5 | ⚠️ API only | Seeded via `memberships` nested under an organization. Membership groups are not implemented. |
| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. |
| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. |
| Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | |
| SSO | ✅ 5/5 | ✅ 3/3 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. |
| Directory Sync | ✅ 6/6 | ✅ 1/1 | ❌ none | Read-only. Every spec endpoint is implemented and all `dsync.*` events are wired, but nothing can create a directory: there is no POST route and no seed key. Node callers can insert directly via `getWorkOSStore(emulator.store)`, which does emit the events. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. |
| Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. |
| FGA / Authorization | ⚠️ 13/22 | ⚠️ 13/31 | ✅ seed `roles`, `permissions` | Warrant/check semantics are partial; group endpoints are not implemented. |
| FGA / Authorization | ⚠️ 13/19 | ⚠️ 13/26 | ✅ seed `roles`, `permissions` | Warrant/check semantics are partial; group role assignments are not implemented. |
| Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. |
| Vault | ❌ 0/5 | ❌ 0/6 | ❌ none | Not implemented. |
| Feature Flags | ✅ 4/4 | ⚠️ 1/4 | ⚠️ API only | Enable/disable and targeting exist, but under different verbs than the spec (`POST /feature-flags/:slug/enable` where the spec says `PUT`), so they do not count toward coverage. |
Expand Down
5 changes: 5 additions & 0 deletions scripts/gen-supported-lib.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ describe('deriveSetup', () => {
expect(cell).toEqual({ level: 'none', label: 'none' });
});

it('reports seeding via a nested config section that is not a top-level key', () => {
const cell = deriveSetup({ name: 'Groups', tags: [], seedVia: 'groups' }, seedKeys, 1);
expect(cell).toEqual({ level: 'full', label: 'seed `groups`' });
});

it('reports automatic for features whose data is a side effect', () => {
const cell = deriveSetup({ name: 'Events', tags: [], automatic: true }, seedKeys, 0);
expect(cell).toEqual({ level: 'full', label: 'automatic' });
Expand Down
25 changes: 22 additions & 3 deletions scripts/gen-supported-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ export interface FeatureDef {
* silently promising seeding that does not exist.
*/
seedKeys?: string[];
/**
* The feature is seedable through a config section that is not a top-level
* `EmulatorSeedConfig` key — e.g. `memberships` or `groups` nested under
* `organizations` — so it cannot be verified the way `seedKeys` can. When set,
* `Set up` reports seeding honestly instead of falling back to "API only";
* the note explains where the nested key lives. A top-level `seedKeys` entry,
* if present, takes priority.
*/
seedVia?: string;
/**
* Path prefixes for emulator-specific creation routes that have no spec
* equivalent, e.g. `POST /feature-flags/:slug/enable`. These are excluded
Expand Down Expand Up @@ -110,7 +119,14 @@ export const FEATURES: FeatureDef[] = [
{
name: 'Organization Memberships',
tags: ['user-management.organization-membership', 'user-management.organization-membership.groups'],
notes: 'Seeded via `memberships` nested under an organization. Membership groups are not implemented.',
seedVia: 'memberships',
notes: 'Seeded via `memberships` nested under an organization.',
},
{
name: 'Groups',
tags: ['groups'],
seedVia: 'groups',
notes: 'Seeded via `groups` nested under an organization. Members reference a seeded membership by email.',
},
{
name: 'Invitations',
Expand All @@ -136,9 +152,9 @@ export const FEATURES: FeatureDef[] = [
},
{
name: 'FGA / Authorization',
tags: ['authorization', 'permissions', 'groups'],
tags: ['authorization', 'permissions'],
seedKeys: ['roles', 'permissions'],
notes: 'Warrant/check semantics are partial; group endpoints are not implemented.',
notes: 'Warrant/check semantics are partial; group role assignments are not implemented.',
},
{
name: 'Audit Logs',
Expand Down Expand Up @@ -474,6 +490,9 @@ export function deriveSetup(feature: FeatureDef, seedConfigKeys: string[], imple
if (seedKeys.length > 0) {
return { level: 'full', label: `seed \`${seedKeys.join('`, `')}\`` };
}
if (feature.seedVia) {
return { level: 'full', label: `seed \`${feature.seedVia}\`` };
}
if (feature.automatic) {
return { level: 'full', label: 'automatic' };
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export const ID_PREFIXES = {
organization: 'org',
organization_membership: 'om',
organization_domain: 'org_domain',
group: 'group',
group_membership: 'gm',
connection: 'conn',
connection_domain: 'conn_domain',
directory: 'directory',
Expand Down
84 changes: 84 additions & 0 deletions src/workos/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,90 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe
});
}
}
if (org.groups) {
if (!Array.isArray(org.groups)) {
errors.push({
path: `organizations[${index}].groups`,
message: 'groups must be an array if provided',
value: org.groups,
});
} else {
// Group members reference an org membership by the user's email, and that
// membership must be one declared in this org's `memberships` — the only seed
// path that creates org memberships. Collect those emails to cross-reference,
// the way `userEmails` cross-references membership emails against users.
// Guard against a truthy non-array `memberships`: the memberships block above
// already recorded a structured error for that, and falling through to here
// would call `.map()` on the invalid value and crash startup instead of
// returning that error.
const orgMembershipEmails = new Set(
(Array.isArray(org.memberships) ? org.memberships : [])
.map((m) => seedEmail(m.email))
.filter((r): r is { ok: true; email: string } => r.ok)
.map((r) => r.email.toLowerCase()),
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
org.groups.forEach((group, gIndex) => {
// A non-object entry (e.g. `groups: [null]` from a YAML/JSON typo) would
// throw on `group.name` below; record a structured error and skip the
// property checks rather than crashing startup or `--validate-config`.
if (group === null || typeof group !== 'object') {
errors.push({
path: `organizations[${index}].groups[${gIndex}]`,
message: 'each group must be an object',
value: group,
});
return;
}
if (!group.name || typeof group.name !== 'string') {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
errors.push({
path: `organizations[${index}].groups[${gIndex}].name`,
message: 'name is required and must be a string',
value: group.name,
});
}
if (
group.description !== undefined &&
group.description !== null &&
typeof group.description !== 'string'
) {
errors.push({
path: `organizations[${index}].groups[${gIndex}].description`,
message: 'description must be a string or null if provided',
value: group.description,
});
}
if (group.members) {
if (!Array.isArray(group.members)) {
errors.push({
path: `organizations[${index}].groups[${gIndex}].members`,
message: 'members must be an array of emails if provided',
value: group.members,
});
} else {
group.members.forEach((email, mIndex) => {
const memberEmail = seedEmail(email);
if (!memberEmail.ok) {
errors.push({
path: `organizations[${index}].groups[${gIndex}].members[${mIndex}]`,
message:
memberEmail.problem === 'malformed'
? 'must be a valid email address'
: 'each member must be the email of a user',
value: email,
});
} else if (!orgMembershipEmails.has(memberEmail.email.toLowerCase())) {
errors.push({
path: `organizations[${index}].groups[${gIndex}].members[${mIndex}]`,
message: "member email must match a membership defined in this organization's `memberships`",
value: email,
});
}
});
}
}
});
}
}
});

// Organization name is the lookup key for connections, connectApplications, and
Expand Down
14 changes: 14 additions & 0 deletions src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export interface WorkOSOrganizationMembership extends Entity {
metadata: Record<string, string>;
}

/** An AuthKit group within an organization (`/organizations/{org}/groups`). */
export interface WorkOSGroup extends Entity {
object: 'group';
organization_id: string;
name: string;
description: string | null;
}

/** Join between a group and an organization membership. Internal — never serialized by id. */
export interface WorkOSGroupMembership extends Entity {
group_id: string;
organization_membership_id: string;
}

export interface WorkOSUser extends Entity {
object: 'user';
email: string;
Expand Down
27 changes: 27 additions & 0 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
WorkOSOrganization,
WorkOSOrganizationDomain,
WorkOSOrganizationMembership,
WorkOSGroup,
WorkOSUser,
WorkOSSession,
WorkOSEmailVerification,
Expand Down Expand Up @@ -148,6 +149,32 @@ export function formatMembershipEvent(m: WorkOSOrganizationMembership): Record<s
};
}

/**
* The slimmer membership shape `GET .../groups/{groupId}/organization-memberships` returns —
* the spec's `UserlandUserOrganizationMembershipBase`: the identifying fields without the
* embedded `user` or `roles` the full membership serializer adds. The group-members list
* endpoint is the only caller; the full `formatMembership` is wrong there because the base
* schema has no `user` or `roles` property.
*/
export function formatMembershipBase(m: WorkOSOrganizationMembership): Record<string, unknown> {
return {
object: 'organization_membership',
id: m.id,
user_id: m.user_id,
organization_id: m.organization_id,
status: m.status,
directory_managed: false,
custom_attributes: {},
created_at: m.created_at,
updated_at: m.updated_at,
};
}

/** An AuthKit group (`group` object). `formatEntity` yields exactly the spec's `Group` shape. */
export function formatGroup(g: WorkOSGroup): Record<string, unknown> {
return formatEntity(g);
}

const USER_EXCLUDE = new Set([...INTERNAL_FIELDS, 'impersonator', 'oauth_provider']);

export function formatUser(user: WorkOSUser): Record<string, unknown> {
Expand Down
Loading
Loading