@flowerforce/flowerbase-client 0.1.1-beta.2 → 0.1.1-beta.4

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 (45) hide show
  1. package/dist/app.d.ts +55 -10
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +322 -47
  4. package/dist/credentials.d.ts +1 -0
  5. package/dist/credentials.d.ts.map +1 -1
  6. package/dist/credentials.js +6 -0
  7. package/dist/functions.d.ts +4 -1
  8. package/dist/functions.d.ts.map +1 -1
  9. package/dist/functions.js +18 -1
  10. package/dist/http.d.ts +24 -4
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +121 -25
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/mongo.d.ts +1 -1
  17. package/dist/mongo.d.ts.map +1 -1
  18. package/dist/mongo.js +45 -4
  19. package/dist/session.d.ts +6 -0
  20. package/dist/session.d.ts.map +1 -1
  21. package/dist/session.js +52 -0
  22. package/dist/session.native.d.ts.map +1 -1
  23. package/dist/session.native.js +5 -10
  24. package/dist/types.d.ts +28 -4
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/user.d.ts +24 -4
  27. package/dist/user.d.ts.map +1 -1
  28. package/dist/user.js +103 -8
  29. package/package.json +12 -1
  30. package/src/__tests__/auth.test.ts +49 -0
  31. package/src/__tests__/compat.test.ts +10 -0
  32. package/src/__tests__/functions.test.ts +236 -0
  33. package/src/__tests__/mongo.test.ts +35 -0
  34. package/src/__tests__/session.test.ts +494 -0
  35. package/src/__tests__/watch.test.ts +74 -0
  36. package/src/app.ts +390 -63
  37. package/src/credentials.ts +7 -0
  38. package/src/functions.ts +27 -3
  39. package/src/http.ts +156 -27
  40. package/src/index.ts +1 -0
  41. package/src/mongo.ts +48 -4
  42. package/src/session.native.ts +2 -11
  43. package/src/session.ts +55 -0
  44. package/src/types.ts +34 -4
  45. package/src/user.ts +123 -12
package/src/http.ts CHANGED
@@ -1,12 +1,94 @@
1
- export class FlowerbaseHttpError extends Error {
2
- status: number
3
- payload?: unknown
1
+ type ParsedPayloadError = {
2
+ message?: string
3
+ error?: string
4
+ errorCode?: string
5
+ link?: string
6
+ }
7
+
8
+ const parsePayloadError = (payload: unknown): ParsedPayloadError => {
9
+ if (!payload || typeof payload !== 'object') return {}
10
+
11
+ const message = 'message' in payload && typeof payload.message === 'string' ? payload.message : undefined
12
+ const error = 'error' in payload && typeof payload.error === 'string' ? payload.error : undefined
13
+ const errorCode =
14
+ 'error_code' in payload && typeof payload.error_code === 'string'
15
+ ? payload.error_code
16
+ : 'errorCode' in payload && typeof payload.errorCode === 'string'
17
+ ? payload.errorCode
18
+ : undefined
19
+ const link = 'link' in payload && typeof payload.link === 'string' ? payload.link : undefined
20
+
21
+ if (error) {
22
+ try {
23
+ const parsed = JSON.parse(error)
24
+ if (parsed && typeof parsed === 'object') {
25
+ const nestedMessage = 'message' in parsed && typeof parsed.message === 'string' ? parsed.message : undefined
26
+ const nestedErrorCode =
27
+ 'error_code' in parsed && typeof parsed.error_code === 'string'
28
+ ? parsed.error_code
29
+ : 'errorCode' in parsed && typeof parsed.errorCode === 'string'
30
+ ? parsed.errorCode
31
+ : undefined
32
+ return {
33
+ message: nestedMessage ?? message ?? error,
34
+ error,
35
+ errorCode: nestedErrorCode ?? errorCode,
36
+ link
37
+ }
38
+ }
39
+ } catch {
40
+ // Keep original error text if it isn't JSON.
41
+ }
42
+ }
43
+
44
+ return {
45
+ message: message ?? error,
46
+ error,
47
+ errorCode,
48
+ link
49
+ }
50
+ }
51
+
52
+ export class MongoDBRealmError extends Error {
53
+ readonly method: string
54
+ readonly url: string
55
+ readonly statusCode: number
56
+ readonly statusText: string
57
+ readonly error: string | undefined
58
+ readonly errorCode: string | undefined
59
+ readonly link: string | undefined
60
+ readonly payload?: unknown
4
61
 
5
- constructor(message: string, status: number, payload?: unknown) {
6
- super(message)
62
+ constructor(params: {
63
+ method: string
64
+ url: string
65
+ statusCode: number
66
+ statusText: string
67
+ error?: string
68
+ errorCode?: string
69
+ link?: string
70
+ payload?: unknown
71
+ }) {
72
+ super(params.error || `${params.statusCode} ${params.statusText}`.trim())
73
+ this.name = 'MongoDBRealmError'
74
+ this.method = params.method
75
+ this.url = params.url
76
+ this.statusCode = params.statusCode
77
+ this.statusText = params.statusText
78
+ this.error = params.error
79
+ this.errorCode = params.errorCode
80
+ this.link = params.link
81
+ this.payload = params.payload
82
+ }
83
+ }
84
+
85
+ export class FlowerbaseHttpError extends MongoDBRealmError {
86
+ readonly status: number
87
+
88
+ constructor(params: ConstructorParameters<typeof MongoDBRealmError>[0]) {
89
+ super(params)
7
90
  this.name = 'FlowerbaseHttpError'
8
- this.status = status
9
- this.payload = payload
91
+ this.status = params.statusCode
10
92
  }
11
93
  }
12
94
 
@@ -61,31 +143,78 @@ export const requestJson = async <T = unknown>({
61
143
  const payload = await parseBody(response)
62
144
 
63
145
  if (!response.ok) {
64
- let parsedErrorMessage: string | null = null
65
- if (payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string') {
146
+ const parsedError = parsePayloadError(payload)
147
+ throw new FlowerbaseHttpError({
148
+ method,
149
+ url,
150
+ statusCode: response.status,
151
+ statusText: response.statusText,
152
+ error: parsedError.message ?? `HTTP ${response.status}`,
153
+ errorCode: parsedError.errorCode,
154
+ link: parsedError.link,
155
+ payload
156
+ })
157
+ }
158
+
159
+ return payload as T
160
+ } finally {
161
+ clear()
162
+ }
163
+ }
164
+
165
+ export const requestStream = async ({
166
+ url,
167
+ method = 'GET',
168
+ body,
169
+ bearerToken,
170
+ timeout
171
+ }: RequestParams): Promise<AsyncIterable<Uint8Array>> => {
172
+ const { signal, clear } = timeoutSignal(timeout)
173
+
174
+ try {
175
+ const response = await fetch(url, {
176
+ method,
177
+ headers: {
178
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
179
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {})
180
+ },
181
+ body: body !== undefined ? JSON.stringify(body) : undefined,
182
+ signal
183
+ })
184
+
185
+ if (!response.ok) {
186
+ const payload = await parseBody(response)
187
+ const parsedError = parsePayloadError(payload)
188
+ throw new FlowerbaseHttpError({
189
+ method,
190
+ url,
191
+ statusCode: response.status,
192
+ statusText: response.statusText,
193
+ error: parsedError.message ?? `HTTP ${response.status}`,
194
+ errorCode: parsedError.errorCode,
195
+ link: parsedError.link,
196
+ payload
197
+ })
198
+ }
199
+
200
+ if (!response.body) {
201
+ throw new Error('Response stream body is missing')
202
+ }
203
+
204
+ const reader = response.body.getReader()
205
+ return {
206
+ async *[Symbol.asyncIterator]() {
66
207
  try {
67
- const parsed = JSON.parse(payload.error)
68
- if (parsed && typeof parsed === 'object' && 'message' in parsed && typeof parsed.message === 'string') {
69
- parsedErrorMessage = parsed.message
208
+ while (true) {
209
+ const { done, value } = await reader.read()
210
+ if (done) break
211
+ if (value) yield value
70
212
  }
71
- } catch {
72
- parsedErrorMessage = null
213
+ } finally {
214
+ reader.releaseLock()
73
215
  }
74
216
  }
75
-
76
- const message =
77
- parsedErrorMessage ||
78
- (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string'
79
- ? payload.message
80
- : null) ||
81
- (payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
82
- ? payload.error
83
- : null) ||
84
- `HTTP ${response.status}`
85
- throw new FlowerbaseHttpError(message, response.status, payload)
86
217
  }
87
-
88
- return payload as T
89
218
  } finally {
90
219
  clear()
91
220
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { BSON, EJSON, ObjectId, ObjectID } from './bson'
3
3
  export { App } from './app'
4
4
  export { User } from './user'
5
5
  export { Credentials } from './credentials'
6
+ export { MongoDBRealmError } from './http'
6
7
  export { BSON, EJSON, ObjectId, ObjectID }
7
8
  export type {
8
9
  AppConfig,
package/src/mongo.ts CHANGED
@@ -20,7 +20,7 @@ const mapResult = (value: unknown) => {
20
20
  return deserialize(value)
21
21
  }
22
22
 
23
- export const createMongoClient = (app: App): MongoClientLike => ({
23
+ export const createMongoClient = (app: App, serviceName: string, userId: string): MongoClientLike => ({
24
24
  db: (database: string) => ({
25
25
  collection: (collection: string): CollectionLike => {
26
26
  const callService = async (name: string, args: unknown[]) => {
@@ -31,21 +31,65 @@ export const createMongoClient = (app: App): MongoClientLike => ({
31
31
  ...serialize(args[0] ?? {}),
32
32
  ...(args[1] !== undefined ? { options: serialize(args[1]) } : {})
33
33
  }
34
- ])
34
+ ], serviceName, userId)
35
35
  return mapResult(result)
36
36
  }
37
37
 
38
+ const normalizeWatchInput = (input?: unknown) => {
39
+ if (Array.isArray(input)) {
40
+ return { pipeline: input, options: {} }
41
+ }
42
+ if (input && typeof input === 'object' && ('ids' in input || 'filter' in input)) {
43
+ const typed = input as { ids?: unknown[]; filter?: Record<string, unknown>; [key: string]: unknown }
44
+ if (typed.ids && typed.filter) {
45
+ throw new Error('watch options cannot include both "ids" and "filter"')
46
+ }
47
+ const { ids, filter, ...options } = typed
48
+ if (ids) {
49
+ return {
50
+ pipeline: [{ $match: { 'documentKey._id': { $in: ids } } }],
51
+ options
52
+ }
53
+ }
54
+ if (filter) {
55
+ return {
56
+ pipeline: [{ $match: filter }],
57
+ options
58
+ }
59
+ }
60
+ return { pipeline: [], options }
61
+ }
62
+ if (input && typeof input === 'object' && ('pipeline' in input || 'options' in input)) {
63
+ const typed = input as { pipeline?: unknown[]; options?: Record<string, unknown> }
64
+ return {
65
+ pipeline: typed.pipeline ?? [],
66
+ options: typed.options ?? {}
67
+ }
68
+ }
69
+ return { pipeline: [], options: (input as Record<string, unknown> | undefined) ?? {} }
70
+ }
71
+
38
72
  return {
39
73
  find: (query = {}, options = {}) => callService('find', [{ query, options }]),
40
74
  findOne: (query = {}, options = {}) => callService('findOne', [{ query, options }]),
75
+ findOneAndUpdate: (filter, update, options = {}) =>
76
+ callService('findOneAndUpdate', [{ filter, update, options }]),
77
+ findOneAndReplace: (filter, replacement, options = {}) =>
78
+ callService('findOneAndReplace', [{ filter, replacement, options }]),
79
+ findOneAndDelete: (filter, options = {}) => callService('findOneAndDelete', [{ filter, options }]),
80
+ aggregate: (pipeline) => callService('aggregate', [{ pipeline }]),
81
+ count: (query = {}, options = {}) => callService('count', [{ query, options }]),
41
82
  insertOne: (document, options = {}) => callService('insertOne', [{ document, options }]),
83
+ insertMany: (documents, options = {}) => callService('insertMany', [{ documents, options }]),
42
84
  updateOne: (filter, update, options = {}) =>
43
85
  callService('updateOne', [{ filter, update, options }]),
44
86
  updateMany: (filter, update, options = {}) =>
45
87
  callService('updateMany', [{ filter, update, options }]),
46
88
  deleteOne: (filter, options = {}) => callService('deleteOne', [{ query: filter, options }]),
47
- watch: (pipeline = [], options = {}) => {
48
- const session = app.getSessionOrThrow()
89
+ deleteMany: (filter, options = {}) => callService('deleteMany', [{ query: filter, options }]),
90
+ watch: (input) => {
91
+ const { pipeline, options } = normalizeWatchInput(input)
92
+ const session = app.getSessionOrThrow(userId)
49
93
  return createWatchIterator({
50
94
  appId: app.id,
51
95
  baseUrl: app.baseUrl,
@@ -1,4 +1,5 @@
1
1
  import { SessionData } from './types'
2
+ import AsyncStorage from '@react-native-async-storage/async-storage'
2
3
 
3
4
  type StorageLike = {
4
5
  getItem: (key: string) => Promise<string | null>
@@ -8,17 +9,7 @@ type StorageLike = {
8
9
 
9
10
  const memoryStore = new Map<string, string>()
10
11
 
11
- const getAsyncStorage = (): StorageLike | null => {
12
- try {
13
- const req = (0, eval)('require') as (name: string) => unknown
14
- const asyncStorageModule = req('@react-native-async-storage/async-storage') as {
15
- default?: StorageLike
16
- }
17
- return asyncStorageModule?.default ?? null
18
- } catch {
19
- return null
20
- }
21
- }
12
+ const getAsyncStorage = (): StorageLike => AsyncStorage
22
13
 
23
14
  const parseSession = (raw: string | null): SessionData | null => {
24
15
  if (!raw) return null
package/src/session.ts CHANGED
@@ -24,11 +24,15 @@ const getStorage = () => {
24
24
 
25
25
  export class SessionManager {
26
26
  private readonly key: string
27
+ private readonly usersKey: string
28
+ private readonly sessionsKey: string
27
29
  private readonly storage = getStorage()
28
30
  private session: SessionData | null = null
29
31
 
30
32
  constructor(appId: string) {
31
33
  this.key = `flowerbase:${appId}:session`
34
+ this.usersKey = `flowerbase:${appId}:users`
35
+ this.sessionsKey = `flowerbase:${appId}:sessions`
32
36
  this.session = this.load()
33
37
  }
34
38
 
@@ -56,4 +60,55 @@ export class SessionManager {
56
60
  this.session = null
57
61
  this.storage.removeItem(this.key)
58
62
  }
63
+
64
+ getUsersOrder() {
65
+ const raw = this.storage.getItem(this.usersKey)
66
+ if (!raw) return []
67
+ try {
68
+ const parsed = JSON.parse(raw)
69
+ if (!Array.isArray(parsed)) return []
70
+ return parsed.filter((item): item is string => typeof item === 'string')
71
+ } catch {
72
+ return []
73
+ }
74
+ }
75
+
76
+ setUsersOrder(order: string[]) {
77
+ if (order.length === 0) {
78
+ this.storage.removeItem(this.usersKey)
79
+ return
80
+ }
81
+ this.storage.setItem(this.usersKey, JSON.stringify(order))
82
+ }
83
+
84
+ getSessionsByUser() {
85
+ const raw = this.storage.getItem(this.sessionsKey)
86
+ if (!raw) return {} as Record<string, SessionData>
87
+ try {
88
+ const parsed = JSON.parse(raw) as Record<string, SessionData>
89
+ const normalized: Record<string, SessionData> = {}
90
+ for (const [userId, session] of Object.entries(parsed)) {
91
+ if (
92
+ session &&
93
+ typeof session === 'object' &&
94
+ typeof session.accessToken === 'string' &&
95
+ typeof session.refreshToken === 'string' &&
96
+ typeof session.userId === 'string'
97
+ ) {
98
+ normalized[userId] = session
99
+ }
100
+ }
101
+ return normalized
102
+ } catch {
103
+ return {} as Record<string, SessionData>
104
+ }
105
+ }
106
+
107
+ setSessionsByUser(sessionsByUser: Record<string, SessionData>) {
108
+ if (Object.keys(sessionsByUser).length === 0) {
109
+ this.storage.removeItem(this.sessionsKey)
110
+ return
111
+ }
112
+ this.storage.setItem(this.sessionsKey, JSON.stringify(sessionsByUser))
113
+ }
59
114
  }
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type AppConfig = {
2
2
  id: string
3
- baseUrl: string
3
+ baseUrl?: string
4
4
  timeout?: number
5
5
  }
6
6
 
@@ -8,6 +8,7 @@ export type CredentialsLike =
8
8
  | { provider: 'local-userpass'; email: string; password: string }
9
9
  | { provider: 'anon-user' }
10
10
  | { provider: 'custom-function'; payload: Record<string, unknown> }
11
+ | { provider: 'custom-token'; token: string }
11
12
 
12
13
  export type SessionData = {
13
14
  accessToken: string
@@ -47,7 +48,21 @@ export type WatchAsyncIterator<TChange = unknown> = AsyncIterableIterator<TChang
47
48
  export interface CollectionLike {
48
49
  find: (query?: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>
49
50
  findOne: (query?: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>
51
+ findOneAndUpdate: (
52
+ filter: Record<string, unknown>,
53
+ update: Record<string, unknown>,
54
+ options?: Record<string, unknown>
55
+ ) => Promise<unknown>
56
+ findOneAndReplace: (
57
+ filter: Record<string, unknown>,
58
+ replacement: Record<string, unknown>,
59
+ options?: Record<string, unknown>
60
+ ) => Promise<unknown>
61
+ findOneAndDelete: (filter: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>
62
+ aggregate: (pipeline: Record<string, unknown>[]) => Promise<unknown>
63
+ count: (filter?: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>
50
64
  insertOne: (document: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>
65
+ insertMany: (documents: Record<string, unknown>[], options?: Record<string, unknown>) => Promise<unknown>
51
66
  updateOne: (
52
67
  filter: Record<string, unknown>,
53
68
  update: Record<string, unknown>,
@@ -59,7 +74,8 @@ export interface CollectionLike {
59
74
  options?: Record<string, unknown>
60
75
  ) => Promise<unknown>
61
76
  deleteOne: (filter: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>
62
- watch: (pipeline?: unknown[], options?: Record<string, unknown>) => WatchAsyncIterator<unknown>
77
+ deleteMany: (filter: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>
78
+ watch: (options?: unknown) => WatchAsyncIterator<unknown>
63
79
  }
64
80
 
65
81
  export interface MongoDbLike {
@@ -72,13 +88,27 @@ export interface MongoClientLike {
72
88
 
73
89
  export interface UserLike {
74
90
  id: string
91
+ state: 'active' | 'logged-out' | 'removed'
92
+ isLoggedIn: boolean
93
+ accessToken: string | null
94
+ refreshToken: string | null
95
+ providerType: string | null
96
+ identities: unknown[]
97
+ customData: Record<string, unknown>
75
98
  profile?: {
76
99
  email?: string
77
100
  [key: string]: unknown
78
101
  }
79
- functions: Record<string, (...args: unknown[]) => Promise<unknown>>
102
+ functions: Record<string, (...args: unknown[]) => Promise<unknown>> & {
103
+ callFunction: (name: string, ...args: unknown[]) => Promise<unknown>
104
+ callFunctionStreaming: (name: string, ...args: unknown[]) => Promise<AsyncIterable<Uint8Array>>
105
+ }
80
106
  logOut: () => Promise<void>
107
+ callFunction: (name: string, ...args: unknown[]) => Promise<unknown>
81
108
  refreshAccessToken: () => Promise<string>
82
- refreshCustomData: () => Promise<ProfileData>
109
+ refreshCustomData: () => Promise<Record<string, unknown>>
83
110
  mongoClient: (serviceName: string) => MongoClientLike
111
+ addListener: (callback: () => void) => void
112
+ removeListener: (callback: () => void) => void
113
+ removeAllListeners: () => void
84
114
  }
package/src/user.ts CHANGED
@@ -1,39 +1,150 @@
1
1
  import type { App } from './app'
2
2
  import { createFunctionsProxy } from './functions'
3
3
  import { createMongoClient } from './mongo'
4
- import { MongoClientLike, ProfileData, UserLike } from './types'
4
+ import { MongoClientLike, UserLike } from './types'
5
5
 
6
6
  export class User implements UserLike {
7
- id: string
7
+ readonly id: string
8
+ customData: Record<string, unknown> = {}
8
9
  profile?: { email?: string;[key: string]: unknown }
9
10
  private readonly app: App
11
+ private _providerType: string | null = null
12
+ private readonly listeners = new Set<() => void>()
10
13
 
11
- functions: Record<string, (...args: unknown[]) => Promise<unknown>>
14
+ functions: Record<string, (...args: unknown[]) => Promise<unknown>> & {
15
+ callFunction: (name: string, ...args: unknown[]) => Promise<unknown>
16
+ callFunctionStreaming: (name: string, ...args: unknown[]) => Promise<AsyncIterable<Uint8Array>>
17
+ }
12
18
 
13
19
  constructor(app: App, id: string) {
14
20
  this.app = app
15
21
  this.id = id
16
- this.functions = createFunctionsProxy((name, args) => this.app.callFunction(name, args))
22
+ this.functions = createFunctionsProxy(
23
+ (name, args) => this.app.callFunction(name, args, this.id),
24
+ (name, args) => this.app.callFunctionStreaming(name, args, this.id)
25
+ )
26
+ this.customData = this.resolveCustomDataFromToken()
27
+ }
28
+
29
+ get state() {
30
+ if (!this.app.hasUser(this.id)) {
31
+ return 'removed'
32
+ }
33
+ return this.isLoggedIn ? 'active' : 'logged-out'
34
+ }
35
+
36
+ get isLoggedIn() {
37
+ return this.accessToken !== null && this.refreshToken !== null
38
+ }
39
+
40
+ get providerType() {
41
+ return this._providerType
42
+ }
43
+
44
+ get identities() {
45
+ return this.app.getProfileSnapshot(this.id)?.identities ?? []
46
+ }
47
+
48
+ private resolveCustomDataFromToken() {
49
+ const payload = this.decodeAccessTokenPayload()
50
+ if (!payload) return {}
51
+ return (
52
+ 'user_data' in payload && payload.user_data && typeof payload.user_data === 'object'
53
+ ? (payload.user_data as Record<string, unknown>)
54
+ : 'userData' in payload && payload.userData && typeof payload.userData === 'object'
55
+ ? (payload.userData as Record<string, unknown>)
56
+ : 'custom_data' in payload && payload.custom_data && typeof payload.custom_data === 'object'
57
+ ? (payload.custom_data as Record<string, unknown>)
58
+ : {}
59
+ )
60
+ }
61
+
62
+ get accessToken() {
63
+ const session = this.app.getSession(this.id)
64
+ if (!session) return null
65
+ return session.accessToken
66
+ }
67
+
68
+ get refreshToken() {
69
+ const session = this.app.getSession(this.id)
70
+ if (!session) return null
71
+ return session.refreshToken
72
+ }
73
+
74
+ setProviderType(providerType: string) {
75
+ this._providerType = providerType
76
+ }
77
+
78
+ private decodeAccessTokenPayload() {
79
+ if (!this.accessToken) return null
80
+ const parts = this.accessToken.split('.')
81
+ if (parts.length < 2) return null
82
+
83
+ const base64Url = parts[1]
84
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(base64Url.length / 4) * 4, '=')
85
+
86
+ const decodeBase64 = (input: string) => {
87
+ if (typeof atob === 'function') return atob(input)
88
+ const runtimeBuffer = (globalThis as { Buffer?: { from: (data: string, encoding: string) => { toString: (enc: string) => string } } }).Buffer
89
+ if (runtimeBuffer) return runtimeBuffer.from(input, 'base64').toString('utf8')
90
+ return ''
91
+ }
92
+
93
+ try {
94
+ const decoded = decodeBase64(base64)
95
+ return JSON.parse(decoded) as Record<string, unknown>
96
+ } catch {
97
+ return null
98
+ }
17
99
  }
18
100
 
19
101
  async logOut() {
20
- await this.app.logoutUser()
102
+ await this.app.logoutUser(this.id)
103
+ }
104
+
105
+ async callFunction(name: string, ...args: unknown[]) {
106
+ return this.app.callFunction(name, args, this.id)
21
107
  }
22
108
 
23
109
  async refreshAccessToken() {
24
- return this.app.refreshAccessToken()
110
+ const accessToken = await this.app.refreshAccessToken(this.id)
111
+ this.customData = this.resolveCustomDataFromToken()
112
+ return accessToken
25
113
  }
26
114
 
27
- async refreshCustomData(): Promise<ProfileData> {
28
- const profile = await this.app.getProfile()
115
+ async refreshCustomData(): Promise<Record<string, unknown>> {
116
+ const profile = await this.app.getProfile(this.id)
29
117
  this.profile = profile.data
30
- return profile.custom_data || {}
118
+ this.customData = (profile.custom_data && typeof profile.custom_data === 'object'
119
+ ? profile.custom_data
120
+ : this.resolveCustomDataFromToken()) as Record<string, unknown>
121
+ this.notifyListeners()
122
+ return this.customData
31
123
  }
32
124
 
33
125
  mongoClient(serviceName: string): MongoClientLike {
34
- if (serviceName !== 'mongodb-atlas') {
35
- throw new Error(`Unsupported service "${serviceName}"`)
126
+ return createMongoClient(this.app, serviceName, this.id)
127
+ }
128
+
129
+ addListener(callback: () => void) {
130
+ this.listeners.add(callback)
131
+ }
132
+
133
+ removeListener(callback: () => void) {
134
+ this.listeners.delete(callback)
135
+ }
136
+
137
+ removeAllListeners() {
138
+ this.listeners.clear()
139
+ }
140
+
141
+ notifyListeners() {
142
+ for (const callback of Array.from(this.listeners)) {
143
+ try {
144
+ callback()
145
+ } catch {
146
+ // Listener failures should not break user lifecycle operations.
147
+ }
36
148
  }
37
- return createMongoClient(this.app)
38
149
  }
39
150
  }