@circle-rd/auth-ai 0.0.0-stage → 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/dist/ai.d.ts +32 -0
  4. package/dist/ai.js +65 -0
  5. package/dist/browser/client.d.ts +39 -0
  6. package/dist/browser/client.js +88 -0
  7. package/dist/browser/index.d.ts +10 -0
  8. package/dist/browser/index.js +5 -0
  9. package/dist/browser/store.d.ts +43 -0
  10. package/dist/browser/store.js +99 -0
  11. package/dist/claims.d.ts +60 -0
  12. package/dist/claims.js +68 -0
  13. package/dist/client.d.ts +54 -0
  14. package/dist/client.js +204 -0
  15. package/dist/config.d.ts +51 -0
  16. package/dist/config.js +80 -0
  17. package/dist/errors.d.ts +82 -0
  18. package/dist/errors.js +87 -0
  19. package/dist/fastify/index.d.ts +21 -0
  20. package/dist/fastify/index.js +88 -0
  21. package/dist/gateway-errors.d.ts +14 -0
  22. package/dist/gateway-errors.js +48 -0
  23. package/dist/gateway.d.ts +72 -0
  24. package/dist/gateway.js +115 -0
  25. package/dist/grants.d.ts +72 -0
  26. package/dist/grants.js +157 -0
  27. package/dist/http.d.ts +30 -0
  28. package/dist/http.js +63 -0
  29. package/dist/index.d.ts +20 -0
  30. package/dist/index.js +10 -0
  31. package/dist/introspection.d.ts +13 -0
  32. package/dist/introspection.js +12 -0
  33. package/dist/jwt.d.ts +16 -0
  34. package/dist/jwt.js +35 -0
  35. package/dist/oauth-errors.d.ts +9 -0
  36. package/dist/oauth-errors.js +50 -0
  37. package/dist/provider.d.ts +8 -0
  38. package/dist/provider.js +86 -0
  39. package/dist/react/index.d.ts +44 -0
  40. package/dist/react/index.js +88 -0
  41. package/dist/server/backchannel-logout.d.ts +47 -0
  42. package/dist/server/backchannel-logout.js +105 -0
  43. package/dist/server/bearer.d.ts +30 -0
  44. package/dist/server/bearer.js +58 -0
  45. package/dist/server/billing.d.ts +78 -0
  46. package/dist/server/billing.js +83 -0
  47. package/dist/server/consumption.d.ts +40 -0
  48. package/dist/server/consumption.js +79 -0
  49. package/dist/server/index.d.ts +11 -0
  50. package/dist/server/index.js +6 -0
  51. package/dist/server/machine-api.d.ts +17 -0
  52. package/dist/server/machine-api.js +31 -0
  53. package/dist/server/machine-tokens.d.ts +13 -0
  54. package/dist/server/machine-tokens.js +49 -0
  55. package/dist/server/protected-resource.d.ts +27 -0
  56. package/dist/server/protected-resource.js +37 -0
  57. package/dist/session-user.d.ts +21 -0
  58. package/dist/session-user.js +35 -0
  59. package/dist/types.d.ts +50 -0
  60. package/dist/types.js +1 -0
  61. package/dist/vue/index.d.ts +42 -0
  62. package/dist/vue/index.js +82 -0
  63. package/dist/wallet.d.ts +182 -0
  64. package/dist/wallet.js +184 -0
  65. package/dist/web/ai-routes.d.ts +8 -0
  66. package/dist/web/ai-routes.js +38 -0
  67. package/dist/web/cookies.d.ts +11 -0
  68. package/dist/web/cookies.js +46 -0
  69. package/dist/web/handlers.d.ts +47 -0
  70. package/dist/web/handlers.js +290 -0
  71. package/dist/web/http.d.ts +24 -0
  72. package/dist/web/http.js +85 -0
  73. package/dist/web/index.d.ts +7 -0
  74. package/dist/web/index.js +4 -0
  75. package/dist/web/seal.d.ts +11 -0
  76. package/dist/web/seal.js +39 -0
  77. package/dist/web/sessions.d.ts +43 -0
  78. package/dist/web/sessions.js +39 -0
  79. package/package.json +127 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CIRCLE <https://circle-cyber.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,324 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs-site/public/logo-dark.svg">
4
+ <img src="docs-site/public/logo-light.svg" alt="CIRCLE auth-ai logo" width="140" />
5
+ </picture>
6
+ </p>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
8
+ <h1 align="center">CIRCLE auth-ai</h1>
9
+
10
+ <p align="center">
11
+ <em>The TypeScript SDK for CIRCLE auth-service — sign-in, entitlements, billing and LLM credit, in one library.</em>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://github.com/circle-rd/auth-ai/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/circle-rd/auth-ai/actions/workflows/ci.yml/badge.svg"></a>
16
+ <a href="https://www.npmjs.com/package/@circle-rd/auth-ai"><img alt="npm" src="https://img.shields.io/npm/v/@circle-rd/auth-ai?logo=npm"></a>
17
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg"></a>
18
+ <a href="https://nodejs.org"><img alt="Node" src="https://img.shields.io/badge/node-22-3c873a?logo=node.js&logoColor=white"></a>
19
+ <a href="https://www.typescriptlang.org"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-strict-3178C6.svg?logo=typescript&logoColor=white"></a>
20
+ <a href="https://github.com/circle-rd/auth-service"><img alt="auth-service" src="https://img.shields.io/badge/for-auth--service-black.svg"></a>
21
+ </p>
22
+
23
+ Let your application sign users in with a self-hosted
24
+ [CIRCLE auth-service](https://github.com/circle-rd/auth-service), read what they
25
+ are entitled to (roles, permissions, plan features), meter what they consume,
26
+ and call LLMs on their behalf through a [LiteLLM](https://docs.litellm.ai)
27
+ gateway that bills their credit wallet — without writing any OAuth, token
28
+ refresh, logout or billing plumbing yourself.
29
+
30
+ Full documentation: **https://docs.circle-cyber.com/auth-ai/**
31
+
32
+ ```ts
33
+ const handlers = await createAuthHandlers({
34
+ auth,
35
+ grants,
36
+ sessions,
37
+ secret,
38
+ ai,
39
+ });
40
+ await app.register(authPlugin, { handlers }); // /auth/login, /callback, /me, /logout…
41
+
42
+ app.get("/api/models", { preHandler: requireAuth(handlers) }, async (request) =>
43
+ ai.forUser(request.authSession!.userId).gateway.listModels(),
44
+ );
45
+ ```
46
+
47
+ ## Principles
48
+
49
+ - **Do not reinvent OAuth.** Protocol work stays with the maintained libraries
50
+ [`openid-client`](https://github.com/panva/openid-client) and
51
+ [`jose`](https://github.com/panva/jose); the SDK adds what is specific to
52
+ auth-service on top of them.
53
+ - **Backend-for-frontend by default.** The browser holds one opaque session
54
+ cookie. Access and refresh tokens never reach it.
55
+ - **Fail loud, leak nothing.** Invalid state is rejected, not repaired. Errors
56
+ carry stable codes; HTTP responses expose the code and a generic message, and
57
+ the detail stays in your logs.
58
+ - **Strict at every boundary.** Every external payload (tokens, API answers,
59
+ cookies, environment) is validated with [Zod](https://zod.dev); `any` is
60
+ banned.
61
+ - **One package, hard boundaries.** Browser entry points import no server code
62
+ (a test enforces it); frameworks are optional peer dependencies.
63
+ - **Centralized spending.** The SDK never debits a wallet itself: the gateway
64
+ bills each call, so margin and pricing stay under the administrator's control.
65
+
66
+ ## Features
67
+
68
+ **Sign-in and sessions**
69
+
70
+ - Authorization-code flow with PKCE, `state` and `nonce`, multi-audience
71
+ tokens ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)), TOTP handled by
72
+ the provider.
73
+ - One serialized refresh-token chain per user, with per-audience access tokens
74
+ minted on demand and never stored.
75
+ - Sealed one-shot login cookie, hashed session ids, `__Host-` cookies,
76
+ same-origin redirect targets, refused cross-site logout.
77
+ - Claims (roles, permissions, features, plan, organization) renewed from the
78
+ provider on a schedule.
79
+ - **Back-channel logout**: signature, `typ`, audience, event, lifetime and
80
+ replay checks, then the sessions and grant of the user end.
81
+
82
+ **Entitlements**
83
+
84
+ - Typed claims and helpers: `hasRole`, `hasPermission` (write implies read),
85
+ `hasFeature`, `getFeature`.
86
+ - Bearer-token verification for your own API, with RFC 9728
87
+ protected-resource metadata and `WWW-Authenticate` challenges (MCP-friendly).
88
+
89
+ **LLM gateway and credit wallet**
90
+
91
+ - One switch in auth-service ("LiteLLM support") and one option here
92
+ (`gatewayUrl`) turn on the gateway and wallet audiences.
93
+ - An origin-locked `fetch` that signs OpenAI-compatible calls with a fresh user
94
+ token; model listing; MCP endpoint URLs; gateway errors mapped
95
+ (`402` → `GW_001`, `429` with `Retry-After`, …).
96
+ - Wallet reads: balance, spend statistics, per-session usage, transactions,
97
+ top-up link. Amounts are exact `bigint` micro-euros.
98
+
99
+ **Billing and consumption**
100
+
101
+ - Machine client (`client_credentials`, cached and single-flighted): a user's
102
+ subscription, metered usage and invoices; usage reporting with idempotency
103
+ keys for features billed per call.
104
+
105
+ **Frontend**
106
+
107
+ - Framework-agnostic browser client and observable stores, React hooks
108
+ (`useAuth`, `useUser`, `useEntitlements`, `useWallet`, `useModels`…) and Vue
109
+ composables.
110
+
111
+ **Delivery**
112
+
113
+ - Web-standard `Request` → `Response` handlers (mount them anywhere) and a
114
+ Fastify plugin.
115
+ - ESM, Node 22+, full type declarations, MIT licensed.
116
+
117
+ ## Install
118
+
119
+ ```sh
120
+ pnpm add @circle-rd/auth-ai
121
+ # optional peers, as you need them
122
+ pnpm add fastify react vue
123
+ ```
124
+
125
+ Register your application in auth-service first: a confidential application with
126
+ the **LiteLLM support** switch on (if you call the gateway), the redirect URI
127
+ `https://<host>/auth/callback` and the back-channel logout URI
128
+ `https://<host>/auth/backchannel-logout`.
129
+
130
+ ## Quick start
131
+
132
+ A Fastify backend (the stores below live in memory: demo only, see
133
+ [Production](#production)):
134
+
135
+ ```ts
136
+ import Fastify from "fastify";
137
+ import {
138
+ GrantManager,
139
+ MemoryGrantStore,
140
+ createAiClient,
141
+ createAuthClient,
142
+ } from "@circle-rd/auth-ai";
143
+ import { authPlugin, requireAuth } from "@circle-rd/auth-ai/fastify";
144
+ import { MemorySessionStore, createAuthHandlers } from "@circle-rd/auth-ai/web";
145
+
146
+ const auth = createAuthClient({
147
+ issuer: "https://auth.example.com",
148
+ clientId: "my-app",
149
+ clientSecret: process.env.AUTH_CLIENT_SECRET,
150
+ redirectUri: "https://app.example.com/auth/callback",
151
+ gatewayUrl: "https://llm.example.com",
152
+ });
153
+ const grants = new GrantManager({ auth, store: new MemoryGrantStore() });
154
+ const ai = createAiClient({ auth, grants });
155
+
156
+ const handlers = await createAuthHandlers({
157
+ auth,
158
+ grants,
159
+ ai, // adds /auth/wallet, /auth/wallet/stats and /auth/models for your frontend
160
+ sessions: new MemorySessionStore(),
161
+ secret: process.env.COOKIE_SECRET!, // 32+ random characters
162
+ });
163
+
164
+ const app = Fastify();
165
+ await app.register(authPlugin, { handlers });
166
+
167
+ app.get("/api/models", { preHandler: requireAuth(handlers) }, async (request) =>
168
+ ai.forUser(request.authSession!.userId).gateway.listModels(),
169
+ );
170
+ await app.listen({ port: 3000 });
171
+ ```
172
+
173
+ A React frontend:
174
+
175
+ ```tsx
176
+ import { AuthProvider, useAuth, useWallet } from "@circle-rd/auth-ai/react";
177
+
178
+ function Header() {
179
+ const { status, user, login, logout } = useAuth();
180
+ const { data: wallet } = useWallet({ pollIntervalMs: 15_000 });
181
+ if (status !== "authenticated")
182
+ return <button onClick={() => login()}>Sign in</button>;
183
+ return (
184
+ <p>
185
+ {user?.name} · canSpend={String(wallet?.canSpend)}{" "}
186
+ <button onClick={() => void logout()}>Sign out</button>
187
+ </p>
188
+ );
189
+ }
190
+
191
+ root.render(
192
+ <AuthProvider>
193
+ <Header />
194
+ </AuthProvider>,
195
+ );
196
+ ```
197
+
198
+ Calling the gateway with any OpenAI-compatible SDK:
199
+
200
+ ```ts
201
+ const { gateway } = ai.forUser(userId);
202
+ const client = new OpenAI({
203
+ baseURL: gateway.openAiBaseUrl,
204
+ apiKey: "unused", // replaced by a fresh user token on every request
205
+ fetch: gateway.fetch,
206
+ });
207
+ ```
208
+
209
+ A complete, deployable application lives in [`examples/minimal`](examples/minimal).
210
+
211
+ ## Entry points
212
+
213
+ | Import | Runs in | What it gives you |
214
+ | ---------------------------- | ------------------- | ---------------------------------------------------------------------------------- |
215
+ | `@circle-rd/auth-ai` | Node, edge, browser | OIDC client, grants, claims helpers, wallet and gateway clients, error registry |
216
+ | `@circle-rd/auth-ai/server` | Node, edge | Bearer verification, back-channel logout, protected-resource metadata, machine API |
217
+ | `@circle-rd/auth-ai/web` | Node, edge | Sign-in handlers (`Request` → `Response`), session store contract |
218
+ | `@circle-rd/auth-ai/fastify` | Node | Fastify plugin and `requireAuth` / `loadSession` hooks (`fastify` ≥ 5.12.6) |
219
+ | `@circle-rd/auth-ai/client` | Browser | Cookie-only browser client and observable stores |
220
+ | `@circle-rd/auth-ai/react` | Browser | `AuthProvider` and hooks (`react` ≥ 18) |
221
+ | `@circle-rd/auth-ai/vue` | Browser | `createAuthPlugin` and composables (`vue` ≥ 3.4) |
222
+
223
+ ## Architecture
224
+
225
+ ```mermaid
226
+ sequenceDiagram
227
+ autonumber
228
+ participant B as Browser
229
+ participant A as Your backend (auth-ai)
230
+ participant S as auth-service
231
+ participant G as LiteLLM gateway
232
+ B->>A: GET /auth/login
233
+ A-->>B: 302 to auth-service (PKCE, resources) + sealed cookie
234
+ B->>S: sign in (password, TOTP…)
235
+ S-->>B: 302 /auth/callback?code
236
+ B->>A: GET /auth/callback
237
+ A->>S: code → tokens (client secret)
238
+ A-->>B: 303 + opaque session cookie
239
+ B->>A: GET /auth/wallet (cookie)
240
+ A->>S: wallet read (user token for auth-service)
241
+ B->>A: POST /api/chat (cookie)
242
+ A->>G: call (user token for the gateway)
243
+ G->>S: debit the wallet after the call
244
+ S-->>A: back-channel logout on sign-out anywhere
245
+ ```
246
+
247
+ ```
248
+ auth-ai/
249
+ ├── 📁 src/
250
+ │ ├── client.ts AuthClient — discovery, PKCE login, refresh, logout, revoke, introspect
251
+ │ ├── config.ts Zod-validated configuration (gatewayUrl adds the gateway audiences)
252
+ │ ├── grants.ts GrantManager — serialized refresh rotation, per-audience tokens
253
+ │ ├── claims.ts Claim schemas and entitlement helpers
254
+ │ ├── wallet.ts WalletClient and wallet schemas (bigint micro-euros)
255
+ │ ├── gateway.ts GatewayClient — origin-locked fetch, listings, MCP URLs
256
+ │ ├── ai.ts createAiClient().forUser(grantId)
257
+ │ ├── errors.ts AuthAiError + ERR registry (OIDC_*, TOK_*, GW_*, SES_*…)
258
+ │ ├── 📁 server/ Bearer verifier, back-channel logout, machine API
259
+ │ ├── 📁 web/ Sign-in handlers, sealed cookies, session store contract
260
+ │ ├── 📁 fastify/ Fastify adapter
261
+ │ ├── 📁 browser/ Cookie-only client and stores (entry point `/client`)
262
+ │ ├── 📁 react/ React bindings
263
+ │ └── 📁 vue/ Vue bindings
264
+ ├── 📁 examples/minimal/ Deployable end-to-end app with a self-test
265
+ └── 📁 docs-site/ Docus documentation (built into a container image)
266
+ ```
267
+
268
+ ## Production
269
+
270
+ The SDK is storage-agnostic on purpose. Before going live:
271
+
272
+ - **Persist sessions and grants.** Implement `SessionStore` and `GrantStore` on
273
+ your database. The in-memory stores sign everyone out on restart and cannot
274
+ be shared by replicas.
275
+ - **Several replicas?** Provide a distributed `GrantLock` (a refresh token
276
+ rotates on every use; a replayed one revokes the whole family) and a shared
277
+ `ReplayStore` for back-channel logout tokens.
278
+ - **Secrets from the environment only:** the client secret and a 32+ character
279
+ cookie secret. Neither is ever logged or returned.
280
+ - **Registered URIs must match:** `redirectUri` is `<origin><basePath>/callback`
281
+ and the back-channel URI is `<origin><basePath>/backchannel-logout`, reachable
282
+ over HTTPS by auth-service.
283
+ - **Log with `onError`, answer with `toPublicJSON()`.** Never forward
284
+ `error.message` or `error.details` to a browser.
285
+ - **Keep `fastify` ≥ 5.12.6** (several 2026 advisories are fixed there).
286
+
287
+ ## Documentation
288
+
289
+ The full documentation is published at
290
+ [docs.circle-cyber.com/auth-ai](https://docs.circle-cyber.com/auth-ai/):
291
+ concepts, one guide per framework, the configuration and API reference, the
292
+ error-code registry and a production checklist.
293
+
294
+ **For AI agents** — the documentation is exposed through a built-in MCP server
295
+ (HTTP), so agents can query it directly:
296
+
297
+ | Environment | MCP endpoint |
298
+ | ----------- | --------------------------------------------------------------- |
299
+ | Production | `https://docs.circle-cyber.com/auth-ai/mcp` |
300
+ | Local dev | `http://localhost:3000/mcp` (run `npm run dev` in `docs-site/`) |
301
+
302
+ **Contributing** — the development rules in [AGENTS.md](AGENTS.md) (naming,
303
+ TypeScript strictness, error handling, testing, security) and the process in
304
+ [CONTRIBUTING.md](CONTRIBUTING.md) are mandatory for any contribution.
305
+ Security reports: see [SECURITY.md](SECURITY.md). Releases are tracked in
306
+ [CHANGELOG.md](CHANGELOG.md).
307
+
308
+ ## Roadmap
309
+
310
+ - **Python port** of the same behaviours (the test suite pins them), for the
311
+ Python backends of the CIRCLE stack.
312
+ - **Migration of [gine](https://github.com/circle-rd/gine)** onto the SDK.
313
+ - **npm Trusted Publishing** once the first release is out, replacing the
314
+ publish token.
315
+ - **More adapters** on top of the Web-standard handlers, as needs appear.
316
+
317
+ ## Credits & license
318
+
319
+ Built and maintained by [CIRCLE](https://circle-cyber.com). Stands on the
320
+ shoulders of [openid-client](https://github.com/panva/openid-client),
321
+ [jose](https://github.com/panva/jose), [Zod](https://zod.dev) and
322
+ [Fastify](https://fastify.dev).
323
+
324
+ Licensed under the **MIT License** — see [LICENSE](LICENSE).
package/dist/ai.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { AuthClient } from "./client.js";
2
+ import { GatewayClient } from "./gateway.js";
3
+ import type { GrantManager } from "./grants.js";
4
+ import { WalletClient } from "./wallet.js";
5
+ type AiAuth = Pick<AuthClient, "config" | "fetch">;
6
+ type AiGrants = Pick<GrantManager, "getAccessToken" | "invalidate">;
7
+ export interface AiClientOptions {
8
+ readonly auth: AiAuth;
9
+ /** Holds the refresh token of each user: access tokens are minted from it. */
10
+ readonly grants: AiGrants;
11
+ }
12
+ /** What an application does for one signed-in user: read the wallet, call the gateway. */
13
+ export declare class UserAi {
14
+ #private;
15
+ readonly wallet: WalletClient;
16
+ constructor(auth: AiAuth, grants: AiGrants, grantId: string);
17
+ /** Needs `gatewayUrl` in the client configuration. */
18
+ get gateway(): GatewayClient;
19
+ }
20
+ /**
21
+ * Wallet and gateway clients bound to a user's grant. Each call mints (or
22
+ * reuses) a short-lived access token for the right audience: the wallet is
23
+ * served by auth-service, the gateway by its own URL.
24
+ */
25
+ export declare class AiClient {
26
+ #private;
27
+ constructor(options: AiClientOptions);
28
+ /** `grantId` is the key the user's refresh token was saved under (typically `sub`). */
29
+ forUser(grantId: string): UserAi;
30
+ }
31
+ export declare function createAiClient(options: AiClientOptions): AiClient;
32
+ export {};
package/dist/ai.js ADDED
@@ -0,0 +1,65 @@
1
+ import { ERR } from "./errors.js";
2
+ import { GatewayClient } from "./gateway.js";
3
+ import { JsonHttp } from "./http.js";
4
+ import { WalletClient } from "./wallet.js";
5
+ /** What an application does for one signed-in user: read the wallet, call the gateway. */
6
+ export class UserAi {
7
+ wallet;
8
+ #auth;
9
+ #gatewayTokens;
10
+ #gateway;
11
+ constructor(auth, grants, grantId) {
12
+ this.#auth = auth;
13
+ const { config } = auth;
14
+ const tokensFor = (resource) => ({
15
+ getToken: async () => (await grants.getAccessToken(grantId, { resource })).accessToken,
16
+ invalidate: () => grants.invalidate(grantId, resource),
17
+ });
18
+ // A user has a single wallet audience, so the supplier ignores the key.
19
+ const walletTokens = tokensFor(config.issuer);
20
+ this.wallet = new WalletClient(new JsonHttp({
21
+ baseUrl: config.issuer,
22
+ fetch: auth.fetch,
23
+ timeoutMs: config.timeoutMs,
24
+ tokens: {
25
+ getToken: () => walletTokens.getToken(),
26
+ invalidate: () => walletTokens.invalidate(),
27
+ },
28
+ }), config.issuer);
29
+ this.#gatewayTokens = config.gatewayUrl
30
+ ? tokensFor(config.gatewayUrl)
31
+ : undefined;
32
+ }
33
+ /** Needs `gatewayUrl` in the client configuration. */
34
+ get gateway() {
35
+ const { gatewayUrl, timeoutMs } = this.#auth.config;
36
+ if (!gatewayUrl || !this.#gatewayTokens) {
37
+ throw ERR.CFG_001("The gateway requires `gatewayUrl` in the configuration");
38
+ }
39
+ this.#gateway ??= new GatewayClient({
40
+ gatewayUrl,
41
+ tokens: this.#gatewayTokens,
42
+ fetch: this.#auth.fetch,
43
+ timeoutMs,
44
+ });
45
+ return this.#gateway;
46
+ }
47
+ }
48
+ /**
49
+ * Wallet and gateway clients bound to a user's grant. Each call mints (or
50
+ * reuses) a short-lived access token for the right audience: the wallet is
51
+ * served by auth-service, the gateway by its own URL.
52
+ */
53
+ export class AiClient {
54
+ #options;
55
+ constructor(options) {
56
+ this.#options = options;
57
+ }
58
+ /** `grantId` is the key the user's refresh token was saved under (typically `sub`). */
59
+ forUser(grantId) {
60
+ return new UserAi(this.#options.auth, this.#options.grants, grantId);
61
+ }
62
+ }
63
+ export function createAiClient(options) {
64
+ return new AiClient(options);
65
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ import type { GatewayModel } from "../gateway.js";
3
+ import type { SessionUser } from "../session-user.js";
4
+ import type { WalletStats } from "../wallet.js";
5
+ declare const walletSchema: z.ZodObject<{
6
+ balance: z.ZodPipe<z.ZodString, z.ZodTransform<bigint, string>>;
7
+ isUnlimited: z.ZodBoolean;
8
+ canSpend: z.ZodBoolean;
9
+ currency: z.ZodLiteral<"eur">;
10
+ topupUrl: z.ZodString;
11
+ }, z.core.$strip>;
12
+ export type WalletView = z.infer<typeof walletSchema>;
13
+ export interface BrowserAuthOptions {
14
+ /** Where the backend mounted the sign-in routes. */
15
+ readonly basePath?: string;
16
+ readonly fetch?: typeof fetch;
17
+ /** Navigation target of `login` and `logout`; the page's `location` by default. */
18
+ readonly location?: Pick<Location, "assign" | "pathname" | "search">;
19
+ }
20
+ export interface BrowserAuth {
21
+ /** The signed-in user, or null when signed out. */
22
+ getUser(): Promise<SessionUser | null>;
23
+ loginUrl(next?: string): string;
24
+ /** Sends the browser to the sign-in, then back to `next` (the current page by default). */
25
+ login(next?: string): void;
26
+ /** Ends the session and the provider's single sign-on session. */
27
+ logout(): Promise<void>;
28
+ /** Needs the backend routes enabled with the `ai` option. */
29
+ getWallet(): Promise<WalletView>;
30
+ getWalletStats(days?: number): Promise<WalletStats>;
31
+ listModels(): Promise<GatewayModel[]>;
32
+ }
33
+ /**
34
+ * The browser side of the backend-for-frontend: it holds no token, only talks
35
+ * to the application's own routes with the session cookie. Importing it pulls
36
+ * no server code.
37
+ */
38
+ export declare function createBrowserAuth(options?: BrowserAuthOptions): BrowserAuth;
39
+ export {};
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+ import { AuthAiError, ERR, isAuthAiErrorCode } from "../errors.js";
3
+ import { gatewayModelSchema } from "../gateway.js";
4
+ import { sessionUserSchema } from "../session-user.js";
5
+ import { walletAccountSchema, walletStatsSchema } from "../wallet.js";
6
+ const errorEnvelopeSchema = z.object({
7
+ error: z.object({ code: z.string(), message: z.string() }),
8
+ });
9
+ const meSchema = z.object({ user: sessionUserSchema, expiresAt: z.number() });
10
+ const logoutSchema = z.object({ logoutUrl: z.string().nullable() });
11
+ const walletSchema = walletAccountSchema.extend({ topupUrl: z.string() });
12
+ const modelsSchema = z.object({ models: z.array(gatewayModelSchema) });
13
+ function toError(status, body) {
14
+ const envelope = errorEnvelopeSchema.safeParse(body);
15
+ if (!envelope.success) {
16
+ return ERR.API_001(`The server answered ${status}`, {
17
+ details: { status },
18
+ });
19
+ }
20
+ const { code, message } = envelope.data.error;
21
+ return isAuthAiErrorCode(code)
22
+ ? new AuthAiError(code, message, { details: { status } })
23
+ : ERR.API_001(message, { details: { status, apiCode: code } });
24
+ }
25
+ /**
26
+ * The browser side of the backend-for-frontend: it holds no token, only talks
27
+ * to the application's own routes with the session cookie. Importing it pulls
28
+ * no server code.
29
+ */
30
+ export function createBrowserAuth(options = {}) {
31
+ const basePath = options.basePath ?? "/auth";
32
+ const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
33
+ const where = () => options.location ?? globalThis.location;
34
+ async function send(path, init = {}) {
35
+ const response = await doFetch(`${basePath}${path}`, {
36
+ credentials: "same-origin",
37
+ redirect: "error",
38
+ ...init,
39
+ headers: { accept: "application/json", ...init.headers },
40
+ });
41
+ return {
42
+ status: response.status,
43
+ ok: response.ok,
44
+ body: await response.json().catch(() => undefined),
45
+ };
46
+ }
47
+ function parse(schema, body) {
48
+ const parsed = schema.safeParse(body);
49
+ if (!parsed.success)
50
+ throw ERR.API_002(undefined, { cause: parsed.error });
51
+ return parsed.data;
52
+ }
53
+ async function call(path, schema, init) {
54
+ const result = await send(path, init);
55
+ if (!result.ok)
56
+ throw toError(result.status, result.body);
57
+ return parse(schema, result.body);
58
+ }
59
+ const loginUrl = (next) => next === undefined
60
+ ? `${basePath}/login`
61
+ : `${basePath}/login?next=${encodeURIComponent(next)}`;
62
+ return {
63
+ async getUser() {
64
+ const result = await send("/me");
65
+ if (result.status === 401)
66
+ return null;
67
+ if (!result.ok)
68
+ throw toError(result.status, result.body);
69
+ return parse(meSchema, result.body).user;
70
+ },
71
+ loginUrl,
72
+ login(next) {
73
+ const { pathname, search } = where();
74
+ where().assign(loginUrl(next ?? `${pathname}${search}`));
75
+ },
76
+ async logout() {
77
+ const { logoutUrl } = await call("/logout", logoutSchema, {
78
+ method: "POST",
79
+ });
80
+ where().assign(logoutUrl ?? "/");
81
+ },
82
+ getWallet: () => call("/wallet", walletSchema),
83
+ getWalletStats: (days) => call(days === undefined ? "/wallet/stats" : `/wallet/stats?days=${days}`, walletStatsSchema),
84
+ async listModels() {
85
+ return (await call("/models", modelsSchema)).models;
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,10 @@
1
+ export { createBrowserAuth } from "./client.js";
2
+ export type { BrowserAuth, BrowserAuthOptions, WalletView } from "./client.js";
3
+ export { INITIAL_AUTH_STATE, createAuthStore, createObservable, createResourceStore, } from "./store.js";
4
+ export type { AuthState, AuthStatus, AuthStore, Observable, ResourceOptions, ResourceState, ResourceStatus, ResourceStore, } from "./store.js";
5
+ export { getFeature, hasFeature, hasPermission, hasRole } from "../claims.js";
6
+ export { AuthAiError } from "../errors.js";
7
+ export { formatEuros } from "../wallet.js";
8
+ export type { SessionUser } from "../session-user.js";
9
+ export type { GatewayModel } from "../gateway.js";
10
+ export type { WalletStats } from "../wallet.js";
@@ -0,0 +1,5 @@
1
+ export { createBrowserAuth } from "./client.js";
2
+ export { INITIAL_AUTH_STATE, createAuthStore, createObservable, createResourceStore, } from "./store.js";
3
+ export { getFeature, hasFeature, hasPermission, hasRole } from "../claims.js";
4
+ export { AuthAiError } from "../errors.js";
5
+ export { formatEuros } from "../wallet.js";
@@ -0,0 +1,43 @@
1
+ import { AuthAiError } from "../errors.js";
2
+ import type { SessionUser } from "../session-user.js";
3
+ import type { BrowserAuth } from "./client.js";
4
+ type Listener = () => void;
5
+ /** A value that notifies subscribers when it changes: what React and Vue both bind to. */
6
+ export interface Observable<T> {
7
+ get(): T;
8
+ set(value: T): void;
9
+ subscribe(listener: Listener): () => void;
10
+ }
11
+ export declare function createObservable<T>(initial: T): Observable<T>;
12
+ export type AuthStatus = "loading" | "authenticated" | "unauthenticated" | "error";
13
+ export interface AuthState {
14
+ readonly status: AuthStatus;
15
+ readonly user: SessionUser | null;
16
+ readonly error: AuthAiError | null;
17
+ }
18
+ export declare const INITIAL_AUTH_STATE: AuthState;
19
+ export interface AuthStore {
20
+ readonly state: Observable<AuthState>;
21
+ /** Reads the session again: on mount, on focus, or after an API answered 401. */
22
+ refresh(): Promise<void>;
23
+ }
24
+ export declare function createAuthStore(auth: Pick<BrowserAuth, "getUser">): AuthStore;
25
+ export type ResourceStatus = "idle" | "loading" | "ready" | "error";
26
+ export interface ResourceState<T> {
27
+ readonly status: ResourceStatus;
28
+ readonly data: T | null;
29
+ readonly error: AuthAiError | null;
30
+ }
31
+ export interface ResourceStore<T> {
32
+ readonly state: Observable<ResourceState<T>>;
33
+ refresh(): Promise<void>;
34
+ /** Loads now and, with `pollIntervalMs`, keeps reloading; returns the stop function. */
35
+ start(): () => void;
36
+ }
37
+ export interface ResourceOptions {
38
+ readonly pollIntervalMs?: number;
39
+ /** Called when the load fails: e.g. refresh the session on SES_001. */
40
+ readonly onError?: (error: AuthAiError) => void;
41
+ }
42
+ export declare function createResourceStore<T>(load: () => Promise<T>, options?: ResourceOptions): ResourceStore<T>;
43
+ export {};