From 4ccbe468e45e89b6f63b97aeae11863395cc4b61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:28:42 +0000 Subject: [PATCH 1/2] Initial plan From 0d946cf83c0ff5d72769d80f8c88393a5d970568 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:34:48 +0000 Subject: [PATCH 2/2] Add Multi-Organization architecture to protocol - Created organization.zod.ts with OrganizationSchema, MemberSchema, and InvitationSchema - Updated SessionSchema to include activeOrganizationId for context switching - Added organization configuration block to AuthConfigSchema - Exported organization schemas in index.ts - Added comprehensive tests for all new schemas - Generated JSON schemas and documentation Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- content/docs/references/system/AuthConfig.mdx | 1 + content/docs/references/system/Invitation.mdx | 18 + .../references/system/InvitationStatus.mdx | 11 + content/docs/references/system/Member.mdx | 15 + .../docs/references/system/Organization.mdx | 16 + content/docs/references/system/Session.mdx | 1 + packages/spec/json-schema/AuthConfig.json | 27 ++ packages/spec/json-schema/Invitation.json | 69 ++++ .../spec/json-schema/InvitationStatus.json | 15 + packages/spec/json-schema/Member.json | 46 +++ packages/spec/json-schema/Organization.json | 52 +++ packages/spec/json-schema/Session.json | 4 + .../json-schema/StandardAuthProvider.json | 27 ++ packages/spec/src/index.ts | 1 + packages/spec/src/system/auth.test.ts | 65 ++++ packages/spec/src/system/auth.zod.ts | 14 + packages/spec/src/system/identity.test.ts | 27 ++ packages/spec/src/system/identity.zod.ts | 6 + packages/spec/src/system/organization.test.ts | 326 ++++++++++++++++++ packages/spec/src/system/organization.zod.ts | 158 +++++++++ 20 files changed, 899 insertions(+) create mode 100644 content/docs/references/system/Invitation.mdx create mode 100644 content/docs/references/system/InvitationStatus.mdx create mode 100644 content/docs/references/system/Member.mdx create mode 100644 content/docs/references/system/Organization.mdx create mode 100644 packages/spec/json-schema/Invitation.json create mode 100644 packages/spec/json-schema/InvitationStatus.json create mode 100644 packages/spec/json-schema/Member.json create mode 100644 packages/spec/json-schema/Organization.json create mode 100644 packages/spec/src/system/organization.test.ts create mode 100644 packages/spec/src/system/organization.zod.ts diff --git a/content/docs/references/system/AuthConfig.mdx b/content/docs/references/system/AuthConfig.mdx index 514a25cee..3844175d1 100644 --- a/content/docs/references/system/AuthConfig.mdx +++ b/content/docs/references/system/AuthConfig.mdx @@ -22,6 +22,7 @@ description: AuthConfig Schema Reference | **csrf** | `object` | optional | | | **accountLinking** | `object` | optional | | | **twoFactor** | `object` | optional | | +| **organization** | `object` | optional | Organization/multi-tenant configuration | | **enterprise** | `object` | optional | | | **userFieldMapping** | `object` | optional | | | **database** | `object` | optional | | diff --git a/content/docs/references/system/Invitation.mdx b/content/docs/references/system/Invitation.mdx new file mode 100644 index 000000000..6b1dc9901 --- /dev/null +++ b/content/docs/references/system/Invitation.mdx @@ -0,0 +1,18 @@ +--- +title: Invitation +description: Invitation Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique invitation identifier | +| **organizationId** | `string` | ✅ | Organization ID | +| **email** | `string` | ✅ | Invitee email address | +| **role** | `string` | ✅ | Role to assign upon acceptance | +| **status** | `Enum<'pending' \| 'accepted' \| 'rejected' \| 'expired'>` | optional | Invitation status | +| **expiresAt** | `string` | ✅ | Invitation expiry timestamp | +| **inviterId** | `string` | ✅ | User ID of the inviter | +| **createdAt** | `string` | ✅ | Invitation creation timestamp | +| **updatedAt** | `string` | ✅ | Last update timestamp | diff --git a/content/docs/references/system/InvitationStatus.mdx b/content/docs/references/system/InvitationStatus.mdx new file mode 100644 index 000000000..8969996d1 --- /dev/null +++ b/content/docs/references/system/InvitationStatus.mdx @@ -0,0 +1,11 @@ +--- +title: InvitationStatus +description: InvitationStatus Schema Reference +--- + +## Allowed Values + +* `pending` +* `accepted` +* `rejected` +* `expired` \ No newline at end of file diff --git a/content/docs/references/system/Member.mdx b/content/docs/references/system/Member.mdx new file mode 100644 index 000000000..f02aac973 --- /dev/null +++ b/content/docs/references/system/Member.mdx @@ -0,0 +1,15 @@ +--- +title: Member +description: Member Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique member identifier | +| **organizationId** | `string` | ✅ | Organization ID | +| **userId** | `string` | ✅ | User ID | +| **role** | `string` | ✅ | Member role (e.g., owner, admin, member, guest) | +| **createdAt** | `string` | ✅ | Member creation timestamp | +| **updatedAt** | `string` | ✅ | Last update timestamp | diff --git a/content/docs/references/system/Organization.mdx b/content/docs/references/system/Organization.mdx new file mode 100644 index 000000000..5afbfecce --- /dev/null +++ b/content/docs/references/system/Organization.mdx @@ -0,0 +1,16 @@ +--- +title: Organization +description: Organization Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique organization identifier | +| **name** | `string` | ✅ | Organization display name | +| **slug** | `string` | ✅ | Unique URL-friendly slug (lowercase alphanumeric, hyphens, underscores) | +| **logo** | `string` | optional | Organization logo URL | +| **metadata** | `Record` | optional | Custom metadata | +| **createdAt** | `string` | ✅ | Organization creation timestamp | +| **updatedAt** | `string` | ✅ | Last update timestamp | diff --git a/content/docs/references/system/Session.mdx b/content/docs/references/system/Session.mdx index dd7d3226b..8423d852a 100644 --- a/content/docs/references/system/Session.mdx +++ b/content/docs/references/system/Session.mdx @@ -10,6 +10,7 @@ description: Session Schema Reference | **id** | `string` | ✅ | Unique session identifier | | **sessionToken** | `string` | ✅ | Session token | | **userId** | `string` | ✅ | Associated user ID | +| **activeOrganizationId** | `string` | optional | Active organization ID for context switching | | **expires** | `string` | ✅ | Session expiry timestamp | | **createdAt** | `string` | ✅ | Session creation timestamp | | **updatedAt** | `string` | ✅ | Last update timestamp | diff --git a/packages/spec/json-schema/AuthConfig.json b/packages/spec/json-schema/AuthConfig.json index 56a131403..342e969fe 100644 --- a/packages/spec/json-schema/AuthConfig.json +++ b/packages/spec/json-schema/AuthConfig.json @@ -381,6 +381,33 @@ }, "additionalProperties": false }, + "organization": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable organization/multi-tenant features" + }, + "allowUserToCreateOrg": { + "type": "boolean", + "default": true, + "description": "Allow users to create organizations" + }, + "defaultRole": { + "type": "string", + "default": "member", + "description": "Default role for new members" + }, + "creatorRole": { + "type": "string", + "default": "owner", + "description": "Role assigned to organization creator" + } + }, + "additionalProperties": false, + "description": "Organization/multi-tenant configuration" + }, "enterprise": { "type": "object", "properties": { diff --git a/packages/spec/json-schema/Invitation.json b/packages/spec/json-schema/Invitation.json new file mode 100644 index 000000000..b64bed7c7 --- /dev/null +++ b/packages/spec/json-schema/Invitation.json @@ -0,0 +1,69 @@ +{ + "$ref": "#/definitions/Invitation", + "definitions": { + "Invitation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique invitation identifier" + }, + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "email": { + "type": "string", + "format": "email", + "description": "Invitee email address" + }, + "role": { + "type": "string", + "description": "Role to assign upon acceptance" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "accepted", + "rejected", + "expired" + ], + "default": "pending", + "description": "Invitation status" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "Invitation expiry timestamp" + }, + "inviterId": { + "type": "string", + "description": "User ID of the inviter" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Invitation creation timestamp" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp" + } + }, + "required": [ + "id", + "organizationId", + "email", + "role", + "expiresAt", + "inviterId", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/InvitationStatus.json b/packages/spec/json-schema/InvitationStatus.json new file mode 100644 index 000000000..0515376aa --- /dev/null +++ b/packages/spec/json-schema/InvitationStatus.json @@ -0,0 +1,15 @@ +{ + "$ref": "#/definitions/InvitationStatus", + "definitions": { + "InvitationStatus": { + "type": "string", + "enum": [ + "pending", + "accepted", + "rejected", + "expired" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Member.json b/packages/spec/json-schema/Member.json new file mode 100644 index 000000000..5c6ffe159 --- /dev/null +++ b/packages/spec/json-schema/Member.json @@ -0,0 +1,46 @@ +{ + "$ref": "#/definitions/Member", + "definitions": { + "Member": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique member identifier" + }, + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "userId": { + "type": "string", + "description": "User ID" + }, + "role": { + "type": "string", + "description": "Member role (e.g., owner, admin, member, guest)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Member creation timestamp" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp" + } + }, + "required": [ + "id", + "organizationId", + "userId", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Organization.json b/packages/spec/json-schema/Organization.json new file mode 100644 index 000000000..19ac2e69f --- /dev/null +++ b/packages/spec/json-schema/Organization.json @@ -0,0 +1,52 @@ +{ + "$ref": "#/definitions/Organization", + "definitions": { + "Organization": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique organization identifier" + }, + "name": { + "type": "string", + "description": "Organization display name" + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9_-]+$", + "description": "Unique URL-friendly slug (lowercase alphanumeric, hyphens, underscores)" + }, + "logo": { + "type": "string", + "format": "uri", + "description": "Organization logo URL" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Custom metadata" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Organization creation timestamp" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp" + } + }, + "required": [ + "id", + "name", + "slug", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/Session.json b/packages/spec/json-schema/Session.json index fe11b29f1..e936829e3 100644 --- a/packages/spec/json-schema/Session.json +++ b/packages/spec/json-schema/Session.json @@ -16,6 +16,10 @@ "type": "string", "description": "Associated user ID" }, + "activeOrganizationId": { + "type": "string", + "description": "Active organization ID for context switching" + }, "expires": { "type": "string", "format": "date-time", diff --git a/packages/spec/json-schema/StandardAuthProvider.json b/packages/spec/json-schema/StandardAuthProvider.json index 12b41ef5f..81573d8a7 100644 --- a/packages/spec/json-schema/StandardAuthProvider.json +++ b/packages/spec/json-schema/StandardAuthProvider.json @@ -389,6 +389,33 @@ }, "additionalProperties": false }, + "organization": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable organization/multi-tenant features" + }, + "allowUserToCreateOrg": { + "type": "boolean", + "default": true, + "description": "Allow users to create organizations" + }, + "defaultRole": { + "type": "string", + "default": "member", + "description": "Default role for new members" + }, + "creatorRole": { + "type": "string", + "default": "owner", + "description": "Role assigned to organization creator" + } + }, + "additionalProperties": false, + "description": "Organization/multi-tenant configuration" + }, "enterprise": { "type": "object", "properties": { diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index db5504553..6bb1aaba1 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -44,6 +44,7 @@ export * from './system/api.zod'; export * from './system/identity.zod'; // User, Account, Session models export * from './system/auth.zod'; // Authentication configuration export * from './system/auth-protocol'; // Authentication wire protocol & constants +export * from './system/organization.zod'; // Organization, Member, Invitation models export * from './system/policy.zod'; export * from './system/role.zod'; export * from './system/territory.zod'; diff --git a/packages/spec/src/system/auth.test.ts b/packages/spec/src/system/auth.test.ts index 4c4fc40f9..3cd13aeeb 100644 --- a/packages/spec/src/system/auth.test.ts +++ b/packages/spec/src/system/auth.test.ts @@ -1019,6 +1019,71 @@ describe('AuthConfigSchema', () => { expect(result.allowRegistration).toBe(true); expect(result.plugins).toEqual([]); }); + + it('should accept configuration with organization settings', () => { + const config = { + name: 'test_auth', + label: 'Test', + strategies: ['email_password'], + baseUrl: 'https://example.com', + secret: 'a'.repeat(32), + session: {}, + rateLimit: {}, + csrf: {}, + accountLinking: {}, + organization: { + enabled: true, + allowUserToCreateOrg: true, + defaultRole: 'member', + creatorRole: 'owner', + }, + }; + + expect(() => AuthConfigSchema.parse(config)).not.toThrow(); + }); + + it('should use default values for organization settings', () => { + const config = { + name: 'test_auth', + label: 'Test', + strategies: ['email_password'], + baseUrl: 'https://example.com', + secret: 'a'.repeat(32), + session: {}, + rateLimit: {}, + csrf: {}, + accountLinking: {}, + organization: {}, + }; + + const result = AuthConfigSchema.parse(config); + + expect(result.organization?.enabled).toBe(false); + expect(result.organization?.allowUserToCreateOrg).toBe(true); + expect(result.organization?.defaultRole).toBe('member'); + expect(result.organization?.creatorRole).toBe('owner'); + }); + + it('should accept configuration with organization disabled', () => { + const config = { + name: 'test_auth', + label: 'Test', + strategies: ['email_password'], + baseUrl: 'https://example.com', + secret: 'a'.repeat(32), + session: {}, + rateLimit: {}, + csrf: {}, + accountLinking: {}, + organization: { + enabled: false, + }, + }; + + const result = AuthConfigSchema.parse(config); + + expect(result.organization?.enabled).toBe(false); + }); }); describe('StandardAuthProviderSchema', () => { diff --git a/packages/spec/src/system/auth.zod.ts b/packages/spec/src/system/auth.zod.ts index a45170ad0..ee7b10800 100644 --- a/packages/spec/src/system/auth.zod.ts +++ b/packages/spec/src/system/auth.zod.ts @@ -520,6 +520,20 @@ export const AuthConfigSchema = z.object({ */ twoFactor: TwoFactorConfigSchema.optional(), + /** + * Organization (Multi-tenant) configuration + * Enables B2B SaaS scenarios where users belong to multiple teams/workspaces + */ + organization: z.object({ + enabled: z.boolean().default(false).describe('Enable organization/multi-tenant features'), + + allowUserToCreateOrg: z.boolean().default(true).describe('Allow users to create organizations'), + + defaultRole: z.string().default('member').describe('Default role for new members'), + + creatorRole: z.string().default('owner').describe('Role assigned to organization creator'), + }).optional().describe('Organization/multi-tenant configuration'), + /** * Enterprise authentication configuration (SAML, LDAP, OIDC) */ diff --git a/packages/spec/src/system/identity.test.ts b/packages/spec/src/system/identity.test.ts index 0851c129a..e954fdd18 100644 --- a/packages/spec/src/system/identity.test.ts +++ b/packages/spec/src/system/identity.test.ts @@ -183,6 +183,33 @@ describe('SessionSchema', () => { expect(() => SessionSchema.parse(session)).not.toThrow(); }); + + it('should accept session with activeOrganizationId', () => { + const session = { + id: 'session_123', + sessionToken: 'session_token_xyz', + userId: 'user_123', + activeOrganizationId: 'org_123', + expires: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => SessionSchema.parse(session)).not.toThrow(); + }); + + it('should accept session without activeOrganizationId', () => { + const session = { + id: 'session_123', + sessionToken: 'session_token_xyz', + userId: 'user_123', + expires: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => SessionSchema.parse(session)).not.toThrow(); + }); }); describe('VerificationTokenSchema', () => { diff --git a/packages/spec/src/system/identity.zod.ts b/packages/spec/src/system/identity.zod.ts index 655b25222..bf75ff9d3 100644 --- a/packages/spec/src/system/identity.zod.ts +++ b/packages/spec/src/system/identity.zod.ts @@ -158,6 +158,12 @@ export const SessionSchema = z.object({ */ userId: z.string().describe('Associated user ID'), + /** + * Active organization ID for this session + * Used for context switching in multi-tenant applications + */ + activeOrganizationId: z.string().optional().describe('Active organization ID for context switching'), + /** * Session expiry timestamp */ diff --git a/packages/spec/src/system/organization.test.ts b/packages/spec/src/system/organization.test.ts new file mode 100644 index 000000000..a76122398 --- /dev/null +++ b/packages/spec/src/system/organization.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect } from 'vitest'; +import { + OrganizationSchema, + MemberSchema, + InvitationSchema, + InvitationStatus, + type Organization, + type Member, + type Invitation, +} from "./organization.zod"; + +describe('OrganizationSchema', () => { + it('should accept valid organization data', () => { + const org: Organization = { + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + logo: 'https://example.com/logo.png', + metadata: { + industry: 'Technology', + size: 'Enterprise', + }, + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => OrganizationSchema.parse(org)).not.toThrow(); + }); + + it('should accept minimal organization data', () => { + const org = { + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => OrganizationSchema.parse(org)).not.toThrow(); + }); + + it('should validate slug format', () => { + const validSlugs = [ + 'acme-corp', + 'my_organization', + 'test123', + 'org-123', + 'my_org-123', + ]; + + validSlugs.forEach((slug) => { + const org = { + id: 'org_123', + name: 'Test Org', + slug, + createdAt: new Date(), + updatedAt: new Date(), + }; + expect(() => OrganizationSchema.parse(org)).not.toThrow(); + }); + }); + + it('should reject invalid slug format', () => { + const invalidSlugs = [ + 'Acme Corp', // spaces and uppercase + 'acme.corp', // dots + 'acme@corp', // special characters + 'ACME', // uppercase + 'acme corp', // spaces + ]; + + invalidSlugs.forEach((slug) => { + const org = { + id: 'org_123', + name: 'Test Org', + slug, + createdAt: new Date(), + updatedAt: new Date(), + }; + expect(() => OrganizationSchema.parse(org)).toThrow(); + }); + }); + + it('should validate logo URL format', () => { + const org = { + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + logo: 'not-a-url', + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => OrganizationSchema.parse(org)).toThrow(); + }); + + it('should accept organization with metadata', () => { + const org = { + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + metadata: { + industry: 'Technology', + size: 'Enterprise', + customField: 'Custom Value', + nested: { + key: 'value', + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => OrganizationSchema.parse(org)).not.toThrow(); + }); +}); + +describe('MemberSchema', () => { + it('should accept valid member data', () => { + const member: Member = { + id: 'member_123', + organizationId: 'org_123', + userId: 'user_123', + role: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => MemberSchema.parse(member)).not.toThrow(); + }); + + it('should accept different role types', () => { + const roles = ['owner', 'admin', 'member', 'guest', 'viewer', 'editor']; + + roles.forEach((role) => { + const member = { + id: 'member_123', + organizationId: 'org_123', + userId: 'user_123', + role, + createdAt: new Date(), + updatedAt: new Date(), + }; + expect(() => MemberSchema.parse(member)).not.toThrow(); + }); + }); + + it('should require all mandatory fields', () => { + const incompleteMember = { + id: 'member_123', + organizationId: 'org_123', + // missing userId and role + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => MemberSchema.parse(incompleteMember)).toThrow(); + }); +}); + +describe('InvitationStatus', () => { + it('should accept valid invitation statuses', () => { + const statuses = ['pending', 'accepted', 'rejected', 'expired']; + + statuses.forEach((status) => { + expect(() => InvitationStatus.parse(status)).not.toThrow(); + }); + }); + + it('should reject invalid status', () => { + expect(() => InvitationStatus.parse('invalid')).toThrow(); + }); +}); + +describe('InvitationSchema', () => { + it('should accept valid invitation data', () => { + const invitation: Invitation = { + id: 'invite_123', + organizationId: 'org_123', + email: 'newuser@example.com', + role: 'member', + status: 'pending', + expiresAt: new Date(Date.now() + 86400000), + inviterId: 'user_123', + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => InvitationSchema.parse(invitation)).not.toThrow(); + }); + + it('should use default status of pending', () => { + const invitation = { + id: 'invite_123', + organizationId: 'org_123', + email: 'newuser@example.com', + role: 'member', + expiresAt: new Date(), + inviterId: 'user_123', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = InvitationSchema.parse(invitation); + expect(result.status).toBe('pending'); + }); + + it('should accept all valid statuses', () => { + const statuses: Array<'pending' | 'accepted' | 'rejected' | 'expired'> = [ + 'pending', + 'accepted', + 'rejected', + 'expired', + ]; + + statuses.forEach((status) => { + const invitation = { + id: 'invite_123', + organizationId: 'org_123', + email: 'newuser@example.com', + role: 'member', + status, + expiresAt: new Date(), + inviterId: 'user_123', + createdAt: new Date(), + updatedAt: new Date(), + }; + expect(() => InvitationSchema.parse(invitation)).not.toThrow(); + }); + }); + + it('should validate email format', () => { + const invitation = { + id: 'invite_123', + organizationId: 'org_123', + email: 'invalid-email', + role: 'member', + expiresAt: new Date(), + inviterId: 'user_123', + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => InvitationSchema.parse(invitation)).toThrow(); + }); + + it('should accept different role types', () => { + const roles = ['admin', 'member', 'guest', 'viewer', 'editor']; + + roles.forEach((role) => { + const invitation = { + id: 'invite_123', + organizationId: 'org_123', + email: 'newuser@example.com', + role, + expiresAt: new Date(), + inviterId: 'user_123', + createdAt: new Date(), + updatedAt: new Date(), + }; + expect(() => InvitationSchema.parse(invitation)).not.toThrow(); + }); + }); + + it('should require all mandatory fields', () => { + const incompleteInvitation = { + id: 'invite_123', + organizationId: 'org_123', + // missing email, role, expiresAt, inviterId + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(() => InvitationSchema.parse(incompleteInvitation)).toThrow(); + }); +}); + +describe('Type inference', () => { + it('should correctly infer Organization type', () => { + const org: Organization = { + id: 'org_123', + name: 'Acme Corporation', + slug: 'acme-corp', + createdAt: new Date(), + updatedAt: new Date(), + }; + + // This test passes if TypeScript compiles without errors + expect(org.id).toBe('org_123'); + expect(org.name).toBe('Acme Corporation'); + expect(org.slug).toBe('acme-corp'); + }); + + it('should correctly infer Member type', () => { + const member: Member = { + id: 'member_123', + organizationId: 'org_123', + userId: 'user_123', + role: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + }; + + // This test passes if TypeScript compiles without errors + expect(member.role).toBe('admin'); + expect(member.organizationId).toBe('org_123'); + }); + + it('should correctly infer Invitation type', () => { + const invitation: Invitation = { + id: 'invite_123', + organizationId: 'org_123', + email: 'newuser@example.com', + role: 'member', + status: 'pending', + expiresAt: new Date(), + inviterId: 'user_123', + createdAt: new Date(), + updatedAt: new Date(), + }; + + // This test passes if TypeScript compiles without errors + expect(invitation.email).toBe('newuser@example.com'); + expect(invitation.status).toBe('pending'); + }); +}); diff --git a/packages/spec/src/system/organization.zod.ts b/packages/spec/src/system/organization.zod.ts new file mode 100644 index 000000000..2346498a0 --- /dev/null +++ b/packages/spec/src/system/organization.zod.ts @@ -0,0 +1,158 @@ +import { z } from 'zod'; + +/** + * Organization Schema (Multi-Tenant Architecture) + * + * Defines the standard organization/workspace model for ObjectStack. + * Supports B2B SaaS scenarios where users belong to multiple teams/workspaces. + * + * This aligns with better-auth's organization plugin capabilities. + */ + +/** + * Organization Schema + * Represents a team, workspace, or tenant in a multi-tenant application + */ +export const OrganizationSchema = z.object({ + /** + * Unique organization identifier + */ + id: z.string().describe('Unique organization identifier'), + + /** + * Organization name (display name) + */ + name: z.string().describe('Organization display name'), + + /** + * Organization slug (URL-friendly identifier) + * Must be unique across all organizations + */ + slug: z.string() + .regex(/^[a-z0-9_-]+$/) + .describe('Unique URL-friendly slug (lowercase alphanumeric, hyphens, underscores)'), + + /** + * Organization logo URL + */ + logo: z.string().url().optional().describe('Organization logo URL'), + + /** + * Custom metadata for the organization + * Can store additional configuration, settings, or custom fields + */ + metadata: z.record(z.any()).optional().describe('Custom metadata'), + + /** + * Organization creation timestamp + */ + createdAt: z.date().describe('Organization creation timestamp'), + + /** + * Last update timestamp + */ + updatedAt: z.date().describe('Last update timestamp'), +}); + +export type Organization = z.infer; + +/** + * Organization Member Schema + * Links users to organizations with specific roles + */ +export const MemberSchema = z.object({ + /** + * Unique member identifier + */ + id: z.string().describe('Unique member identifier'), + + /** + * Organization ID this membership belongs to + */ + organizationId: z.string().describe('Organization ID'), + + /** + * User ID of the member + */ + userId: z.string().describe('User ID'), + + /** + * Member's role within the organization + * Common roles: 'owner', 'admin', 'member', 'guest' + * Can be customized per application + */ + role: z.string().describe('Member role (e.g., owner, admin, member, guest)'), + + /** + * Member creation timestamp + */ + createdAt: z.date().describe('Member creation timestamp'), + + /** + * Last update timestamp + */ + updatedAt: z.date().describe('Last update timestamp'), +}); + +export type Member = z.infer; + +/** + * Invitation Status Enum + */ +export const InvitationStatus = z.enum(['pending', 'accepted', 'rejected', 'expired']); + +export type InvitationStatus = z.infer; + +/** + * Organization Invitation Schema + * Represents an invitation to join an organization + */ +export const InvitationSchema = z.object({ + /** + * Unique invitation identifier + */ + id: z.string().describe('Unique invitation identifier'), + + /** + * Organization ID the invitation is for + */ + organizationId: z.string().describe('Organization ID'), + + /** + * Email address of the invitee + */ + email: z.string().email().describe('Invitee email address'), + + /** + * Role the invitee will receive upon accepting + * Common roles: 'admin', 'member', 'guest' + */ + role: z.string().describe('Role to assign upon acceptance'), + + /** + * Invitation status + */ + status: InvitationStatus.default('pending').describe('Invitation status'), + + /** + * Invitation expiration timestamp + */ + expiresAt: z.date().describe('Invitation expiry timestamp'), + + /** + * User ID of the person who sent the invitation + */ + inviterId: z.string().describe('User ID of the inviter'), + + /** + * Invitation creation timestamp + */ + createdAt: z.date().describe('Invitation creation timestamp'), + + /** + * Last update timestamp + */ + updatedAt: z.date().describe('Last update timestamp'), +}); + +export type Invitation = z.infer;