@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
@@ -0,0 +1,47 @@
1
+ import type { AuthClient } from "../client.js";
2
+ /**
3
+ * Remembers the `jti` of accepted logout tokens until they expire. Share it
4
+ * across replicas (e.g. a Redis `SET NX PX`): a per-process store only stops
5
+ * replays that hit the same instance.
6
+ */
7
+ export interface ReplayStore {
8
+ /** Returns true the first time a `jti` is claimed, false on a replay. */
9
+ claim(jti: string, expiresAt: number): Promise<boolean>;
10
+ }
11
+ export declare class MemoryReplayStore implements ReplayStore {
12
+ #private;
13
+ claim(jti: string, expiresAt: number): Promise<boolean>;
14
+ }
15
+ export interface LogoutTokenClaims {
16
+ readonly sid: string;
17
+ readonly sub: string;
18
+ readonly jti: string;
19
+ readonly iat: number;
20
+ readonly exp: number;
21
+ }
22
+ type LogoutAuth = Pick<AuthClient, "verifyJwt" | "config">;
23
+ /**
24
+ * Validates an OIDC Back-Channel Logout token (spec §2.6) and records its
25
+ * `jti`. auth-service always names both the session and the user.
26
+ */
27
+ export declare function verifyLogoutToken(auth: LogoutAuth, token: string, replayStore: ReplayStore): Promise<LogoutTokenClaims>;
28
+ export interface BackchannelLogoutEvent {
29
+ /** End every local session whose stored `sid` equals this one. */
30
+ readonly sid: string;
31
+ /** For per-user state, when you keep any. */
32
+ readonly sub: string;
33
+ }
34
+ export interface BackchannelLogoutHandlerOptions {
35
+ readonly auth: LogoutAuth;
36
+ readonly onLogout: (event: BackchannelLogoutEvent) => Promise<void> | void;
37
+ readonly replayStore?: ReplayStore;
38
+ /** Receives every rejected token and failed callback: the response carries no detail. */
39
+ readonly onError?: (error: unknown) => void;
40
+ }
41
+ /**
42
+ * Web-standard handler for the application's back-channel logout endpoint.
43
+ * It is called without cookies or credentials: mount it outside any session
44
+ * or CSRF protection.
45
+ */
46
+ export declare function createBackchannelLogoutHandler(options: BackchannelLogoutHandlerOptions): (request: Request) => Promise<Response>;
47
+ export {};
@@ -0,0 +1,105 @@
1
+ import { ERR } from "../errors.js";
2
+ const LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
3
+ const LOGOUT_TOKEN_TYP = "logout+jwt";
4
+ // auth-service issues 120 s tokens; anything far beyond is not one of its.
5
+ const MAX_LOGOUT_TOKEN_LIFETIME_SECONDS = 600;
6
+ const MAX_BODY_BYTES = 16_384;
7
+ export class MemoryReplayStore {
8
+ #seen = new Map();
9
+ claim(jti, expiresAt) {
10
+ const now = Date.now() / 1000;
11
+ for (const [seenJti, seenExpiry] of this.#seen) {
12
+ if (seenExpiry <= now)
13
+ this.#seen.delete(seenJti);
14
+ }
15
+ if (this.#seen.has(jti))
16
+ return Promise.resolve(false);
17
+ this.#seen.set(jti, expiresAt);
18
+ return Promise.resolve(true);
19
+ }
20
+ }
21
+ /**
22
+ * Validates an OIDC Back-Channel Logout token (spec §2.6) and records its
23
+ * `jti`. auth-service always names both the session and the user.
24
+ */
25
+ export async function verifyLogoutToken(auth, token, replayStore) {
26
+ const payload = await auth.verifyJwt(token, {
27
+ audience: auth.config.clientId,
28
+ typ: LOGOUT_TOKEN_TYP,
29
+ });
30
+ const { events, nonce, jti, sid, sub, iat, exp } = payload;
31
+ const hasEvent = typeof events === "object" && events !== null && LOGOUT_EVENT in events;
32
+ if (!hasEvent) {
33
+ throw ERR.TOK_001("The logout token lacks the back-channel logout event");
34
+ }
35
+ if (nonce !== undefined) {
36
+ throw ERR.TOK_001("A logout token must not carry a nonce");
37
+ }
38
+ if (typeof jti !== "string" || !jti || typeof sid !== "string" || !sid) {
39
+ throw ERR.TOK_001("The logout token lacks jti or sid");
40
+ }
41
+ if (typeof sub !== "string" || typeof iat !== "number" || exp === undefined) {
42
+ throw ERR.TOK_001("The logout token lacks sub, iat or exp");
43
+ }
44
+ const now = Date.now() / 1000;
45
+ if (iat > now + auth.config.clockToleranceSeconds ||
46
+ exp - iat > MAX_LOGOUT_TOKEN_LIFETIME_SECONDS) {
47
+ throw ERR.TOK_001("The logout token has an implausible lifetime");
48
+ }
49
+ if (!(await replayStore.claim(jti, exp))) {
50
+ throw ERR.TOK_001("The logout token was already used");
51
+ }
52
+ return { sid, sub, jti, iat, exp };
53
+ }
54
+ const NO_STORE = { "cache-control": "no-store" };
55
+ function jsonError(status, error) {
56
+ return new Response(JSON.stringify({ error }), {
57
+ status,
58
+ headers: { ...NO_STORE, "content-type": "application/json" },
59
+ });
60
+ }
61
+ /**
62
+ * Web-standard handler for the application's back-channel logout endpoint.
63
+ * It is called without cookies or credentials: mount it outside any session
64
+ * or CSRF protection.
65
+ */
66
+ export function createBackchannelLogoutHandler(options) {
67
+ const replayStore = options.replayStore ?? new MemoryReplayStore();
68
+ return async (request) => {
69
+ if (request.method !== "POST") {
70
+ return new Response(null, {
71
+ status: 405,
72
+ headers: { ...NO_STORE, allow: "POST" },
73
+ });
74
+ }
75
+ const declaredLength = Number(request.headers.get("content-length") ?? 0);
76
+ const isForm = (request.headers.get("content-type") ?? "")
77
+ .toLowerCase()
78
+ .startsWith("application/x-www-form-urlencoded");
79
+ if (!isForm || declaredLength > MAX_BODY_BYTES) {
80
+ return jsonError(400, "invalid_request");
81
+ }
82
+ const body = await request.text();
83
+ const token = body.length <= MAX_BODY_BYTES
84
+ ? new URLSearchParams(body).get("logout_token")
85
+ : null;
86
+ if (!token)
87
+ return jsonError(400, "invalid_request");
88
+ let claims;
89
+ try {
90
+ claims = await verifyLogoutToken(options.auth, token, replayStore);
91
+ }
92
+ catch (err) {
93
+ options.onError?.(err);
94
+ return jsonError(400, "invalid_request");
95
+ }
96
+ try {
97
+ await options.onLogout({ sid: claims.sid, sub: claims.sub });
98
+ }
99
+ catch (err) {
100
+ options.onError?.(err);
101
+ return jsonError(500, "server_error");
102
+ }
103
+ return new Response(null, { status: 204, headers: NO_STORE });
104
+ };
105
+ }
@@ -0,0 +1,30 @@
1
+ import type { AccessTokenClaims } from "../claims.js";
2
+ import type { AuthClient } from "../client.js";
3
+ /** Extracts the token of an `Authorization: Bearer <token>` header. */
4
+ export declare function extractBearerToken(authorization: string | null | undefined): string;
5
+ export interface VerifiedAccessToken {
6
+ /** The end user, or `null` for a `client_credentials` token (machine call). */
7
+ readonly userId: string | null;
8
+ /** The OAuth client (application slug) the token was issued to. */
9
+ readonly clientId: string;
10
+ readonly scopes: readonly string[];
11
+ readonly audiences: readonly string[];
12
+ readonly claims: AccessTokenClaims;
13
+ }
14
+ export interface BearerVerifierOptions {
15
+ /** The audience this API accepts: its RFC 8707 resource identifier. */
16
+ readonly audience: string;
17
+ }
18
+ export interface BearerVerifier {
19
+ /** Verifies a raw JWT access token. */
20
+ verify(token: string): Promise<VerifiedAccessToken>;
21
+ /** Verifies the token of an `Authorization` header. */
22
+ verifyHeader(authorization: string | null | undefined): Promise<VerifiedAccessToken>;
23
+ }
24
+ /**
25
+ * Verifies JWT access tokens minted for this API (`aud` contains `audience`).
26
+ * Opaque tokens are refused: they carry no audience this API could trust.
27
+ */
28
+ export declare function createBearerVerifier(auth: Pick<AuthClient, "verifyJwt">, options: BearerVerifierOptions): BearerVerifier;
29
+ /** Throws TOK_004 unless the token carries every listed scope. */
30
+ export declare function requireScopes(token: Pick<VerifiedAccessToken, "scopes">, ...required: string[]): void;
@@ -0,0 +1,58 @@
1
+ import { accessTokenClaimsSchema } from "../claims.js";
2
+ import { ERR } from "../errors.js";
3
+ import { z } from "zod";
4
+ const BEARER_PATTERN = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/i;
5
+ /** Extracts the token of an `Authorization: Bearer <token>` header. */
6
+ export function extractBearerToken(authorization) {
7
+ const match = authorization ? BEARER_PATTERN.exec(authorization) : null;
8
+ const token = match?.[1];
9
+ if (!token)
10
+ throw ERR.TOK_003();
11
+ return token;
12
+ }
13
+ const audienceSchema = z.url();
14
+ /**
15
+ * Verifies JWT access tokens minted for this API (`aud` contains `audience`).
16
+ * Opaque tokens are refused: they carry no audience this API could trust.
17
+ */
18
+ export function createBearerVerifier(auth, options) {
19
+ const parsedAudience = audienceSchema.safeParse(options.audience);
20
+ if (!parsedAudience.success) {
21
+ throw ERR.CFG_001("audience must be the URL of this API");
22
+ }
23
+ const audience = parsedAudience.data;
24
+ async function verify(token) {
25
+ if (token.split(".").length !== 3) {
26
+ throw ERR.TOK_001("Only JWT access tokens are accepted");
27
+ }
28
+ const payload = await auth.verifyJwt(token, { audience });
29
+ const claims = accessTokenClaimsSchema.safeParse(payload);
30
+ if (!claims.success) {
31
+ throw ERR.TOK_001("The access token is not bound to a client", {
32
+ cause: claims.error,
33
+ });
34
+ }
35
+ const { sub, azp, aud, scope } = claims.data;
36
+ return {
37
+ // For client_credentials tokens `sub` is the client id itself.
38
+ userId: sub === azp ? null : sub,
39
+ clientId: azp,
40
+ scopes: scope ? scope.split(" ") : [],
41
+ audiences: typeof aud === "string" ? [aud] : aud,
42
+ claims: claims.data,
43
+ };
44
+ }
45
+ return {
46
+ verify,
47
+ verifyHeader: async (authorization) => verify(extractBearerToken(authorization)),
48
+ };
49
+ }
50
+ /** Throws TOK_004 unless the token carries every listed scope. */
51
+ export function requireScopes(token, ...required) {
52
+ const missing = required.filter((scope) => !token.scopes.includes(scope));
53
+ if (missing.length > 0) {
54
+ throw ERR.TOK_004(`Missing required scope: ${missing.join(" ")}`, {
55
+ details: { missing: missing.join(" ") },
56
+ });
57
+ }
58
+ }
@@ -0,0 +1,78 @@
1
+ import { z } from "zod";
2
+ import type { JsonHttp } from "../http.js";
3
+ export declare const subscriptionViewSchema: z.ZodObject<{
4
+ plan: z.ZodObject<{
5
+ id: z.ZodString;
6
+ name: z.ZodString;
7
+ }, z.core.$strip>;
8
+ isActive: z.ZodBoolean;
9
+ expiresAt: z.ZodNullable<z.ZodString>;
10
+ stripeStatus: z.ZodNullable<z.ZodString>;
11
+ currentPeriodStart: z.ZodNullable<z.ZodString>;
12
+ currentPeriodEnd: z.ZodNullable<z.ZodString>;
13
+ cancelAtPeriodEnd: z.ZodBoolean;
14
+ canceledAt: z.ZodNullable<z.ZodString>;
15
+ }, z.core.$strip>;
16
+ export declare const usageViewSchema: z.ZodObject<{
17
+ periodStart: z.ZodString;
18
+ features: z.ZodArray<z.ZodObject<{
19
+ featureKey: z.ZodString;
20
+ unit: z.ZodNullable<z.ZodString>;
21
+ usageLimit: z.ZodNumber;
22
+ used: z.ZodNumber;
23
+ remaining: z.ZodNullable<z.ZodNumber>;
24
+ }, z.core.$strip>>;
25
+ }, z.core.$strip>;
26
+ export declare const invoiceViewSchema: z.ZodObject<{
27
+ id: z.ZodString;
28
+ status: z.ZodString;
29
+ billingReason: z.ZodNullable<z.ZodString>;
30
+ currency: z.ZodString;
31
+ amountDue: z.ZodNumber;
32
+ amountPaid: z.ZodNumber;
33
+ amountRemaining: z.ZodNumber;
34
+ periodStart: z.ZodNullable<z.ZodString>;
35
+ periodEnd: z.ZodNullable<z.ZodString>;
36
+ createdAt: z.ZodString;
37
+ hostedInvoiceUrl: z.ZodNullable<z.ZodString>;
38
+ invoicePdf: z.ZodNullable<z.ZodString>;
39
+ }, z.core.$strip>;
40
+ export declare const invoicePageSchema: z.ZodObject<{
41
+ items: z.ZodArray<z.ZodObject<{
42
+ id: z.ZodString;
43
+ status: z.ZodString;
44
+ billingReason: z.ZodNullable<z.ZodString>;
45
+ currency: z.ZodString;
46
+ amountDue: z.ZodNumber;
47
+ amountPaid: z.ZodNumber;
48
+ amountRemaining: z.ZodNumber;
49
+ periodStart: z.ZodNullable<z.ZodString>;
50
+ periodEnd: z.ZodNullable<z.ZodString>;
51
+ createdAt: z.ZodString;
52
+ hostedInvoiceUrl: z.ZodNullable<z.ZodString>;
53
+ invoicePdf: z.ZodNullable<z.ZodString>;
54
+ }, z.core.$strip>>;
55
+ nextCursor: z.ZodNullable<z.ZodString>;
56
+ }, z.core.$strip>;
57
+ export type SubscriptionView = z.infer<typeof subscriptionViewSchema>;
58
+ export type UsageView = z.infer<typeof usageViewSchema>;
59
+ export type InvoiceView = z.infer<typeof invoiceViewSchema>;
60
+ export type InvoicePage = z.infer<typeof invoicePageSchema>;
61
+ export interface ListInvoicesOptions {
62
+ /** 1 to 100. */
63
+ readonly limit?: number;
64
+ readonly cursor?: string;
65
+ }
66
+ /**
67
+ * What a user holds and has consumed in this application. The application is
68
+ * the one behind the machine token: it cannot read another one.
69
+ */
70
+ export declare class BillingApi {
71
+ #private;
72
+ constructor(http: JsonHttp);
73
+ /** `null` when the user holds no subscription. */
74
+ getSubscription(userId: string): Promise<SubscriptionView | null>;
75
+ getUsage(userId: string): Promise<UsageView>;
76
+ /** Newest first, keyset-paginated: pass `nextCursor` back until it is null. */
77
+ listInvoices(userId: string, options?: ListInvoicesOptions): Promise<InvoicePage>;
78
+ }
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ const SCOPE = "billing:read";
3
+ export const subscriptionViewSchema = z.object({
4
+ plan: z.object({ id: z.string(), name: z.string() }),
5
+ isActive: z.boolean(),
6
+ expiresAt: z.string().nullable(),
7
+ stripeStatus: z.string().nullable(),
8
+ currentPeriodStart: z.string().nullable(),
9
+ currentPeriodEnd: z.string().nullable(),
10
+ cancelAtPeriodEnd: z.boolean(),
11
+ canceledAt: z.string().nullable(),
12
+ });
13
+ export const usageViewSchema = z.object({
14
+ periodStart: z.string(),
15
+ features: z.array(z.object({
16
+ featureKey: z.string(),
17
+ unit: z.string().nullable(),
18
+ /** The plan's hard cap for the period; -1 means unlimited. */
19
+ usageLimit: z.number(),
20
+ used: z.number(),
21
+ /** Units left before the cap; null when unlimited. */
22
+ remaining: z.number().nullable(),
23
+ })),
24
+ });
25
+ export const invoiceViewSchema = z.object({
26
+ id: z.string(),
27
+ status: z.string(),
28
+ billingReason: z.string().nullable(),
29
+ currency: z.string(),
30
+ amountDue: z.number(),
31
+ amountPaid: z.number(),
32
+ amountRemaining: z.number(),
33
+ periodStart: z.string().nullable(),
34
+ periodEnd: z.string().nullable(),
35
+ createdAt: z.string(),
36
+ hostedInvoiceUrl: z.string().nullable(),
37
+ invoicePdf: z.string().nullable(),
38
+ });
39
+ export const invoicePageSchema = z.object({
40
+ items: z.array(invoiceViewSchema),
41
+ nextCursor: z.string().nullable(),
42
+ });
43
+ const subscriptionResponseSchema = z.object({
44
+ subscription: subscriptionViewSchema.nullable(),
45
+ });
46
+ /**
47
+ * What a user holds and has consumed in this application. The application is
48
+ * the one behind the machine token: it cannot read another one.
49
+ */
50
+ export class BillingApi {
51
+ #http;
52
+ constructor(http) {
53
+ this.#http = http;
54
+ }
55
+ /** `null` when the user holds no subscription. */
56
+ async getSubscription(userId) {
57
+ const { subscription } = await this.#http.request({
58
+ method: "GET",
59
+ path: `/api/app/billing/users/${encodeURIComponent(userId)}/subscription`,
60
+ scope: SCOPE,
61
+ schema: subscriptionResponseSchema,
62
+ });
63
+ return subscription;
64
+ }
65
+ getUsage(userId) {
66
+ return this.#http.request({
67
+ method: "GET",
68
+ path: `/api/app/billing/users/${encodeURIComponent(userId)}/usage`,
69
+ scope: SCOPE,
70
+ schema: usageViewSchema,
71
+ });
72
+ }
73
+ /** Newest first, keyset-paginated: pass `nextCursor` back until it is null. */
74
+ listInvoices(userId, options = {}) {
75
+ return this.#http.request({
76
+ method: "GET",
77
+ path: `/api/app/billing/users/${encodeURIComponent(userId)}/invoices`,
78
+ scope: SCOPE,
79
+ query: { limit: options.limit, cursor: options.cursor },
80
+ schema: invoicePageSchema,
81
+ });
82
+ }
83
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from "zod";
2
+ import type { JsonHttp } from "../http.js";
3
+ declare const storedAggregateSchema: z.ZodObject<{
4
+ key: z.ZodString;
5
+ total: z.ZodString;
6
+ updatedAt: z.ZodString;
7
+ }, z.core.$strip>;
8
+ export interface ConsumptionReport {
9
+ readonly userId: string;
10
+ /** Alphanumeric and dots, 64 characters at most. */
11
+ readonly key: string;
12
+ /** May be negative to credit back. */
13
+ readonly value: number;
14
+ /** The same key reported twice counts once: set it to make retries safe. */
15
+ readonly idempotencyKey?: string;
16
+ }
17
+ export interface ConsumptionResult {
18
+ /** `total` is a decimal string: the running total is not bounded by a double. */
19
+ readonly aggregate: {
20
+ readonly key: string;
21
+ readonly total: string;
22
+ };
23
+ /** True when the key prices a metered feature and was billed through Stripe. */
24
+ readonly metered: boolean;
25
+ }
26
+ export type ConsumptionAggregate = z.infer<typeof storedAggregateSchema>;
27
+ /**
28
+ * Usage the application reports for its users, for features billed on
29
+ * consumption. The application is the one behind the machine token.
30
+ */
31
+ export declare class ConsumptionApi {
32
+ #private;
33
+ constructor(http: JsonHttp);
34
+ report(input: ConsumptionReport): Promise<ConsumptionResult>;
35
+ /** Every running total of a user. */
36
+ list(userId: string): Promise<ConsumptionAggregate[]>;
37
+ /** One running total; rejects (API_001, CONS_003) when the key was never reported. */
38
+ get(userId: string, key: string): Promise<ConsumptionAggregate>;
39
+ }
40
+ export {};
@@ -0,0 +1,79 @@
1
+ import { z } from "zod";
2
+ import { ERR } from "../errors.js";
3
+ const reportSchema = z.object({
4
+ userId: z.string().min(1),
5
+ key: z
6
+ .string()
7
+ .min(1)
8
+ .max(64)
9
+ .regex(/^[a-zA-Z0-9.]+$/, "key must be alphanumeric with dots"),
10
+ value: z.number().finite(),
11
+ idempotencyKey: z.string().trim().min(1).max(200).optional(),
12
+ });
13
+ const WRITE_SCOPE = "consumption:write";
14
+ const READ_SCOPE = "consumption:read";
15
+ const aggregateSchema = z.object({ key: z.string(), total: z.string() });
16
+ const storedAggregateSchema = aggregateSchema.extend({
17
+ updatedAt: z.string(),
18
+ });
19
+ const reportResponseSchema = z.object({
20
+ success: z.literal(true),
21
+ metered: z.boolean().optional(),
22
+ aggregate: aggregateSchema,
23
+ });
24
+ /**
25
+ * Usage the application reports for its users, for features billed on
26
+ * consumption. The application is the one behind the machine token.
27
+ */
28
+ export class ConsumptionApi {
29
+ #http;
30
+ constructor(http) {
31
+ this.#http = http;
32
+ }
33
+ async report(input) {
34
+ const body = reportSchema.safeParse(input);
35
+ if (!body.success) {
36
+ throw ERR.CFG_001(`Invalid consumption report: ${body.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`, { cause: body.error });
37
+ }
38
+ const response = await this.#http.request({
39
+ method: "POST",
40
+ path: this.#path(body.data.userId),
41
+ scope: WRITE_SCOPE,
42
+ body: {
43
+ key: body.data.key,
44
+ value: body.data.value,
45
+ ...(body.data.idempotencyKey === undefined
46
+ ? {}
47
+ : { idempotencyKey: body.data.idempotencyKey }),
48
+ },
49
+ schema: reportResponseSchema,
50
+ });
51
+ return {
52
+ aggregate: response.aggregate,
53
+ metered: response.metered ?? false,
54
+ };
55
+ }
56
+ /** Every running total of a user. */
57
+ async list(userId) {
58
+ const { aggregates } = await this.#http.request({
59
+ method: "GET",
60
+ path: this.#path(userId),
61
+ scope: READ_SCOPE,
62
+ schema: z.object({ aggregates: z.array(storedAggregateSchema) }),
63
+ });
64
+ return aggregates;
65
+ }
66
+ /** One running total; rejects (API_001, CONS_003) when the key was never reported. */
67
+ async get(userId, key) {
68
+ const { aggregate } = await this.#http.request({
69
+ method: "GET",
70
+ path: `${this.#path(userId)}/${encodeURIComponent(key)}`,
71
+ scope: READ_SCOPE,
72
+ schema: z.object({ aggregate: storedAggregateSchema }),
73
+ });
74
+ return aggregate;
75
+ }
76
+ #path(userId) {
77
+ return `/api/app/consumption/users/${encodeURIComponent(userId)}`;
78
+ }
79
+ }
@@ -0,0 +1,11 @@
1
+ export { createBearerVerifier, extractBearerToken, requireScopes, } from "./bearer.js";
2
+ export type { BearerVerifier, BearerVerifierOptions, VerifiedAccessToken, } from "./bearer.js";
3
+ export { MemoryReplayStore, createBackchannelLogoutHandler, verifyLogoutToken, } from "./backchannel-logout.js";
4
+ export type { BackchannelLogoutEvent, BackchannelLogoutHandlerOptions, LogoutTokenClaims, ReplayStore, } from "./backchannel-logout.js";
5
+ export { MachineApi, createMachineApi } from "./machine-api.js";
6
+ export { BillingApi } from "./billing.js";
7
+ export type { InvoicePage, InvoiceView, ListInvoicesOptions, SubscriptionView, UsageView, } from "./billing.js";
8
+ export { ConsumptionApi } from "./consumption.js";
9
+ export type { ConsumptionAggregate, ConsumptionReport, ConsumptionResult, } from "./consumption.js";
10
+ export { bearerChallenge, protectedResourceMetadata, protectedResourceMetadataPath, } from "./protected-resource.js";
11
+ export type { BearerChallengeOptions, ProtectedResourceMetadata, ProtectedResourceMetadataOptions, } from "./protected-resource.js";
@@ -0,0 +1,6 @@
1
+ export { createBearerVerifier, extractBearerToken, requireScopes, } from "./bearer.js";
2
+ export { MemoryReplayStore, createBackchannelLogoutHandler, verifyLogoutToken, } from "./backchannel-logout.js";
3
+ export { MachineApi, createMachineApi } from "./machine-api.js";
4
+ export { BillingApi } from "./billing.js";
5
+ export { ConsumptionApi } from "./consumption.js";
6
+ export { bearerChallenge, protectedResourceMetadata, protectedResourceMetadataPath, } from "./protected-resource.js";
@@ -0,0 +1,17 @@
1
+ import type { AuthClient } from "../client.js";
2
+ import { BillingApi } from "./billing.js";
3
+ import { ConsumptionApi } from "./consumption.js";
4
+ type MachineAuth = Pick<AuthClient, "clientCredentials" | "config" | "fetch">;
5
+ /**
6
+ * The auth-service REST API as the application's backend: every call carries a
7
+ * `client_credentials` token minted from the application's own secret. The
8
+ * scopes `billing:read`, `consumption:read` and `consumption:write` must be
9
+ * granted to the application by a superadmin.
10
+ */
11
+ export declare class MachineApi {
12
+ readonly billing: BillingApi;
13
+ readonly consumption: ConsumptionApi;
14
+ constructor(auth: MachineAuth);
15
+ }
16
+ export declare function createMachineApi(auth: MachineAuth): MachineApi;
17
+ export {};
@@ -0,0 +1,31 @@
1
+ import { ERR } from "../errors.js";
2
+ import { BillingApi } from "./billing.js";
3
+ import { ConsumptionApi } from "./consumption.js";
4
+ import { JsonHttp } from "../http.js";
5
+ import { MachineTokenProvider } from "./machine-tokens.js";
6
+ /**
7
+ * The auth-service REST API as the application's backend: every call carries a
8
+ * `client_credentials` token minted from the application's own secret. The
9
+ * scopes `billing:read`, `consumption:read` and `consumption:write` must be
10
+ * granted to the application by a superadmin.
11
+ */
12
+ export class MachineApi {
13
+ billing;
14
+ consumption;
15
+ constructor(auth) {
16
+ if (!auth.config.clientSecret) {
17
+ throw ERR.CFG_001("The machine API requires a confidential client");
18
+ }
19
+ const http = new JsonHttp({
20
+ baseUrl: auth.config.issuer,
21
+ fetch: auth.fetch,
22
+ timeoutMs: auth.config.timeoutMs,
23
+ tokens: new MachineTokenProvider(auth),
24
+ });
25
+ this.billing = new BillingApi(http);
26
+ this.consumption = new ConsumptionApi(http);
27
+ }
28
+ }
29
+ export function createMachineApi(auth) {
30
+ return new MachineApi(auth);
31
+ }
@@ -0,0 +1,13 @@
1
+ import type { AuthClient } from "../client.js";
2
+ /**
3
+ * `client_credentials` tokens of the application, one per scope set. They are
4
+ * cached until shortly before they expire, and concurrent callers share one
5
+ * request to the token endpoint.
6
+ */
7
+ export declare class MachineTokenProvider {
8
+ #private;
9
+ constructor(auth: Pick<AuthClient, "clientCredentials">);
10
+ getToken(scope: string): Promise<string>;
11
+ /** Drops a token the API refused, e.g. after a secret rotation. */
12
+ invalidate(scope: string): void;
13
+ }
@@ -0,0 +1,49 @@
1
+ import { AuthAiError, ERR } from "../errors.js";
2
+ // A cached token is renewed this long before it expires.
3
+ const EXPIRY_SKEW_SECONDS = 30;
4
+ /**
5
+ * `client_credentials` tokens of the application, one per scope set. They are
6
+ * cached until shortly before they expire, and concurrent callers share one
7
+ * request to the token endpoint.
8
+ */
9
+ export class MachineTokenProvider {
10
+ #auth;
11
+ #cache = new Map();
12
+ #inflight = new Map();
13
+ constructor(auth) {
14
+ this.#auth = auth;
15
+ }
16
+ getToken(scope) {
17
+ const cached = this.#cache.get(scope);
18
+ if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > Date.now() / 1000) {
19
+ return Promise.resolve(cached.accessToken);
20
+ }
21
+ const pending = this.#inflight.get(scope);
22
+ if (pending)
23
+ return pending;
24
+ const request = this.#auth
25
+ .clientCredentials({ scope })
26
+ .catch((err) => {
27
+ // The scope goes in `details` (for logs); the message stays generic.
28
+ if (err instanceof AuthAiError &&
29
+ err.details?.["oauthError"] === "invalid_scope") {
30
+ throw ERR.TOK_004(undefined, { details: { scope }, cause: err });
31
+ }
32
+ throw err;
33
+ })
34
+ .then((tokens) => {
35
+ this.#cache.set(scope, {
36
+ accessToken: tokens.accessToken,
37
+ expiresAt: tokens.expiresAt,
38
+ });
39
+ return tokens.accessToken;
40
+ })
41
+ .finally(() => this.#inflight.delete(scope));
42
+ this.#inflight.set(scope, request);
43
+ return request;
44
+ }
45
+ /** Drops a token the API refused, e.g. after a secret rotation. */
46
+ invalidate(scope) {
47
+ this.#cache.delete(scope);
48
+ }
49
+ }