@korajs/auth 1.0.0-beta.11 → 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.
- package/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- 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
|
+
}
|