@authyon/auth 0.1.4 → 0.2.0-beta.0
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 +158 -158
- package/dist/index.cjs +49 -15
- package/dist/index.d.cts +43 -6
- package/dist/index.d.ts +43 -6
- package/dist/index.js +49 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
# @authyon/auth
|
|
2
|
-
|
|
3
|
-
SDK JS/TS para o [Authyon](https://authyon.com) — autenticação, sessões, multi-tenant e 2FA, com armazenamento e refresh de tokens transparentes.
|
|
4
|
-
|
|
5
|
-
Cobre todos os endpoints públicos documentados em [authyon.com/docs](https://authyon.com/docs), usando a **publishable key** (`pk_...`) — segura para expor no navegador.
|
|
6
|
-
|
|
7
|
-
Para gestão de organização/membros (secret key) e verificação de token no backend, veja [`@authyon/server`](../server).
|
|
8
|
-
|
|
9
|
-
## Instalação
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm install @authyon/auth
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Uso rápido
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
import { createClient } from "@authyon/auth";
|
|
19
|
-
|
|
20
|
-
const authyon = createClient({ envKey: "pk_live_..." });
|
|
21
|
-
|
|
22
|
-
// Login (com suporte a 2FA)
|
|
23
|
-
const result = await authyon.login({
|
|
24
|
-
email: "alice@acme.com",
|
|
25
|
-
password: "...",
|
|
26
|
-
organizationSlug: "acme", // opcional
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
if (result.twoFactorRequired) {
|
|
30
|
-
const code = prompt(`Código 2FA (${result.methods.join(", ")})`);
|
|
31
|
-
await authyon.verifyTwoFactor({
|
|
32
|
-
challengeToken: result.challengeToken,
|
|
33
|
-
method: "authenticator",
|
|
34
|
-
code: code!,
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Usuário atual — o access token é renovado automaticamente quando necessário
|
|
39
|
-
const user = await authyon.user.me();
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
## Sessão e tokens
|
|
43
|
-
|
|
44
|
-
- Tokens persistem em `localStorage` por padrão (`memoryStorage()` ou um `TokenStorage` próprio via opção `storage`).
|
|
45
|
-
- `getAccessToken()` renova o token automaticamente antes de expirar (refresh token é single-use e rotacionado, com single-flight para evitar corridas).
|
|
46
|
-
- Chamadas autenticadas que retornam 401 fazem um refresh e uma retentativa automática.
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
const token = await authyon.getAccessToken(); // sempre válido, ou null se deslogado
|
|
50
|
-
|
|
51
|
-
const unsubscribe = authyon.onAuthStateChange((event) => {
|
|
52
|
-
// "signed_in" | "refreshed" | "signed_out"
|
|
53
|
-
console.log(event.type);
|
|
54
|
-
});
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## API
|
|
58
|
-
|
|
59
|
-
Métodos de sessão/auth ficam soltos no client; os que giram em torno de um recurso específico ficam agrupados em namespaces (`user`, `organization`, `twoFactor`, `webauthn`, `sso`).
|
|
60
|
-
|
|
61
|
-
| Método | Endpoint |
|
|
62
|
-
| ------------------------------------------------------------------------ | ----------------------- |
|
|
63
|
-
| `register({ email, username, password })` | `POST /auth/register` |
|
|
64
|
-
| `login({ email \| username, password, organizationSlug? })` | `POST /auth/login` |
|
|
65
|
-
| `verifyTwoFactor({ challengeToken, method, code?, webAuthnAssertion? })` | `POST /auth/2fa/verify` |
|
|
66
|
-
| `refresh()` | `POST /auth/refresh` |
|
|
67
|
-
| `logout({ everywhere? })` | `POST /auth/logout` |
|
|
68
|
-
| `introspect(token?)` | `POST /auth/introspect` |
|
|
69
|
-
| `validate(token?)` | `POST /auth/validate` |
|
|
70
|
-
|
|
71
|
-
### `authyon.user`
|
|
72
|
-
|
|
73
|
-
| Método | Endpoint |
|
|
74
|
-
| ----------------------------------------------- | ----------------------------------- |
|
|
75
|
-
| `user.me()` | `GET /auth/me` |
|
|
76
|
-
| `user.sessions()` | `GET /auth/sessions` |
|
|
77
|
-
| `user.revokeSession(sessionId)` | `DELETE /auth/sessions/{id}` |
|
|
78
|
-
| `user.activities(params?)` | `GET /auth/me/activities` |
|
|
79
|
-
| `user.requestPasswordReset(email)` | `POST /auth/password-reset/request` |
|
|
80
|
-
| `user.confirmPasswordReset(token, newPassword)` | `POST /auth/password-reset/confirm` |
|
|
81
|
-
|
|
82
|
-
### `authyon.organization`
|
|
83
|
-
|
|
84
|
-
| Método | Endpoint |
|
|
85
|
-
| ----------------------------------------------------- | ---------------------------------------------- |
|
|
86
|
-
| `organization.list()` | `GET /auth/tenants` |
|
|
87
|
-
| `organization.create(params?)` | `POST /auth/tenants` |
|
|
88
|
-
| `organization.get(organizationId)` | `GET /auth/tenants/{id}` |
|
|
89
|
-
| `organization.rename(organizationId, name)` | `PATCH /auth/tenants/{id}` |
|
|
90
|
-
| `organization.switch(slug)` | `POST /auth/switch-tenant` |
|
|
91
|
-
| `organization.current()` | — (lê `activeOrganization` da sessão em cache) |
|
|
92
|
-
| `organization.members.list(organizationId, params?)` | `GET /auth/tenants/{id}/members` |
|
|
93
|
-
| `organization.members.invite(organizationId, params)` | `POST /auth/tenants/{id}/members` |
|
|
94
|
-
| `organization.members.remove(organizationId, userId)` | `DELETE /auth/tenants/{id}/members/{userId}` |
|
|
95
|
-
| `organization.roles.list(organizationId)` | `GET /auth/tenants/{id}/roles` |
|
|
96
|
-
|
|
97
|
-
### `authyon.twoFactor`
|
|
98
|
-
|
|
99
|
-
| Método | Endpoint |
|
|
100
|
-
| ---------------------------------------------------- | -------------------------------------------- |
|
|
101
|
-
| `twoFactor.status()` | `GET /auth/2fa/status` |
|
|
102
|
-
| `twoFactor.resendEmail(challengeToken)` | `POST /auth/2fa/resend-email` |
|
|
103
|
-
| `twoFactor.setupAuthenticator()` | `POST /auth/2fa/authenticator/setup` |
|
|
104
|
-
| `twoFactor.confirmAuthenticator(code)` | `POST /auth/2fa/authenticator/confirm` |
|
|
105
|
-
| `twoFactor.enableEmail(code?)` | `POST /auth/2fa/email/enable` |
|
|
106
|
-
| `twoFactor.disable(method, currentPassword)` | `POST /auth/2fa/disable` |
|
|
107
|
-
| `twoFactor.regenerateRecoveryCodes(currentPassword)` | `POST /auth/2fa/recovery-codes/regenerate` |
|
|
108
|
-
| `twoFactor.webauthn.registerStart()` | `POST /auth/2fa/webauthn/register/start` |
|
|
109
|
-
| `twoFactor.webauthn.registerFinish(...)` | `POST /auth/2fa/webauthn/register/finish` |
|
|
110
|
-
| `twoFactor.webauthn.credentials()` | `GET /auth/2fa/webauthn/credentials` |
|
|
111
|
-
| `twoFactor.webauthn.renameCredential(id, nickname)` | `PATCH /auth/2fa/webauthn/credentials/{id}` |
|
|
112
|
-
| `twoFactor.webauthn.removeCredential(id, pwd)` | `DELETE /auth/2fa/webauthn/credentials/{id}` |
|
|
113
|
-
| `twoFactor.webauthn.assertionStart(challengeToken)` | `POST /auth/2fa/webauthn/assertion/start` |
|
|
114
|
-
|
|
115
|
-
### `authyon.webauthn` (login sem senha)
|
|
116
|
-
|
|
117
|
-
| Método | Endpoint |
|
|
118
|
-
| --------------------------------- | ---------------------------------- |
|
|
119
|
-
| `webauthn.loginStart(email?)` | `POST /auth/webauthn/login/start` |
|
|
120
|
-
| `webauthn.loginFinish(assertion)` | `POST /auth/webauthn/login/finish` |
|
|
121
|
-
|
|
122
|
-
### `authyon.sso` (login social)
|
|
123
|
-
|
|
124
|
-
| Método | Endpoint |
|
|
125
|
-
| -------------------------------- | ------------------------------------------------------------------------------------- |
|
|
126
|
-
| `sso.providers()` | `GET /auth/sso/providers` |
|
|
127
|
-
| `sso.startUrl(provider, params)` | monta a URL de `GET /auth/sso/{provider}/start` (não faz a chamada — navegue até ela) |
|
|
128
|
-
| `sso.exchange(code)` | `POST /auth/sso/exchange` |
|
|
129
|
-
|
|
130
|
-
## Invalidação de token
|
|
131
|
-
|
|
132
|
-
- **Sessão atual**: `logout()` revoga o refresh token atual; `logout({ everywhere: true })` revoga todos os refresh tokens do usuário.
|
|
133
|
-
- **Uma sessão específica**: `user.revokeSession(sessionId)`, usando o `id` retornado por `user.sessions()` — derruba um dispositivo sem afetar a sessão atual.
|
|
134
|
-
- **Access token**: por ser um JWT stateless, o access token continua "válido" até expirar (`expiresIn`, tipicamente 30 min) mesmo após revogar o refresh token. Para checar revogação em tempo real no seu backend, use `validate()` (cross-checa o estado no banco) em vez de `introspect()`.
|
|
135
|
-
|
|
136
|
-
## Erros
|
|
137
|
-
|
|
138
|
-
Toda resposta não-2xx vira um `AuthyonError` (problem+json). Compare pelo `code` legível por máquina, nunca pelo `title`:
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
import { AuthyonError, ErrorCodes } from "@authyon/auth";
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
await authyon.register({ email, password });
|
|
145
|
-
} catch (err) {
|
|
146
|
-
if (err instanceof AuthyonError && err.is(ErrorCodes.EmailTaken)) {
|
|
147
|
-
// e-mail já cadastrado
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
## Build
|
|
153
|
-
|
|
154
|
-
```bash
|
|
155
|
-
npm install
|
|
156
|
-
npm run build # dist/ (ESM + CJS + .d.ts via tsup)
|
|
157
|
-
npm run typecheck
|
|
158
|
-
```
|
|
1
|
+
# @authyon/auth
|
|
2
|
+
|
|
3
|
+
SDK JS/TS para o [Authyon](https://authyon.com) — autenticação, sessões, multi-tenant e 2FA, com armazenamento e refresh de tokens transparentes.
|
|
4
|
+
|
|
5
|
+
Cobre todos os endpoints públicos documentados em [authyon.com/docs](https://authyon.com/docs), usando a **publishable key** (`pk_...`) — segura para expor no navegador.
|
|
6
|
+
|
|
7
|
+
Para gestão de organização/membros (secret key) e verificação de token no backend, veja [`@authyon/server`](../server).
|
|
8
|
+
|
|
9
|
+
## Instalação
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @authyon/auth
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Uso rápido
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createClient } from "@authyon/auth";
|
|
19
|
+
|
|
20
|
+
const authyon = createClient({ envKey: "pk_live_..." });
|
|
21
|
+
|
|
22
|
+
// Login (com suporte a 2FA)
|
|
23
|
+
const result = await authyon.login({
|
|
24
|
+
email: "alice@acme.com",
|
|
25
|
+
password: "...",
|
|
26
|
+
organizationSlug: "acme", // opcional
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (result.twoFactorRequired) {
|
|
30
|
+
const code = prompt(`Código 2FA (${result.methods.join(", ")})`);
|
|
31
|
+
await authyon.verifyTwoFactor({
|
|
32
|
+
challengeToken: result.challengeToken,
|
|
33
|
+
method: "authenticator",
|
|
34
|
+
code: code!,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Usuário atual — o access token é renovado automaticamente quando necessário
|
|
39
|
+
const user = await authyon.user.me();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Sessão e tokens
|
|
43
|
+
|
|
44
|
+
- Tokens persistem em `localStorage` por padrão (`memoryStorage()` ou um `TokenStorage` próprio via opção `storage`).
|
|
45
|
+
- `getAccessToken()` renova o token automaticamente antes de expirar (refresh token é single-use e rotacionado, com single-flight para evitar corridas).
|
|
46
|
+
- Chamadas autenticadas que retornam 401 fazem um refresh e uma retentativa automática.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const token = await authyon.getAccessToken(); // sempre válido, ou null se deslogado
|
|
50
|
+
|
|
51
|
+
const unsubscribe = authyon.onAuthStateChange((event) => {
|
|
52
|
+
// "signed_in" | "refreshed" | "signed_out"
|
|
53
|
+
console.log(event.type);
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
Métodos de sessão/auth ficam soltos no client; os que giram em torno de um recurso específico ficam agrupados em namespaces (`user`, `organization`, `twoFactor`, `webauthn`, `sso`).
|
|
60
|
+
|
|
61
|
+
| Método | Endpoint |
|
|
62
|
+
| ------------------------------------------------------------------------ | ----------------------- |
|
|
63
|
+
| `register({ email, username, password })` | `POST /auth/register` |
|
|
64
|
+
| `login({ email \| username, password, organizationSlug? })` | `POST /auth/login` |
|
|
65
|
+
| `verifyTwoFactor({ challengeToken, method, code?, webAuthnAssertion? })` | `POST /auth/2fa/verify` |
|
|
66
|
+
| `refresh()` | `POST /auth/refresh` |
|
|
67
|
+
| `logout({ everywhere? })` | `POST /auth/logout` |
|
|
68
|
+
| `introspect(token?)` | `POST /auth/introspect` |
|
|
69
|
+
| `validate(token?)` | `POST /auth/validate` |
|
|
70
|
+
|
|
71
|
+
### `authyon.user`
|
|
72
|
+
|
|
73
|
+
| Método | Endpoint |
|
|
74
|
+
| ----------------------------------------------- | ----------------------------------- |
|
|
75
|
+
| `user.me()` | `GET /auth/me` |
|
|
76
|
+
| `user.sessions()` | `GET /auth/sessions` |
|
|
77
|
+
| `user.revokeSession(sessionId)` | `DELETE /auth/sessions/{id}` |
|
|
78
|
+
| `user.activities(params?)` | `GET /auth/me/activities` |
|
|
79
|
+
| `user.requestPasswordReset(email)` | `POST /auth/password-reset/request` |
|
|
80
|
+
| `user.confirmPasswordReset(token, newPassword)` | `POST /auth/password-reset/confirm` |
|
|
81
|
+
|
|
82
|
+
### `authyon.organization`
|
|
83
|
+
|
|
84
|
+
| Método | Endpoint |
|
|
85
|
+
| ----------------------------------------------------- | ---------------------------------------------- |
|
|
86
|
+
| `organization.list()` | `GET /auth/tenants` |
|
|
87
|
+
| `organization.create(params?)` | `POST /auth/tenants` |
|
|
88
|
+
| `organization.get(organizationId)` | `GET /auth/tenants/{id}` |
|
|
89
|
+
| `organization.rename(organizationId, name)` | `PATCH /auth/tenants/{id}` |
|
|
90
|
+
| `organization.switch(slug)` | `POST /auth/switch-tenant` |
|
|
91
|
+
| `organization.current()` | — (lê `activeOrganization` da sessão em cache) |
|
|
92
|
+
| `organization.members.list(organizationId, params?)` | `GET /auth/tenants/{id}/members` |
|
|
93
|
+
| `organization.members.invite(organizationId, params)` | `POST /auth/tenants/{id}/members` |
|
|
94
|
+
| `organization.members.remove(organizationId, userId)` | `DELETE /auth/tenants/{id}/members/{userId}` |
|
|
95
|
+
| `organization.roles.list(organizationId)` | `GET /auth/tenants/{id}/roles` |
|
|
96
|
+
|
|
97
|
+
### `authyon.twoFactor`
|
|
98
|
+
|
|
99
|
+
| Método | Endpoint |
|
|
100
|
+
| ---------------------------------------------------- | -------------------------------------------- |
|
|
101
|
+
| `twoFactor.status()` | `GET /auth/2fa/status` |
|
|
102
|
+
| `twoFactor.resendEmail(challengeToken)` | `POST /auth/2fa/resend-email` |
|
|
103
|
+
| `twoFactor.setupAuthenticator()` | `POST /auth/2fa/authenticator/setup` |
|
|
104
|
+
| `twoFactor.confirmAuthenticator(code)` | `POST /auth/2fa/authenticator/confirm` |
|
|
105
|
+
| `twoFactor.enableEmail(code?)` | `POST /auth/2fa/email/enable` |
|
|
106
|
+
| `twoFactor.disable(method, currentPassword)` | `POST /auth/2fa/disable` |
|
|
107
|
+
| `twoFactor.regenerateRecoveryCodes(currentPassword)` | `POST /auth/2fa/recovery-codes/regenerate` |
|
|
108
|
+
| `twoFactor.webauthn.registerStart()` | `POST /auth/2fa/webauthn/register/start` |
|
|
109
|
+
| `twoFactor.webauthn.registerFinish(...)` | `POST /auth/2fa/webauthn/register/finish` |
|
|
110
|
+
| `twoFactor.webauthn.credentials()` | `GET /auth/2fa/webauthn/credentials` |
|
|
111
|
+
| `twoFactor.webauthn.renameCredential(id, nickname)` | `PATCH /auth/2fa/webauthn/credentials/{id}` |
|
|
112
|
+
| `twoFactor.webauthn.removeCredential(id, pwd)` | `DELETE /auth/2fa/webauthn/credentials/{id}` |
|
|
113
|
+
| `twoFactor.webauthn.assertionStart(challengeToken)` | `POST /auth/2fa/webauthn/assertion/start` |
|
|
114
|
+
|
|
115
|
+
### `authyon.webauthn` (login sem senha)
|
|
116
|
+
|
|
117
|
+
| Método | Endpoint |
|
|
118
|
+
| --------------------------------- | ---------------------------------- |
|
|
119
|
+
| `webauthn.loginStart(email?)` | `POST /auth/webauthn/login/start` |
|
|
120
|
+
| `webauthn.loginFinish(assertion)` | `POST /auth/webauthn/login/finish` |
|
|
121
|
+
|
|
122
|
+
### `authyon.sso` (login social)
|
|
123
|
+
|
|
124
|
+
| Método | Endpoint |
|
|
125
|
+
| -------------------------------- | ------------------------------------------------------------------------------------- |
|
|
126
|
+
| `sso.providers()` | `GET /auth/sso/providers` |
|
|
127
|
+
| `sso.startUrl(provider, params)` | monta a URL de `GET /auth/sso/{provider}/start` (não faz a chamada — navegue até ela) |
|
|
128
|
+
| `sso.exchange(code)` | `POST /auth/sso/exchange` |
|
|
129
|
+
|
|
130
|
+
## Invalidação de token
|
|
131
|
+
|
|
132
|
+
- **Sessão atual**: `logout()` revoga o refresh token atual; `logout({ everywhere: true })` revoga todos os refresh tokens do usuário.
|
|
133
|
+
- **Uma sessão específica**: `user.revokeSession(sessionId)`, usando o `id` retornado por `user.sessions()` — derruba um dispositivo sem afetar a sessão atual.
|
|
134
|
+
- **Access token**: por ser um JWT stateless, o access token continua "válido" até expirar (`expiresIn`, tipicamente 30 min) mesmo após revogar o refresh token. Para checar revogação em tempo real no seu backend, use `validate()` (cross-checa o estado no banco) em vez de `introspect()`.
|
|
135
|
+
|
|
136
|
+
## Erros
|
|
137
|
+
|
|
138
|
+
Toda resposta não-2xx vira um `AuthyonError` (problem+json). Compare pelo `code` legível por máquina, nunca pelo `title`:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { AuthyonError, ErrorCodes } from "@authyon/auth";
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await authyon.register({ email, password });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err instanceof AuthyonError && err.is(ErrorCodes.EmailTaken)) {
|
|
147
|
+
// e-mail já cadastrado
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Build
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm install
|
|
156
|
+
npm run build # dist/ (ESM + CJS + .d.ts via tsup)
|
|
157
|
+
npm run typecheck
|
|
158
|
+
```
|
package/dist/index.cjs
CHANGED
|
@@ -98,6 +98,22 @@ function defaultStorage() {
|
|
|
98
98
|
// src/client.ts
|
|
99
99
|
var DEFAULT_BASE_URL = "https://api.authyon.com";
|
|
100
100
|
var EXPIRY_SKEW_MS = 3e4;
|
|
101
|
+
var FALLBACK_EXPIRES_IN = 1800;
|
|
102
|
+
function readTokens(raw) {
|
|
103
|
+
const tokens = raw.tokens ?? raw;
|
|
104
|
+
if (!tokens.accessToken || !tokens.refreshToken) {
|
|
105
|
+
throw new AuthyonError(502, {
|
|
106
|
+
code: "session.malformed",
|
|
107
|
+
title: "Malformed session response",
|
|
108
|
+
detail: "The session response carried no access/refresh token pair."
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
accessToken: tokens.accessToken,
|
|
113
|
+
refreshToken: tokens.refreshToken,
|
|
114
|
+
expiresIn: tokens.expiresIn ?? FALLBACK_EXPIRES_IN
|
|
115
|
+
};
|
|
116
|
+
}
|
|
101
117
|
var AuthyonClient = class {
|
|
102
118
|
constructor(options) {
|
|
103
119
|
this.listeners = /* @__PURE__ */ new Set();
|
|
@@ -113,7 +129,7 @@ var AuthyonClient = class {
|
|
|
113
129
|
loginFinish: (assertion) => this.request("/auth/webauthn/login/finish", {
|
|
114
130
|
method: "POST",
|
|
115
131
|
body: assertion
|
|
116
|
-
}).then((data) => this.setSession({ tokens: data
|
|
132
|
+
}).then((data) => this.setSession({ tokens: readTokens(data) }, "signed_in")).then((session) => this.hydrateUser(session))
|
|
117
133
|
};
|
|
118
134
|
// ── Social sign-in (SSO) ─────────────────────────────────────────────────
|
|
119
135
|
this.sso = {
|
|
@@ -134,7 +150,7 @@ var AuthyonClient = class {
|
|
|
134
150
|
* POST /auth/sso/exchange — swaps the one-time code from the provider
|
|
135
151
|
* callback for tokens and stores the session.
|
|
136
152
|
*/
|
|
137
|
-
exchange: (code) => this.request("/auth/sso/exchange", { method: "POST", body: { code } }).then((data) => this.setSession({ tokens: data
|
|
153
|
+
exchange: (code) => this.request("/auth/sso/exchange", { method: "POST", body: { code } }).then((data) => this.setSession({ tokens: readTokens(data) }, "signed_in")).then((session) => this.hydrateUser(session))
|
|
138
154
|
};
|
|
139
155
|
// ── User ─────────────────────────────────────────────────────────────────
|
|
140
156
|
this.user = {
|
|
@@ -192,11 +208,16 @@ var AuthyonClient = class {
|
|
|
192
208
|
method: "POST",
|
|
193
209
|
bearer: true,
|
|
194
210
|
body: { tenantSlug: organizationSlug }
|
|
195
|
-
}).then((data) => this.setSession({ tokens: data
|
|
211
|
+
}).then((data) => this.setSession({ tokens: readTokens(data) }, "refreshed")).then((session) => this.hydrateUser(session)),
|
|
196
212
|
/** The organization the current session is scoped to, from the cached session — no network call. */
|
|
197
213
|
current: () => this.getSession()?.user?.activeOrganization ?? null,
|
|
198
214
|
members: {
|
|
199
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* GET /auth/tenants/{organizationId}/members — paginated list of an
|
|
217
|
+
* organization's members. Consistent with the confirmed-live
|
|
218
|
+
* `Page<T>` envelope every other `skip`/`take` endpoint returns
|
|
219
|
+
* (`user.activities()`, `@authyon/server`'s `environment.users.list()`).
|
|
220
|
+
*/
|
|
200
221
|
list: (organizationId, params = {}) => this.request(
|
|
201
222
|
`/auth/tenants/${encodeURIComponent(organizationId)}/members?${toQuery(params)}`,
|
|
202
223
|
{ bearer: true }
|
|
@@ -414,7 +435,9 @@ var AuthyonClient = class {
|
|
|
414
435
|
if (data.twoFactor) {
|
|
415
436
|
return { twoFactorRequired: true, ...data.twoFactor };
|
|
416
437
|
}
|
|
417
|
-
const session = await this.hydrateUser(
|
|
438
|
+
const session = await this.hydrateUser(
|
|
439
|
+
this.setSession({ tokens: readTokens(data) }, "signed_in")
|
|
440
|
+
);
|
|
418
441
|
return { twoFactorRequired: false, session };
|
|
419
442
|
}
|
|
420
443
|
/** POST /auth/2fa/verify — redeems a 2FA challenge from `login()` and stores the session. */
|
|
@@ -423,7 +446,7 @@ var AuthyonClient = class {
|
|
|
423
446
|
method: "POST",
|
|
424
447
|
body: params
|
|
425
448
|
});
|
|
426
|
-
return this.hydrateUser(this.setSession({ tokens: data
|
|
449
|
+
return this.hydrateUser(this.setSession({ tokens: readTokens(data) }, "signed_in"));
|
|
427
450
|
}
|
|
428
451
|
/** POST /auth/refresh — rotates the single-use refresh token (single-flight). */
|
|
429
452
|
async refresh() {
|
|
@@ -436,7 +459,7 @@ var AuthyonClient = class {
|
|
|
436
459
|
body: { refreshToken: current.refreshToken }
|
|
437
460
|
}).then(
|
|
438
461
|
(data) => this.setSession(
|
|
439
|
-
{ tokens: data
|
|
462
|
+
{ tokens: readTokens(data), user: current.user },
|
|
440
463
|
"refreshed"
|
|
441
464
|
)
|
|
442
465
|
).catch((error) => {
|
|
@@ -471,21 +494,32 @@ var AuthyonClient = class {
|
|
|
471
494
|
this.clearSession();
|
|
472
495
|
}
|
|
473
496
|
// ── Token verification ───────────────────────────────────────────────────
|
|
474
|
-
/**
|
|
497
|
+
/**
|
|
498
|
+
* POST /auth/introspect — lightweight token introspection (RFC 7662).
|
|
499
|
+
*
|
|
500
|
+
* ⚠️ Confirmed live: this endpoint requires the CALLER to also
|
|
501
|
+
* authenticate, with an environment or tenant client-credentials bearer
|
|
502
|
+
* token — the end user's own access token doesn't satisfy that (401).
|
|
503
|
+
* A browser app has no client secret to present, so this will fail from
|
|
504
|
+
* `@authyon/auth` in practice; call it from your backend via
|
|
505
|
+
* `@authyon/server` instead.
|
|
506
|
+
*/
|
|
475
507
|
async introspect(token) {
|
|
476
508
|
const accessToken = token ?? await this.getAccessToken();
|
|
477
509
|
return this.request("/auth/introspect", { method: "POST", body: { token: accessToken } });
|
|
478
510
|
}
|
|
479
|
-
/**
|
|
511
|
+
/**
|
|
512
|
+
* POST /auth/validate — recommended: cross-checks DB state, catches
|
|
513
|
+
* revocation immediately. Same caller-authentication requirement (and
|
|
514
|
+
* the same practical limitation from the browser) as `introspect()`.
|
|
515
|
+
*/
|
|
480
516
|
async validate(token) {
|
|
481
517
|
const accessToken = token ?? await this.getAccessToken();
|
|
482
|
-
const raw = await this.request("/auth/validate", {
|
|
483
|
-
method: "POST",
|
|
484
|
-
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : void 0
|
|
485
|
-
});
|
|
518
|
+
const raw = await this.request("/auth/validate", { method: "POST", body: { token: accessToken } });
|
|
486
519
|
return {
|
|
487
|
-
|
|
488
|
-
|
|
520
|
+
valid: raw.valid,
|
|
521
|
+
reason: raw.reason ?? null,
|
|
522
|
+
user: raw.profile ? normalizeUser(raw.profile) : null
|
|
489
523
|
};
|
|
490
524
|
}
|
|
491
525
|
};
|
package/dist/index.d.cts
CHANGED
|
@@ -15,10 +15,14 @@ interface CreateOrganizationParams {
|
|
|
15
15
|
slug?: string;
|
|
16
16
|
description?: string;
|
|
17
17
|
}
|
|
18
|
+
/** GET /auth/tenants/{organizationId}/members — confirmed against the live API. */
|
|
18
19
|
interface OrganizationMember {
|
|
19
20
|
userId: string;
|
|
20
21
|
email?: string;
|
|
22
|
+
username?: string;
|
|
21
23
|
roles?: string[];
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
lastLoginAt?: string | null;
|
|
22
26
|
}
|
|
23
27
|
/** POST /auth/tenants/{tenantId}/members — invites a member by e-mail. */
|
|
24
28
|
interface InviteMemberParams {
|
|
@@ -181,17 +185,32 @@ interface SessionInfo {
|
|
|
181
185
|
lastUsedAt?: string | null;
|
|
182
186
|
lastUsedFromIp?: string | null;
|
|
183
187
|
}
|
|
188
|
+
/** POST /auth/introspect (RFC 7662) — confirmed against the live API. */
|
|
184
189
|
interface IntrospectResult {
|
|
185
190
|
active: boolean;
|
|
186
191
|
sub?: string;
|
|
192
|
+
username?: string | null;
|
|
193
|
+
email?: string | null;
|
|
194
|
+
roles?: string[] | null;
|
|
195
|
+
permissions?: string[];
|
|
187
196
|
client_id?: string;
|
|
188
197
|
scope?: string;
|
|
189
198
|
exp?: number;
|
|
199
|
+
iat?: number;
|
|
200
|
+
jti?: string;
|
|
190
201
|
token_type?: string;
|
|
191
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* POST /auth/validate — confirmed against the live API. The wire shape is
|
|
205
|
+
* `{ valid, reason, profile }`, not `{ user, organization }` as the
|
|
206
|
+
* OpenAPI schema (which didn't document response bodies) suggested.
|
|
207
|
+
* `profile` is `null` for machine tokens (there's no user behind them) and
|
|
208
|
+
* for tokens that fail validation.
|
|
209
|
+
*/
|
|
192
210
|
interface ValidateResult {
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
valid: boolean;
|
|
212
|
+
reason?: string | null;
|
|
213
|
+
user: User | null;
|
|
195
214
|
}
|
|
196
215
|
type AuthEvent = {
|
|
197
216
|
type: "signed_in";
|
|
@@ -349,8 +368,13 @@ declare class AuthyonClient {
|
|
|
349
368
|
/** The organization the current session is scoped to, from the cached session — no network call. */
|
|
350
369
|
current: () => Organization | null;
|
|
351
370
|
members: {
|
|
352
|
-
/**
|
|
353
|
-
|
|
371
|
+
/**
|
|
372
|
+
* GET /auth/tenants/{organizationId}/members — paginated list of an
|
|
373
|
+
* organization's members. Consistent with the confirmed-live
|
|
374
|
+
* `Page<T>` envelope every other `skip`/`take` endpoint returns
|
|
375
|
+
* (`user.activities()`, `@authyon/server`'s `environment.users.list()`).
|
|
376
|
+
*/
|
|
377
|
+
list: (organizationId: string, params?: PageParams) => Promise<Page<OrganizationMember>>;
|
|
354
378
|
/** POST /auth/tenants/{organizationId}/members — invite a member by e-mail. */
|
|
355
379
|
invite: (organizationId: string, params: InviteMemberParams) => Promise<void>;
|
|
356
380
|
/** DELETE /auth/tenants/{organizationId}/members/{userId} — remove a member. */
|
|
@@ -405,9 +429,22 @@ declare class AuthyonClient {
|
|
|
405
429
|
assertionStart: (challengeToken: string) => Promise<WebAuthnCeremonyStart>;
|
|
406
430
|
};
|
|
407
431
|
};
|
|
408
|
-
/**
|
|
432
|
+
/**
|
|
433
|
+
* POST /auth/introspect — lightweight token introspection (RFC 7662).
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ Confirmed live: this endpoint requires the CALLER to also
|
|
436
|
+
* authenticate, with an environment or tenant client-credentials bearer
|
|
437
|
+
* token — the end user's own access token doesn't satisfy that (401).
|
|
438
|
+
* A browser app has no client secret to present, so this will fail from
|
|
439
|
+
* `@authyon/auth` in practice; call it from your backend via
|
|
440
|
+
* `@authyon/server` instead.
|
|
441
|
+
*/
|
|
409
442
|
introspect(token?: string): Promise<IntrospectResult>;
|
|
410
|
-
/**
|
|
443
|
+
/**
|
|
444
|
+
* POST /auth/validate — recommended: cross-checks DB state, catches
|
|
445
|
+
* revocation immediately. Same caller-authentication requirement (and
|
|
446
|
+
* the same practical limitation from the browser) as `introspect()`.
|
|
447
|
+
*/
|
|
411
448
|
validate(token?: string): Promise<ValidateResult>;
|
|
412
449
|
}
|
|
413
450
|
/** Convenience factory: `const authyon = createClient({ envKey: "pk_live_..." })`. */
|
package/dist/index.d.ts
CHANGED
|
@@ -15,10 +15,14 @@ interface CreateOrganizationParams {
|
|
|
15
15
|
slug?: string;
|
|
16
16
|
description?: string;
|
|
17
17
|
}
|
|
18
|
+
/** GET /auth/tenants/{organizationId}/members — confirmed against the live API. */
|
|
18
19
|
interface OrganizationMember {
|
|
19
20
|
userId: string;
|
|
20
21
|
email?: string;
|
|
22
|
+
username?: string;
|
|
21
23
|
roles?: string[];
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
lastLoginAt?: string | null;
|
|
22
26
|
}
|
|
23
27
|
/** POST /auth/tenants/{tenantId}/members — invites a member by e-mail. */
|
|
24
28
|
interface InviteMemberParams {
|
|
@@ -181,17 +185,32 @@ interface SessionInfo {
|
|
|
181
185
|
lastUsedAt?: string | null;
|
|
182
186
|
lastUsedFromIp?: string | null;
|
|
183
187
|
}
|
|
188
|
+
/** POST /auth/introspect (RFC 7662) — confirmed against the live API. */
|
|
184
189
|
interface IntrospectResult {
|
|
185
190
|
active: boolean;
|
|
186
191
|
sub?: string;
|
|
192
|
+
username?: string | null;
|
|
193
|
+
email?: string | null;
|
|
194
|
+
roles?: string[] | null;
|
|
195
|
+
permissions?: string[];
|
|
187
196
|
client_id?: string;
|
|
188
197
|
scope?: string;
|
|
189
198
|
exp?: number;
|
|
199
|
+
iat?: number;
|
|
200
|
+
jti?: string;
|
|
190
201
|
token_type?: string;
|
|
191
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* POST /auth/validate — confirmed against the live API. The wire shape is
|
|
205
|
+
* `{ valid, reason, profile }`, not `{ user, organization }` as the
|
|
206
|
+
* OpenAPI schema (which didn't document response bodies) suggested.
|
|
207
|
+
* `profile` is `null` for machine tokens (there's no user behind them) and
|
|
208
|
+
* for tokens that fail validation.
|
|
209
|
+
*/
|
|
192
210
|
interface ValidateResult {
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
valid: boolean;
|
|
212
|
+
reason?: string | null;
|
|
213
|
+
user: User | null;
|
|
195
214
|
}
|
|
196
215
|
type AuthEvent = {
|
|
197
216
|
type: "signed_in";
|
|
@@ -349,8 +368,13 @@ declare class AuthyonClient {
|
|
|
349
368
|
/** The organization the current session is scoped to, from the cached session — no network call. */
|
|
350
369
|
current: () => Organization | null;
|
|
351
370
|
members: {
|
|
352
|
-
/**
|
|
353
|
-
|
|
371
|
+
/**
|
|
372
|
+
* GET /auth/tenants/{organizationId}/members — paginated list of an
|
|
373
|
+
* organization's members. Consistent with the confirmed-live
|
|
374
|
+
* `Page<T>` envelope every other `skip`/`take` endpoint returns
|
|
375
|
+
* (`user.activities()`, `@authyon/server`'s `environment.users.list()`).
|
|
376
|
+
*/
|
|
377
|
+
list: (organizationId: string, params?: PageParams) => Promise<Page<OrganizationMember>>;
|
|
354
378
|
/** POST /auth/tenants/{organizationId}/members — invite a member by e-mail. */
|
|
355
379
|
invite: (organizationId: string, params: InviteMemberParams) => Promise<void>;
|
|
356
380
|
/** DELETE /auth/tenants/{organizationId}/members/{userId} — remove a member. */
|
|
@@ -405,9 +429,22 @@ declare class AuthyonClient {
|
|
|
405
429
|
assertionStart: (challengeToken: string) => Promise<WebAuthnCeremonyStart>;
|
|
406
430
|
};
|
|
407
431
|
};
|
|
408
|
-
/**
|
|
432
|
+
/**
|
|
433
|
+
* POST /auth/introspect — lightweight token introspection (RFC 7662).
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ Confirmed live: this endpoint requires the CALLER to also
|
|
436
|
+
* authenticate, with an environment or tenant client-credentials bearer
|
|
437
|
+
* token — the end user's own access token doesn't satisfy that (401).
|
|
438
|
+
* A browser app has no client secret to present, so this will fail from
|
|
439
|
+
* `@authyon/auth` in practice; call it from your backend via
|
|
440
|
+
* `@authyon/server` instead.
|
|
441
|
+
*/
|
|
409
442
|
introspect(token?: string): Promise<IntrospectResult>;
|
|
410
|
-
/**
|
|
443
|
+
/**
|
|
444
|
+
* POST /auth/validate — recommended: cross-checks DB state, catches
|
|
445
|
+
* revocation immediately. Same caller-authentication requirement (and
|
|
446
|
+
* the same practical limitation from the browser) as `introspect()`.
|
|
447
|
+
*/
|
|
411
448
|
validate(token?: string): Promise<ValidateResult>;
|
|
412
449
|
}
|
|
413
450
|
/** Convenience factory: `const authyon = createClient({ envKey: "pk_live_..." })`. */
|
package/dist/index.js
CHANGED
|
@@ -66,6 +66,22 @@ function defaultStorage() {
|
|
|
66
66
|
// src/client.ts
|
|
67
67
|
var DEFAULT_BASE_URL = "https://api.authyon.com";
|
|
68
68
|
var EXPIRY_SKEW_MS = 3e4;
|
|
69
|
+
var FALLBACK_EXPIRES_IN = 1800;
|
|
70
|
+
function readTokens(raw) {
|
|
71
|
+
const tokens = raw.tokens ?? raw;
|
|
72
|
+
if (!tokens.accessToken || !tokens.refreshToken) {
|
|
73
|
+
throw new AuthyonError(502, {
|
|
74
|
+
code: "session.malformed",
|
|
75
|
+
title: "Malformed session response",
|
|
76
|
+
detail: "The session response carried no access/refresh token pair."
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
accessToken: tokens.accessToken,
|
|
81
|
+
refreshToken: tokens.refreshToken,
|
|
82
|
+
expiresIn: tokens.expiresIn ?? FALLBACK_EXPIRES_IN
|
|
83
|
+
};
|
|
84
|
+
}
|
|
69
85
|
var AuthyonClient = class {
|
|
70
86
|
constructor(options) {
|
|
71
87
|
this.listeners = /* @__PURE__ */ new Set();
|
|
@@ -81,7 +97,7 @@ var AuthyonClient = class {
|
|
|
81
97
|
loginFinish: (assertion) => this.request("/auth/webauthn/login/finish", {
|
|
82
98
|
method: "POST",
|
|
83
99
|
body: assertion
|
|
84
|
-
}).then((data) => this.setSession({ tokens: data
|
|
100
|
+
}).then((data) => this.setSession({ tokens: readTokens(data) }, "signed_in")).then((session) => this.hydrateUser(session))
|
|
85
101
|
};
|
|
86
102
|
// ── Social sign-in (SSO) ─────────────────────────────────────────────────
|
|
87
103
|
this.sso = {
|
|
@@ -102,7 +118,7 @@ var AuthyonClient = class {
|
|
|
102
118
|
* POST /auth/sso/exchange — swaps the one-time code from the provider
|
|
103
119
|
* callback for tokens and stores the session.
|
|
104
120
|
*/
|
|
105
|
-
exchange: (code) => this.request("/auth/sso/exchange", { method: "POST", body: { code } }).then((data) => this.setSession({ tokens: data
|
|
121
|
+
exchange: (code) => this.request("/auth/sso/exchange", { method: "POST", body: { code } }).then((data) => this.setSession({ tokens: readTokens(data) }, "signed_in")).then((session) => this.hydrateUser(session))
|
|
106
122
|
};
|
|
107
123
|
// ── User ─────────────────────────────────────────────────────────────────
|
|
108
124
|
this.user = {
|
|
@@ -160,11 +176,16 @@ var AuthyonClient = class {
|
|
|
160
176
|
method: "POST",
|
|
161
177
|
bearer: true,
|
|
162
178
|
body: { tenantSlug: organizationSlug }
|
|
163
|
-
}).then((data) => this.setSession({ tokens: data
|
|
179
|
+
}).then((data) => this.setSession({ tokens: readTokens(data) }, "refreshed")).then((session) => this.hydrateUser(session)),
|
|
164
180
|
/** The organization the current session is scoped to, from the cached session — no network call. */
|
|
165
181
|
current: () => this.getSession()?.user?.activeOrganization ?? null,
|
|
166
182
|
members: {
|
|
167
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* GET /auth/tenants/{organizationId}/members — paginated list of an
|
|
185
|
+
* organization's members. Consistent with the confirmed-live
|
|
186
|
+
* `Page<T>` envelope every other `skip`/`take` endpoint returns
|
|
187
|
+
* (`user.activities()`, `@authyon/server`'s `environment.users.list()`).
|
|
188
|
+
*/
|
|
168
189
|
list: (organizationId, params = {}) => this.request(
|
|
169
190
|
`/auth/tenants/${encodeURIComponent(organizationId)}/members?${toQuery(params)}`,
|
|
170
191
|
{ bearer: true }
|
|
@@ -382,7 +403,9 @@ var AuthyonClient = class {
|
|
|
382
403
|
if (data.twoFactor) {
|
|
383
404
|
return { twoFactorRequired: true, ...data.twoFactor };
|
|
384
405
|
}
|
|
385
|
-
const session = await this.hydrateUser(
|
|
406
|
+
const session = await this.hydrateUser(
|
|
407
|
+
this.setSession({ tokens: readTokens(data) }, "signed_in")
|
|
408
|
+
);
|
|
386
409
|
return { twoFactorRequired: false, session };
|
|
387
410
|
}
|
|
388
411
|
/** POST /auth/2fa/verify — redeems a 2FA challenge from `login()` and stores the session. */
|
|
@@ -391,7 +414,7 @@ var AuthyonClient = class {
|
|
|
391
414
|
method: "POST",
|
|
392
415
|
body: params
|
|
393
416
|
});
|
|
394
|
-
return this.hydrateUser(this.setSession({ tokens: data
|
|
417
|
+
return this.hydrateUser(this.setSession({ tokens: readTokens(data) }, "signed_in"));
|
|
395
418
|
}
|
|
396
419
|
/** POST /auth/refresh — rotates the single-use refresh token (single-flight). */
|
|
397
420
|
async refresh() {
|
|
@@ -404,7 +427,7 @@ var AuthyonClient = class {
|
|
|
404
427
|
body: { refreshToken: current.refreshToken }
|
|
405
428
|
}).then(
|
|
406
429
|
(data) => this.setSession(
|
|
407
|
-
{ tokens: data
|
|
430
|
+
{ tokens: readTokens(data), user: current.user },
|
|
408
431
|
"refreshed"
|
|
409
432
|
)
|
|
410
433
|
).catch((error) => {
|
|
@@ -439,21 +462,32 @@ var AuthyonClient = class {
|
|
|
439
462
|
this.clearSession();
|
|
440
463
|
}
|
|
441
464
|
// ── Token verification ───────────────────────────────────────────────────
|
|
442
|
-
/**
|
|
465
|
+
/**
|
|
466
|
+
* POST /auth/introspect — lightweight token introspection (RFC 7662).
|
|
467
|
+
*
|
|
468
|
+
* ⚠️ Confirmed live: this endpoint requires the CALLER to also
|
|
469
|
+
* authenticate, with an environment or tenant client-credentials bearer
|
|
470
|
+
* token — the end user's own access token doesn't satisfy that (401).
|
|
471
|
+
* A browser app has no client secret to present, so this will fail from
|
|
472
|
+
* `@authyon/auth` in practice; call it from your backend via
|
|
473
|
+
* `@authyon/server` instead.
|
|
474
|
+
*/
|
|
443
475
|
async introspect(token) {
|
|
444
476
|
const accessToken = token ?? await this.getAccessToken();
|
|
445
477
|
return this.request("/auth/introspect", { method: "POST", body: { token: accessToken } });
|
|
446
478
|
}
|
|
447
|
-
/**
|
|
479
|
+
/**
|
|
480
|
+
* POST /auth/validate — recommended: cross-checks DB state, catches
|
|
481
|
+
* revocation immediately. Same caller-authentication requirement (and
|
|
482
|
+
* the same practical limitation from the browser) as `introspect()`.
|
|
483
|
+
*/
|
|
448
484
|
async validate(token) {
|
|
449
485
|
const accessToken = token ?? await this.getAccessToken();
|
|
450
|
-
const raw = await this.request("/auth/validate", {
|
|
451
|
-
method: "POST",
|
|
452
|
-
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : void 0
|
|
453
|
-
});
|
|
486
|
+
const raw = await this.request("/auth/validate", { method: "POST", body: { token: accessToken } });
|
|
454
487
|
return {
|
|
455
|
-
|
|
456
|
-
|
|
488
|
+
valid: raw.valid,
|
|
489
|
+
reason: raw.reason ?? null,
|
|
490
|
+
user: raw.profile ? normalizeUser(raw.profile) : null
|
|
457
491
|
};
|
|
458
492
|
}
|
|
459
493
|
};
|