@pmate/account-sdk 0.6.0 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/account-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -8,38 +8,52 @@
8
8
  "!src/tests/**"
9
9
  ],
10
10
  "exports": {
11
- ".": "./src/index.ts"
11
+ ".": "./src/index.ts",
12
+ "./node": "./src/node/index.ts"
12
13
  },
13
14
  "publishConfig": {
14
15
  "access": "public"
15
16
  },
17
+ "scripts": {
18
+ "npm:publish": "npm publish --registry https://registry.npmjs.org/",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
16
22
  "peerDependencies": {
17
23
  "jotai": "*",
18
24
  "jotai-family": "*",
19
25
  "react": "*"
20
26
  },
27
+ "peerDependenciesMeta": {
28
+ "jotai": {
29
+ "optional": true
30
+ },
31
+ "jotai-family": {
32
+ "optional": true
33
+ },
34
+ "react": {
35
+ "optional": true
36
+ }
37
+ },
21
38
  "dependencies": {
39
+ "@pmate/lang": "^1.0.2",
40
+ "@pmate/meta": "^1.1.3",
41
+ "@pmate/service-core": "^1.0.0",
42
+ "@pmate/utils": "^1.0.3",
22
43
  "browser-image-compression": "^2.0.2",
44
+ "elysia": "^1.4.19",
23
45
  "i18next": "^23.0.0",
24
46
  "react-i18next": "^13.0.0",
25
- "react-use": "^17.6.0",
26
- "@pmate/lang": "1.0.2",
27
- "@pmate/utils": "1.0.3",
28
- "@pmate/meta": "1.1.3"
47
+ "react-use": "^17.6.0"
29
48
  },
30
49
  "devDependencies": {
31
- "@types/react": "*",
32
- "@types/react-dom": "*",
50
+ "@types/react": "catalog:",
51
+ "@types/react-dom": "catalog:",
33
52
  "@vitejs/plugin-react": "^4.2.1",
34
53
  "jsdom": "^26.1.0",
35
54
  "react-dom": "*",
36
55
  "typescript": "^5.2.2",
37
- "vite": "^7.3.1",
56
+ "vite": "catalog:",
38
57
  "vitest": "^4.0.17"
39
- },
40
- "scripts": {
41
- "npm:publish": "npm publish --registry https://registry.npmjs.org/",
42
- "test": "vitest run",
43
- "test:watch": "vitest"
44
58
  }
45
- }
59
+ }
@@ -0,0 +1,24 @@
1
+ import { atom, type Atom } from "jotai"
2
+ import { atomFamily, type AtomFamily } from "jotai-family"
3
+ import { DEFAULT_APP_ID } from "../constants"
4
+ import { AppService } from "../api/AppService"
5
+ import type { AppConfig } from "../types/app"
6
+ import { resolveAppId } from "../utils/resolveAppId"
7
+
8
+ export const resolveAppConfigId = (app?: string | null) => {
9
+ return resolveAppId(app ?? DEFAULT_APP_ID)
10
+ }
11
+
12
+ type AppConfigAtomFamily = AtomFamily<string, Atom<Promise<AppConfig>>>
13
+
14
+ export const appConfigAtom: AppConfigAtomFamily = atomFamily((appId: string) =>
15
+ atom(async () => {
16
+ return AppService.getAppConfig(appId)
17
+ })
18
+ )
19
+
20
+ export const clearAppConfigAtoms = () => {
21
+ for (const appId of appConfigAtom.getParams()) {
22
+ appConfigAtom.remove(appId)
23
+ }
24
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./uploadAvatarAtom"
2
+ export * from "./appConfigAtom"
2
3
  export * from "./accountAtom"
3
4
  export * from "./accountProfileAtom"
4
5
  export * from "./switchProfileAtom"
@@ -6,7 +6,6 @@ import {
6
6
  PropsWithChildren,
7
7
  useCallback,
8
8
  useEffect,
9
- useMemo,
10
9
  useState,
11
10
  } from "react"
12
11
  import { useAuthSnapshot } from "../hooks/useAuthSnapshot"
@@ -61,8 +60,8 @@ const getAuthBehaviors = (
61
60
  })
62
61
  const authBehavior =
63
62
  matchedAuthRoute && typeof matchedAuthRoute !== "string"
64
- ? (matchedAuthRoute.behavior ?? "prompt")
65
- : "prompt"
63
+ ? (matchedAuthRoute.behavior ?? "redirect")
64
+ : "redirect"
66
65
  const requiresAuth = Boolean(matchedAuthRoute)
67
66
  return {
68
67
  authBehavior,
@@ -76,7 +75,6 @@ type AuthProviderV2Props = PropsWithChildren<{
76
75
  onLoginSuccess?: () => void | Promise<void>
77
76
  rtcProvider?: React.ComponentType<{ children: React.ReactNode }>
78
77
  pathname?: string
79
- navigate?: (to: string, options?: { replace?: boolean }) => void
80
78
  }>
81
79
 
82
80
  const useWindowPathname = () => {
@@ -94,22 +92,9 @@ export const AuthProviderV2 = ({
94
92
  authRoutes,
95
93
  rtcProvider: RtcProvider,
96
94
  pathname: pathnameProp,
97
- navigate: navigateProp,
98
95
  children,
99
96
  }: AuthProviderV2Props) => {
100
97
  const pathname = pathnameProp ?? useWindowPathname()
101
- const navigate = useMemo(() => {
102
- if (navigateProp) {
103
- return navigateProp
104
- }
105
- return (to: string, options?: { replace?: boolean }) => {
106
- if (options?.replace) {
107
- window.location.replace(to)
108
- return
109
- }
110
- window.location.assign(to)
111
- }
112
- }, [navigateProp])
113
98
  const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false)
114
99
  const [isLoginErrorDismissed, setIsLoginErrorDismissed] = useState(false)
115
100
  const setAccountSnapshot = useSetAtom(accountAtom)
@@ -251,7 +236,7 @@ export const AuthProviderV2 = ({
251
236
  }
252
237
  const RtcWrapper = RtcProvider ?? (({ children }) => <>{children}</>)
253
238
  return (
254
- <AuthErrorBoundary navigate={navigate}>
239
+ <AuthErrorBoundary app={app}>
255
240
  <RtcWrapper>{children}</RtcWrapper>
256
241
  </AuthErrorBoundary>
257
242
  )
@@ -297,15 +282,15 @@ class AuthErrorBoundaryInner extends Component<
297
282
 
298
283
  const AuthErrorBoundary = ({
299
284
  children,
300
- navigate,
285
+ app,
301
286
  }: PropsWithChildren<{
302
- navigate: (to: string, options?: { replace?: boolean }) => void
287
+ app: string
303
288
  }>) => {
304
289
  const logout = useSetAtom(userLogoutAtom)
305
290
  const handleAuthError = useCallback(async () => {
306
291
  await logout()
307
- navigate("/login", { replace: true })
308
- }, [logout, navigate])
292
+ Redirect.toLogin(app)
293
+ }, [app, logout])
309
294
 
310
295
  return (
311
296
  <AuthErrorBoundaryInner onAuthError={handleAuthError}>
@@ -1,8 +1,7 @@
1
1
  import { useEffect, useState } from "react"
2
+ import { resolveAppConfigId } from "../atoms/appConfigAtom"
2
3
  import { AppService } from "../api/AppService"
3
- import { DEFAULT_APP_ID } from "../constants"
4
4
  import type { AppConfig } from "../types/app"
5
- import { resolveAppId } from "../utils/resolveAppId"
6
5
 
7
6
  type AppConfigState = {
8
7
  appConfig: AppConfig | null
@@ -11,7 +10,7 @@ type AppConfigState = {
11
10
  }
12
11
 
13
12
  export const useAppConfig = (app?: string | null): AppConfigState => {
14
- const resolvedApp = resolveAppId(app ?? DEFAULT_APP_ID)
13
+ const resolvedApp = resolveAppConfigId(app)
15
14
  const [state, setState] = useState<AppConfigState>({
16
15
  appConfig: null,
17
16
  isLoading: true,
@@ -21,17 +20,25 @@ export const useAppConfig = (app?: string | null): AppConfigState => {
21
20
  useEffect(() => {
22
21
  let active = true
23
22
  setState({ appConfig: null, isLoading: true, error: null })
23
+
24
24
  AppService.getAppConfig(resolvedApp)
25
25
  .then((appConfig) => {
26
- if (!active) return
26
+ if (!active) {
27
+ return
28
+ }
27
29
  setState({ appConfig, isLoading: false, error: null })
28
30
  })
29
31
  .catch((error: unknown) => {
30
- if (!active) return
32
+ if (!active) {
33
+ return
34
+ }
31
35
  setState({
32
36
  appConfig: null,
33
37
  isLoading: false,
34
- error: error instanceof Error ? error : new Error("Failed to load app config"),
38
+ error:
39
+ error instanceof Error
40
+ ? error
41
+ : new Error("Failed to load app config"),
35
42
  })
36
43
  })
37
44
 
@@ -2,8 +2,6 @@ import { useCallback, useMemo } from "react"
2
2
  import { isProfileStepType, ProfileStepType } from "../utils/profileStep"
3
3
  import { useAppConfig } from "./useAppConfig"
4
4
 
5
- const DEFAULT_CREATE_STEP: ProfileStepType = "learning-language"
6
-
7
5
  type UseProfileStepFlowParams = {
8
6
  params: URLSearchParams
9
7
  }
@@ -13,7 +11,13 @@ export const useProfileStepFlow = ({
13
11
  }: UseProfileStepFlowParams) => {
14
12
  const appParam = params.get("app")
15
13
  const redirectParam = params.get("redirect")
16
- const { appConfig } = useAppConfig(appParam)
14
+ const { appConfig, error, isLoading } = useAppConfig(appParam)
15
+ const shouldBlockStep = Boolean(appParam) && isLoading && !appConfig && !error
16
+
17
+ // The app registry decides the canonical order of profile steps.
18
+ // When the config is still loading for an explicit app, we intentionally
19
+ // block the create flow instead of briefly falling back to a synthetic
20
+ // default step that does not exist in the app schema.
17
21
  const appProfileSteps = useMemo<ProfileStepType[]>(
18
22
  () =>
19
23
  (appConfig?.profiles ?? [])
@@ -24,15 +28,27 @@ export const useProfileStepFlow = ({
24
28
  const stepParam = params.get("step")
25
29
  const normalizedStep: ProfileStepType | null =
26
30
  appProfileSteps.find((item) => item === stepParam) ?? null
27
- const defaultStep: ProfileStepType = appProfileSteps[0] ?? DEFAULT_CREATE_STEP
28
- const activeStep: ProfileStepType = normalizedStep ?? defaultStep
31
+
32
+ // step=... wins when it is valid for the current app.
33
+ // Otherwise we use the first configured step. If the app does not declare
34
+ // any profile schema, create-profile should stay unavailable instead of
35
+ // inventing a fallback step.
36
+ const defaultStep: ProfileStepType | null = shouldBlockStep
37
+ ? null
38
+ : (appProfileSteps[0] ?? null)
39
+ const activeStep: ProfileStepType | null = normalizedStep ?? defaultStep
40
+
41
+ // createSteps stays empty while we intentionally block rendering.
42
+ // Once app config is ready, it mirrors the registry schema order exactly.
29
43
  const createSteps = useMemo<ProfileStepType[]>(() => {
30
- return appProfileSteps.length > 0 ? appProfileSteps : [DEFAULT_CREATE_STEP]
31
- }, [appProfileSteps])
32
- const currentStepIndex = createSteps.indexOf(activeStep)
44
+ return shouldBlockStep ? [] : appProfileSteps
45
+ }, [appProfileSteps, shouldBlockStep])
46
+ const currentStepIndex =
47
+ activeStep === null ? -1 : createSteps.indexOf(activeStep)
33
48
  const nextStep =
34
49
  currentStepIndex >= 0 ? createSteps[currentStepIndex + 1] : undefined
35
- const isCreateFlowStep = createSteps.includes(activeStep)
50
+ const isCreateFlowStep =
51
+ activeStep === null ? false : createSteps.includes(activeStep)
36
52
  const buildStepUrl = useCallback(
37
53
  (next: ProfileStepType) => {
38
54
  const search = new URLSearchParams()
@@ -53,7 +69,10 @@ export const useProfileStepFlow = ({
53
69
  appProfileSteps,
54
70
  buildStepUrl,
55
71
  createSteps,
72
+ error,
73
+ isLoading,
56
74
  isCreateFlowStep,
75
+ isReady: activeStep !== null,
57
76
  nextStep,
58
77
  }
59
78
  }
@@ -0,0 +1,271 @@
1
+ import type {
2
+ AccountViewResult,
3
+ AuthSession,
4
+ AuthzCheckResult,
5
+ RoleBinding,
6
+ ServerResponse,
7
+ } from "@pmate/meta"
8
+ import { BizErrorCode } from "@pmate/meta"
9
+ import { ServiceError } from "@pmate/service-core"
10
+ import { Elysia } from "elysia"
11
+
12
+ export type AuthenticatedAccount = {
13
+ token: string
14
+ accountId: string
15
+ session: AuthSession
16
+ }
17
+
18
+ export type AuthClientLike = {
19
+ authenticateRequest(request: Request): Promise<AuthenticatedAccount>
20
+ checkAuthorization(
21
+ request: Request,
22
+ input: { account?: string; namespace: string; roles: string[] }
23
+ ): Promise<AuthzCheckResult>
24
+ getAccountView(
25
+ request: Request,
26
+ query: { account?: string; mobile?: string; nickName?: string }
27
+ ): Promise<AccountViewResult>
28
+ listRoleBindings(
29
+ request: Request,
30
+ query: { account?: string; namespace?: string }
31
+ ): Promise<RoleBinding[]>
32
+ createRoleBinding(
33
+ request: Request,
34
+ input: { account: string; namespace: string; role: string; source?: string }
35
+ ): Promise<RoleBinding>
36
+ }
37
+
38
+ export class AuthClient implements AuthClientLike {
39
+ private readonly authApiBaseUrl: string
40
+ private readonly fetchImpl: typeof fetch
41
+
42
+ constructor(options: { authApiBaseUrl?: string; fetchImpl?: typeof fetch } = {}) {
43
+ this.authApiBaseUrl = (
44
+ options.authApiBaseUrl ||
45
+ process.env.AUTH_API_BASE_URL ||
46
+ "https://auth-api-v2.pmate.chat"
47
+ ).replace(/\/+$/, "")
48
+ this.fetchImpl = options.fetchImpl ?? fetch
49
+ }
50
+
51
+ async authenticateRequest(request: Request): Promise<AuthenticatedAccount> {
52
+ const token = extractBearerToken(request.headers.get("authorization"))
53
+ const session = await authRequest<AuthSession | null>(
54
+ this.fetchImpl,
55
+ this.authApiBaseUrl,
56
+ "/session",
57
+ {
58
+ method: "GET",
59
+ token,
60
+ }
61
+ )
62
+ if (!session?.identity?.accountId) {
63
+ throw new ServiceError("Unauthorized", 401, BizErrorCode.AUTH_ERROR)
64
+ }
65
+ return {
66
+ token,
67
+ accountId: session.identity.accountId,
68
+ session,
69
+ }
70
+ }
71
+
72
+ async checkAuthorization(
73
+ request: Request,
74
+ input: { account?: string; namespace: string; roles: string[] }
75
+ ) {
76
+ const auth = await this.authenticateRequest(request)
77
+ return authRequest<AuthzCheckResult>(
78
+ this.fetchImpl,
79
+ this.authApiBaseUrl,
80
+ "/authz/check",
81
+ {
82
+ method: "POST",
83
+ token: auth.token,
84
+ body: JSON.stringify({
85
+ account: input.account ?? auth.accountId,
86
+ namespace: input.namespace,
87
+ roles: input.roles,
88
+ }),
89
+ }
90
+ )
91
+ }
92
+
93
+ async getAccountView(
94
+ request: Request,
95
+ query: { account?: string; mobile?: string; nickName?: string }
96
+ ) {
97
+ const auth = await this.authenticateRequest(request)
98
+ const url = new URL(`${this.authApiBaseUrl}/accounts/view`)
99
+ if (query.account) url.searchParams.set("account", query.account)
100
+ if (query.mobile) url.searchParams.set("mobile", query.mobile)
101
+ if (query.nickName) url.searchParams.set("nickName", query.nickName)
102
+ return authRequest<AccountViewResult>(this.fetchImpl, this.authApiBaseUrl, url, {
103
+ method: "GET",
104
+ token: auth.token,
105
+ })
106
+ }
107
+
108
+ async listRoleBindings(
109
+ request: Request,
110
+ query: { account?: string; namespace?: string }
111
+ ) {
112
+ const auth = await this.authenticateRequest(request)
113
+ const url = new URL(`${this.authApiBaseUrl}/role/bindings`)
114
+ if (query.account) url.searchParams.set("account", query.account)
115
+ if (query.namespace) url.searchParams.set("namespace", query.namespace)
116
+ return authRequest<RoleBinding[]>(this.fetchImpl, this.authApiBaseUrl, url, {
117
+ method: "GET",
118
+ token: auth.token,
119
+ })
120
+ }
121
+
122
+ async createRoleBinding(
123
+ request: Request,
124
+ input: { account: string; namespace: string; role: string; source?: string }
125
+ ) {
126
+ const auth = await this.authenticateRequest(request)
127
+ return authRequest<RoleBinding>(
128
+ this.fetchImpl,
129
+ this.authApiBaseUrl,
130
+ "/role/bindings",
131
+ {
132
+ method: "POST",
133
+ token: auth.token,
134
+ body: JSON.stringify(input),
135
+ }
136
+ )
137
+ }
138
+ }
139
+
140
+ export function createAccountAuthPlugin(options: {
141
+ authApiBaseUrl?: string
142
+ fetchImpl?: typeof fetch
143
+ } = {}) {
144
+ const client = new AuthClient(options)
145
+ return new Elysia({ name: "pmate-account-auth" }).decorate(
146
+ "accountAuthClient",
147
+ client
148
+ )
149
+ }
150
+
151
+ export function requireAuth(
152
+ client: AuthClientLike,
153
+ options: {
154
+ namespace?:
155
+ | string
156
+ | ((context: {
157
+ request: Request
158
+ params?: Record<string, string | undefined>
159
+ query?: Record<string, unknown>
160
+ body?: unknown
161
+ }) => string | Promise<string>)
162
+ roles?: string[]
163
+ } = {}
164
+ ) {
165
+ return {
166
+ beforeHandle: async (context: {
167
+ request: Request
168
+ params?: Record<string, string | undefined>
169
+ query?: Record<string, unknown>
170
+ body?: unknown
171
+ }) => {
172
+ if (context.request.method.toUpperCase() === "OPTIONS") {
173
+ return
174
+ }
175
+
176
+ const auth = await client.authenticateRequest(context.request)
177
+ let namespace: string | undefined
178
+ let matchedRole: string | undefined
179
+ if (options.roles?.length) {
180
+ namespace =
181
+ typeof options.namespace === "function"
182
+ ? await options.namespace(context)
183
+ : options.namespace
184
+ if (!namespace) {
185
+ throw new ServiceError(
186
+ "Namespace is required for role authorization",
187
+ 500,
188
+ BizErrorCode.AUTH_ERROR
189
+ )
190
+ }
191
+ const result = await client.checkAuthorization(context.request, {
192
+ account: auth.accountId,
193
+ namespace,
194
+ roles: options.roles,
195
+ })
196
+ if (!result.allowed) {
197
+ throw new ServiceError("Forbidden", 403, BizErrorCode.AUTH_ERROR)
198
+ }
199
+ matchedRole = result.matchedRole
200
+ }
201
+
202
+ const authContext = {
203
+ ...auth,
204
+ ...(namespace ? { namespace } : {}),
205
+ ...(matchedRole ? { matchedRole } : {}),
206
+ }
207
+ ;(context as Record<string, unknown>).auth = authContext
208
+ ;(context.request as Request & { auth?: typeof authContext }).auth =
209
+ authContext
210
+ },
211
+ }
212
+ }
213
+
214
+ type AuthRequestInit = RequestInit & { token?: string }
215
+
216
+ async function authRequest<T>(
217
+ fetchImpl: typeof fetch,
218
+ authApiBaseUrl: string,
219
+ path: string | URL,
220
+ init: AuthRequestInit
221
+ ): Promise<T> {
222
+ const { token, headers, ...rest } = init
223
+ const url =
224
+ path instanceof URL
225
+ ? path.toString()
226
+ : `${authApiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`
227
+ const response = await fetchImpl(url, {
228
+ ...rest,
229
+ headers: {
230
+ "Content-Type": "application/json",
231
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
232
+ ...(headers ?? {}),
233
+ },
234
+ })
235
+
236
+ let json: ServerResponse<T> | null = null
237
+ try {
238
+ json = (await response.json()) as ServerResponse<T>
239
+ } catch {
240
+ json = null
241
+ }
242
+
243
+ if (!response.ok || !json?.success) {
244
+ throw new ServiceError(
245
+ json?.message || response.statusText || "Request failed",
246
+ response.status || 500,
247
+ BizErrorCode.AUTH_ERROR
248
+ )
249
+ }
250
+
251
+ return json.data
252
+ }
253
+
254
+ function extractBearerToken(header: string | null) {
255
+ if (!header) {
256
+ throw new ServiceError(
257
+ "Missing authorization header",
258
+ 401,
259
+ BizErrorCode.AUTH_ERROR
260
+ )
261
+ }
262
+ const match = header.match(/^Bearer\s+(.+)$/i)
263
+ if (!match) {
264
+ throw new ServiceError(
265
+ "Invalid authorization header",
266
+ 401,
267
+ BizErrorCode.AUTH_ERROR
268
+ )
269
+ }
270
+ return match[1].trim()
271
+ }
@@ -216,6 +216,9 @@ export class AccountManagerV2 extends EmitterV2<AccountManagerEventMap> {
216
216
  }
217
217
 
218
218
  public clearSelectedProfile(): void {
219
+ if (!getSelectedProfileId(this.app)) {
220
+ return
221
+ }
219
222
  clearSelectedProfileId(this.app)
220
223
  this.emit(AccountManagerEvent.StateChange)
221
224
  }