@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,213 @@
1
+ import {
2
+ type SchemaDefinition,
3
+ type ScopeMap,
4
+ buildScopeMap,
5
+ extractScopeValuesFromClaims,
6
+ } from '@korajs/core'
7
+ import type { AuthSyncBinding, AuthSyncState } from '@korajs/core/bindings'
8
+ import type { AuthClientSession, AuthState } from './auth-client'
9
+
10
+ /**
11
+ * Minimal auth client surface required for sync integration.
12
+ * Matches {@link AuthClient} without importing implementation details.
13
+ */
14
+ export interface AuthSyncClient {
15
+ getAccessToken(): Promise<string | null>
16
+ /**
17
+ * Force a refresh even if the cached token looks valid locally. Called after
18
+ * the sync server ends a session with `AUTH_EXPIRED` / `AUTH_REVOKED`.
19
+ */
20
+ refreshAccessToken?(): Promise<string | null>
21
+ readonly state?: AuthState
22
+ onAuthChange?(callback: (state: AuthState) => void): () => void
23
+ /** Stored session freshness (authenticated-offline support). */
24
+ readonly session?: AuthClientSession | null
25
+ /** Unverified claims of the stored credentials, available offline. */
26
+ getStoredClaims?(): Promise<Record<string, unknown> | null>
27
+ /** Notifies when session freshness changes (fresh, offline, locked). */
28
+ onSessionChange?(callback: (session: AuthClientSession | null) => void): () => void
29
+ }
30
+
31
+ /**
32
+ * Sync binding returned by {@link createKoraAuthSync}.
33
+ * Passed to `createApp({ sync: { authClient } })` in korajs.
34
+ *
35
+ * @deprecated Use {@link AuthSyncBinding} from `@korajs/core/bindings` or `@korajs/auth`.
36
+ */
37
+ export type KoraAuthSyncBinding = AuthSyncBinding
38
+
39
+ /**
40
+ * Configuration for {@link createKoraAuthSync}.
41
+ */
42
+ export interface CreateKoraAuthSyncOptions {
43
+ /** Kora auth client from `createKoraAuth()`. */
44
+ authClient: AuthSyncClient
45
+ /**
46
+ * Application schema. When provided, a client-side scope hint is built from
47
+ * token claims and schema scope declarations. The server only uses it to
48
+ * NARROW its own grant; it never authorizes anything.
49
+ */
50
+ schema?: SchemaDefinition
51
+ /**
52
+ * Custom claim → flat scope value mapping.
53
+ * Defaults to {@link extractScopeValuesFromClaims}.
54
+ */
55
+ scopeFromClaims?: (claims: Record<string, unknown>) => Record<string, unknown>
56
+ /** Signed-out behavior. Authenticated-only sync is suspended by default. */
57
+ anonymous?: 'suspend' | 'allow'
58
+ }
59
+
60
+ /**
61
+ * Decode JWT payload without signature verification (client-side scope hints only).
62
+ */
63
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
64
+ const parts = token.split('.')
65
+ if (parts.length !== 3) {
66
+ return null
67
+ }
68
+
69
+ const payloadSegment = parts[1]
70
+ if (payloadSegment === undefined) {
71
+ return null
72
+ }
73
+
74
+ try {
75
+ const base64 = payloadSegment.replace(/-/g, '+').replace(/_/g, '/')
76
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=')
77
+ const json =
78
+ typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf-8')
79
+ const parsed: unknown = JSON.parse(json)
80
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
81
+ return null
82
+ }
83
+ return parsed as Record<string, unknown>
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ function readString(claims: Record<string, unknown> | null, key: string): string | undefined {
90
+ const value = claims?.[key]
91
+ return typeof value === 'string' && value.length > 0 ? value : undefined
92
+ }
93
+
94
+ /**
95
+ * Creates a sync auth binding for `createApp({ sync: { authClient: binding } })`.
96
+ *
97
+ * Wires token refresh, a client-side scope hint, and device-bound sync node ids
98
+ * (`dev` claim) separate from the user id (`sub`).
99
+ *
100
+ * Offline-first: identity (user id, device id) comes from the stored session,
101
+ * not from token freshness. While the auth server is unreachable the binding
102
+ * keeps reporting the signed-in user (authenticated-offline, `token: null`), so
103
+ * the user's own local database opens and the app stays usable; only the sync
104
+ * transport waits for a fresh token.
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * import { createKoraAuth, createKoraAuthSync } from '@korajs/auth'
109
+ * import { createApp } from 'korajs'
110
+ *
111
+ * const authClient = createKoraAuth({ serverUrl: 'https://api.example.com' })
112
+ *
113
+ * const app = createApp({
114
+ * schema,
115
+ * sync: {
116
+ * url: 'wss://api.example.com/kora-sync',
117
+ * authClient: createKoraAuthSync({ authClient, schema }),
118
+ * },
119
+ * })
120
+ * ```
121
+ */
122
+ export function createKoraAuthSync(options: CreateKoraAuthSyncOptions): AuthSyncBinding {
123
+ const { authClient, schema, scopeFromClaims, anonymous = 'suspend' } = options
124
+
125
+ /**
126
+ * Claims of the session: from a fresh token when one can be minted, else from
127
+ * the stored credentials while the client is still signed in.
128
+ */
129
+ const resolveClaims = async (): Promise<{
130
+ claims: Record<string, unknown> | null
131
+ token: string | null
132
+ }> => {
133
+ const token = await authClient.getAccessToken()
134
+ if (token) return { claims: decodeJwtPayload(token), token }
135
+ if (authClient.state === 'unauthenticated' || !authClient.getStoredClaims) {
136
+ return { claims: null, token: null }
137
+ }
138
+ return { claims: await authClient.getStoredClaims(), token: null }
139
+ }
140
+
141
+ const binding: AuthSyncBinding = {
142
+ auth: async (request) => {
143
+ // After the server ended the session for an expired or revoked credential,
144
+ // the cached token is known bad: refresh instead of re-presenting it.
145
+ const token =
146
+ request?.forceRefresh && authClient.refreshAccessToken
147
+ ? await authClient.refreshAccessToken()
148
+ : await authClient.getAccessToken()
149
+ return { token: token ?? '' }
150
+ },
151
+ }
152
+
153
+ binding.resolveSyncState = async (): Promise<AuthSyncState> => {
154
+ if (authClient.state === 'loading') return { state: 'loading' }
155
+ const { claims, token } = await resolveClaims()
156
+ const userId = readString(claims, 'sub')
157
+ if (!userId) {
158
+ return anonymous === 'allow'
159
+ ? { state: 'anonymous', mayConnectAnonymously: true }
160
+ : { state: 'signed-out', mayConnectAnonymously: false }
161
+ }
162
+ const deviceId = readString(claims, 'dev')
163
+ if (token) {
164
+ return { state: 'authenticated', userId, token, ...(deviceId ? { deviceId } : {}) }
165
+ }
166
+ return {
167
+ state: 'authenticated',
168
+ userId,
169
+ token: null,
170
+ offline: true,
171
+ locked: authClient.session?.status === 'locked',
172
+ ...(deviceId ? { deviceId } : {}),
173
+ }
174
+ }
175
+
176
+ if (schema) {
177
+ binding.resolveScopeMap = async () => {
178
+ const { claims } = await resolveClaims()
179
+ if (!claims) {
180
+ return undefined
181
+ }
182
+
183
+ const scopeValues = scopeFromClaims
184
+ ? scopeFromClaims(claims)
185
+ : extractScopeValuesFromClaims(schema, claims)
186
+
187
+ return buildScopeMap(schema, scopeValues) as ScopeMap
188
+ }
189
+ }
190
+
191
+ binding.resolveNodeId = async () => {
192
+ const { claims } = await resolveClaims()
193
+ return readString(claims, 'dev')
194
+ }
195
+
196
+ binding.resolveUserId = async () => {
197
+ const { claims } = await resolveClaims()
198
+ return readString(claims, 'sub')
199
+ }
200
+
201
+ if (authClient.onAuthChange || authClient.onSessionChange) {
202
+ binding.subscribe = (listener) => {
203
+ const offAuth = authClient.onAuthChange?.(() => listener()) ?? (() => {})
204
+ const offSession = authClient.onSessionChange?.(() => listener()) ?? (() => {})
205
+ return () => {
206
+ offAuth()
207
+ offSession()
208
+ }
209
+ }
210
+ }
211
+
212
+ return binding
213
+ }
@@ -0,0 +1,104 @@
1
+ import { KoraError } from '@korajs/core'
2
+ import { exportPublicKeyJwk, generateDeviceKeyPair } from '../device/device-identity'
3
+ import { type DeviceKeyStore, createDeviceKeyStore } from '../device/device-store'
4
+ import type { AuthKeyValueStorage } from './storage'
5
+
6
+ const DEFAULT_DEVICE_ID_KEY = 'kora_auth_device_id'
7
+
8
+ export interface AuthDeviceIdentity {
9
+ /** Stable local device ID used in token `dev` claims. */
10
+ deviceId: string
11
+ /** Public proof-of-possession key serialized as a JSON Web Key string. */
12
+ devicePublicKey: string
13
+ }
14
+
15
+ export interface AuthDeviceIdentityProvider {
16
+ getDeviceIdentity(): Promise<AuthDeviceIdentity>
17
+ }
18
+
19
+ export interface PersistentDeviceIdentityOptions {
20
+ /** Store for the stable device ID. Use a platform credential store in production. */
21
+ storage: AuthKeyValueStorage
22
+ /** Store for the non-extractable device key pair. Defaults to IndexedDB when available. */
23
+ keyStore?: DeviceKeyStore
24
+ /** Storage key for the device ID. Defaults to `kora_auth_device_id`. */
25
+ deviceIdKey?: string
26
+ /** Optional device ID generator for tests or custom device registries. */
27
+ generateDeviceId?: () => string
28
+ }
29
+
30
+ export class AuthDeviceIdentityError extends KoraError {
31
+ constructor(message: string, context?: Record<string, unknown>) {
32
+ super(message, 'AUTH_DEVICE_IDENTITY_ERROR', context)
33
+ this.name = 'AuthDeviceIdentityError'
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Creates a persistent device identity provider for `AuthClient`.
39
+ *
40
+ * The provider keeps a stable device ID in the supplied key-value store and a
41
+ * non-extractable ECDSA P-256 key pair in the supplied `DeviceKeyStore`. The
42
+ * public key is returned during sign-up/sign-in so the server can bind tokens
43
+ * to a real offline device instead of a transient browser session.
44
+ */
45
+ export function createPersistentDeviceIdentity(
46
+ options: PersistentDeviceIdentityOptions,
47
+ ): AuthDeviceIdentityProvider {
48
+ const storage = options.storage
49
+ const keyStore = options.keyStore ?? createDefaultPersistentKeyStore()
50
+ const deviceIdKey = options.deviceIdKey ?? DEFAULT_DEVICE_ID_KEY
51
+ const generateDeviceId = options.generateDeviceId ?? defaultDeviceId
52
+
53
+ return {
54
+ async getDeviceIdentity(): Promise<AuthDeviceIdentity> {
55
+ let deviceId = await storage.getItem(deviceIdKey)
56
+ if (!deviceId) {
57
+ deviceId = generateDeviceId()
58
+ await storage.setItem(deviceIdKey, deviceId)
59
+ }
60
+
61
+ let keyPair = await keyStore.loadKeyPair(deviceId)
62
+ if (!keyPair) {
63
+ keyPair = await generateDeviceKeyPair()
64
+ await keyStore.saveKeyPair(deviceId, keyPair)
65
+ }
66
+
67
+ const publicKey = await exportPublicKeyJwk(keyPair)
68
+ return {
69
+ deviceId,
70
+ devicePublicKey: JSON.stringify(publicKey),
71
+ }
72
+ },
73
+ }
74
+ }
75
+
76
+ function createDefaultPersistentKeyStore(): DeviceKeyStore {
77
+ if (typeof globalThis.indexedDB !== 'undefined') {
78
+ return createDeviceKeyStore()
79
+ }
80
+
81
+ throw new AuthDeviceIdentityError(
82
+ 'No persistent device key store is available in this runtime. Pass `keyStore` to createPersistentDeviceIdentity().',
83
+ )
84
+ }
85
+
86
+ function defaultDeviceId(): string {
87
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
88
+ return globalThis.crypto.randomUUID()
89
+ }
90
+
91
+ const bytes = new Uint8Array(16)
92
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
93
+ globalThis.crypto.getRandomValues(bytes)
94
+ } else {
95
+ for (let i = 0; i < bytes.length; i++) {
96
+ bytes[i] = Math.floor(Math.random() * 256)
97
+ }
98
+ }
99
+
100
+ bytes[6] = ((bytes[6] as number) & 0x0f) | 0x40
101
+ bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80
102
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
103
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
104
+ }
@@ -0,0 +1,399 @@
1
+ import { KoraError } from '@korajs/core'
2
+
3
+ // ============================================================================
4
+ // Types
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Organization info returned by the server.
9
+ */
10
+ export interface ClientOrganization {
11
+ id: string
12
+ name: string
13
+ slug: string
14
+ ownerId: string
15
+ createdAt: number
16
+ updatedAt: number
17
+ metadata: Record<string, unknown>
18
+ }
19
+
20
+ /**
21
+ * Membership info returned by the server.
22
+ */
23
+ export interface ClientMembership {
24
+ id: string
25
+ orgId: string
26
+ userId: string
27
+ role: string
28
+ invitedBy: string | null
29
+ joinedAt: number
30
+ }
31
+
32
+ /**
33
+ * Invitation info returned by the server.
34
+ */
35
+ export interface ClientInvitation {
36
+ id: string
37
+ orgId: string
38
+ email: string
39
+ role: string
40
+ invitedBy: string
41
+ token: string
42
+ createdAt: number
43
+ expiresAt: number
44
+ status: string
45
+ }
46
+
47
+ /**
48
+ * Configuration for the OrgClient.
49
+ */
50
+ export interface OrgClientConfig {
51
+ /** Base URL of the auth/org server */
52
+ serverUrl: string
53
+ /** Function that returns a valid access token for authenticated requests */
54
+ getAccessToken: () => Promise<string | null>
55
+ }
56
+
57
+ /**
58
+ * Thrown when an org operation fails.
59
+ */
60
+ export class OrgClientError extends KoraError {
61
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
62
+ super(message, code, context)
63
+ this.name = 'OrgClientError'
64
+ }
65
+ }
66
+
67
+ // ============================================================================
68
+ // OrgClient
69
+ // ============================================================================
70
+
71
+ /**
72
+ * Client-side organization manager.
73
+ *
74
+ * Handles org CRUD, member management, invitations, and active org switching.
75
+ * Framework-agnostic — works in any JavaScript environment with `fetch`.
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const orgClient = new OrgClient({
80
+ * serverUrl: 'http://localhost:3001',
81
+ * getAccessToken: () => authClient.getAccessToken(),
82
+ * })
83
+ *
84
+ * const org = await orgClient.createOrg({ name: 'Acme Inc', slug: 'acme' })
85
+ * orgClient.switchOrg(org.id)
86
+ * ```
87
+ */
88
+ export class OrgClient {
89
+ private readonly serverUrl: string
90
+ private readonly getAccessToken: () => Promise<string | null>
91
+ private readonly listeners = new Set<(orgId: string | null) => void>()
92
+
93
+ private _activeOrgId: string | null = null
94
+ private _activeOrg: ClientOrganization | null = null
95
+ private _activeRole: string | null = null
96
+
97
+ constructor(config: OrgClientConfig) {
98
+ this.serverUrl = config.serverUrl.replace(/\/+$/, '')
99
+ this.getAccessToken = config.getAccessToken
100
+ }
101
+
102
+ // --- Getters ---
103
+
104
+ /** Currently active organization ID */
105
+ get activeOrgId(): string | null {
106
+ return this._activeOrgId
107
+ }
108
+
109
+ /** Currently active organization */
110
+ get activeOrg(): ClientOrganization | null {
111
+ return this._activeOrg
112
+ }
113
+
114
+ /** Current user's role in the active organization */
115
+ get activeRole(): string | null {
116
+ return this._activeRole
117
+ }
118
+
119
+ // --- Organization Operations ---
120
+
121
+ /**
122
+ * Create a new organization.
123
+ */
124
+ async createOrg(params: {
125
+ name: string
126
+ slug?: string
127
+ metadata?: Record<string, unknown>
128
+ }): Promise<ClientOrganization> {
129
+ return this.request<ClientOrganization>('/orgs', {
130
+ method: 'POST',
131
+ body: params,
132
+ })
133
+ }
134
+
135
+ /**
136
+ * List all organizations the current user belongs to.
137
+ */
138
+ async listOrgs(): Promise<ClientOrganization[]> {
139
+ return this.request<ClientOrganization[]>('/orgs', { method: 'GET' })
140
+ }
141
+
142
+ /**
143
+ * Get an organization by ID.
144
+ */
145
+ async getOrg(orgId: string): Promise<ClientOrganization> {
146
+ return this.request<ClientOrganization>(`/orgs/${orgId}`, { method: 'GET' })
147
+ }
148
+
149
+ /**
150
+ * Update an organization.
151
+ */
152
+ async updateOrg(
153
+ orgId: string,
154
+ params: { name?: string; slug?: string; metadata?: Record<string, unknown> },
155
+ ): Promise<ClientOrganization> {
156
+ const result = await this.request<ClientOrganization>(`/orgs/${orgId}`, {
157
+ method: 'PATCH',
158
+ body: params,
159
+ })
160
+ // Update cached active org if this is the active one
161
+ if (this._activeOrgId === orgId) {
162
+ this._activeOrg = result
163
+ }
164
+ return result
165
+ }
166
+
167
+ /**
168
+ * Delete an organization.
169
+ */
170
+ async deleteOrg(orgId: string): Promise<void> {
171
+ await this.request(`/orgs/${orgId}`, { method: 'DELETE' })
172
+ if (this._activeOrgId === orgId) {
173
+ this._activeOrgId = null
174
+ this._activeOrg = null
175
+ this._activeRole = null
176
+ this.notifyListeners()
177
+ }
178
+ }
179
+
180
+ // --- Org Switching ---
181
+
182
+ /**
183
+ * Switch the active organization context.
184
+ * Fetches the org details and the user's membership/role.
185
+ */
186
+ async switchOrg(orgId: string): Promise<void> {
187
+ const org = await this.request<ClientOrganization>(`/orgs/${orgId}`, { method: 'GET' })
188
+ const membership = await this.request<ClientMembership>(`/orgs/${orgId}/membership`, {
189
+ method: 'GET',
190
+ })
191
+
192
+ this._activeOrgId = orgId
193
+ this._activeOrg = org
194
+ this._activeRole = membership.role
195
+ this.notifyListeners()
196
+ }
197
+
198
+ /**
199
+ * Clear the active organization (no org selected).
200
+ */
201
+ clearActiveOrg(): void {
202
+ this._activeOrgId = null
203
+ this._activeOrg = null
204
+ this._activeRole = null
205
+ this.notifyListeners()
206
+ }
207
+
208
+ // --- Member Management ---
209
+
210
+ /**
211
+ * List members of an organization.
212
+ */
213
+ async listMembers(orgId: string): Promise<ClientMembership[]> {
214
+ return this.request<ClientMembership[]>(`/orgs/${orgId}/members`, { method: 'GET' })
215
+ }
216
+
217
+ /**
218
+ * Remove a member from an organization.
219
+ */
220
+ async removeMember(orgId: string, userId: string): Promise<void> {
221
+ await this.request(`/orgs/${orgId}/members/${userId}`, { method: 'DELETE' })
222
+ }
223
+
224
+ /**
225
+ * Update a member's role.
226
+ */
227
+ async updateMemberRole(orgId: string, userId: string, role: string): Promise<ClientMembership> {
228
+ return this.request<ClientMembership>(`/orgs/${orgId}/members/${userId}/role`, {
229
+ method: 'PATCH',
230
+ body: { role },
231
+ })
232
+ }
233
+
234
+ /**
235
+ * Transfer ownership to another member.
236
+ */
237
+ async transferOwnership(orgId: string, newOwnerId: string): Promise<void> {
238
+ await this.request(`/orgs/${orgId}/transfer`, {
239
+ method: 'POST',
240
+ body: { newOwnerId },
241
+ })
242
+ }
243
+
244
+ /**
245
+ * Leave an organization (remove yourself).
246
+ */
247
+ async leaveOrg(orgId: string): Promise<void> {
248
+ await this.request(`/orgs/${orgId}/leave`, { method: 'POST' })
249
+ if (this._activeOrgId === orgId) {
250
+ this._activeOrgId = null
251
+ this._activeOrg = null
252
+ this._activeRole = null
253
+ this.notifyListeners()
254
+ }
255
+ }
256
+
257
+ // --- Invitations ---
258
+
259
+ /**
260
+ * Invite a user to an organization by email.
261
+ */
262
+ async inviteMember(
263
+ orgId: string,
264
+ params: { email: string; role: string },
265
+ ): Promise<ClientInvitation> {
266
+ return this.request<ClientInvitation>(`/orgs/${orgId}/invitations`, {
267
+ method: 'POST',
268
+ body: params,
269
+ })
270
+ }
271
+
272
+ /**
273
+ * Accept an invitation by token.
274
+ */
275
+ async acceptInvitation(token: string): Promise<ClientMembership> {
276
+ return this.request<ClientMembership>('/invitations/accept', {
277
+ method: 'POST',
278
+ body: { token },
279
+ })
280
+ }
281
+
282
+ /**
283
+ * List pending invitations for an organization.
284
+ */
285
+ async listInvitations(orgId: string): Promise<ClientInvitation[]> {
286
+ return this.request<ClientInvitation[]>(`/orgs/${orgId}/invitations`, { method: 'GET' })
287
+ }
288
+
289
+ /**
290
+ * Revoke a pending invitation.
291
+ */
292
+ async revokeInvitation(orgId: string, invitationId: string): Promise<void> {
293
+ await this.request(`/orgs/${orgId}/invitations/${invitationId}`, { method: 'DELETE' })
294
+ }
295
+
296
+ /**
297
+ * List pending invitations addressed to the signed-in user's verified email.
298
+ * The server resolves the email from the session; tokens are never returned.
299
+ */
300
+ async listMyInvitations(): Promise<Array<Omit<ClientInvitation, 'token'>>> {
301
+ return this.request<Array<Omit<ClientInvitation, 'token'>>>('/invitations', {
302
+ method: 'GET',
303
+ })
304
+ }
305
+
306
+ // --- Subscriptions ---
307
+
308
+ /**
309
+ * Subscribe to active org changes.
310
+ * @returns Unsubscribe function
311
+ */
312
+ onOrgChange(callback: (orgId: string | null) => void): () => void {
313
+ this.listeners.add(callback)
314
+ return () => {
315
+ this.listeners.delete(callback)
316
+ }
317
+ }
318
+
319
+ // --- Internal ---
320
+
321
+ private notifyListeners(): void {
322
+ for (const listener of this.listeners) {
323
+ try {
324
+ listener(this._activeOrgId)
325
+ } catch {
326
+ // Don't let listener errors break the notification loop
327
+ }
328
+ }
329
+ }
330
+
331
+ private async request<T = void>(
332
+ path: string,
333
+ options: {
334
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
335
+ body?: Record<string, unknown>
336
+ },
337
+ ): Promise<T> {
338
+ const token = await this.getAccessToken()
339
+ if (!token) {
340
+ throw new OrgClientError(
341
+ 'Not authenticated. Sign in before performing organization operations.',
342
+ 'ORG_NOT_AUTHENTICATED',
343
+ )
344
+ }
345
+
346
+ const url = `${this.serverUrl}${path}`
347
+ const headers: Record<string, string> = {
348
+ Authorization: `Bearer ${token}`,
349
+ }
350
+ if (options.body) {
351
+ headers['Content-Type'] = 'application/json'
352
+ }
353
+
354
+ let response: Response
355
+ try {
356
+ response = await fetch(url, {
357
+ method: options.method,
358
+ headers,
359
+ body: options.body ? JSON.stringify(options.body) : undefined,
360
+ })
361
+ } catch (cause) {
362
+ throw new OrgClientError(`Network request to ${path} failed.`, 'ORG_NETWORK_ERROR', {
363
+ path,
364
+ cause: cause instanceof Error ? cause.message : String(cause),
365
+ })
366
+ }
367
+
368
+ if (!response.ok) {
369
+ let errorMessage = `Server returned HTTP ${response.status}`
370
+ try {
371
+ const body = (await response.json()) as Record<string, unknown>
372
+ if (typeof body.error === 'string') {
373
+ errorMessage = body.error as string
374
+ }
375
+ } catch {
376
+ // not JSON
377
+ }
378
+ throw new OrgClientError(errorMessage, 'ORG_SERVER_ERROR', {
379
+ path,
380
+ status: response.status,
381
+ })
382
+ }
383
+
384
+ // Handle empty responses (DELETE, etc.)
385
+ const text = await response.text()
386
+ if (text.length === 0) return undefined as T
387
+
388
+ try {
389
+ const body = JSON.parse(text)
390
+ // Unwrap { data: ... } envelope if present
391
+ if (body && typeof body === 'object' && 'data' in body) {
392
+ return body.data as T
393
+ }
394
+ return body as T
395
+ } catch {
396
+ return undefined as T
397
+ }
398
+ }
399
+ }