herald-sdk 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # herald-sdk
2
+
3
+ Node.js SDK for [Herald](https://github.com/timzaak/herald) — a multi-tenant
4
+ authentication, authorization, billing & points system. This is the
5
+ server-side TypeScript counterpart of the Rust
6
+ [`herald-sdk`](../rust) crate, with the same API surface and caching
7
+ behaviour. Zero runtime dependencies (native `fetch`, Node 18+).
8
+
9
+ For browser end-user authentication (login flows, token refresh, WebAuthn)
10
+ use [`herald-auth-web`](../web) instead.
11
+
12
+ ## Features
13
+
14
+ - **Permission checking** with built-in caching (per-request TTL cache,
15
+ token-indexed invalidation, 5-minute token staleness heuristic)
16
+ - **Subscription management** — get subscription details by `entitlementKey`
17
+ - **Points system** — check balance, consume points with idempotency support,
18
+ grant points to explicit Credit Buckets
19
+ - **Realm / user / client-app administration** over the external API
20
+ - ESM + CommonJS dual build, async/await native
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install herald-sdk
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```ts
31
+ import { HeraldClient } from 'herald-sdk'
32
+
33
+ const client = new HeraldClient(
34
+ 'https://your-herald-instance.com', // base URL
35
+ 'your-api-key', // realm/service API key (X-API-Key)
36
+ 300, // permission cache TTL seconds (default 300)
37
+ )
38
+
39
+ // Check permission (cached per exact request for the TTL)
40
+ const resp = await client.checkPermission({
41
+ accessToken: 'user-browser-token', // issued by /api/auth/{realmId}/login
42
+ clientId: 'your-client-id',
43
+ rules: [{ resource: 'document', action: 'read' }],
44
+ })
45
+ console.log('allowed:', resp.allowed)
46
+
47
+ // Force-refresh a user's cached checks after you know they changed
48
+ client.invalidateCache('user-browser-token')
49
+
50
+ // Get subscription
51
+ const sub = await client.getSubscription('realm-id', 'client-app-id')
52
+ console.log('subscription status:', sub.status)
53
+
54
+ // Check points balance
55
+ const balance = await client.getBalance('realm-id', 'user-id')
56
+ console.log('balance:', balance.balance)
57
+
58
+ // Consume points (idempotent via idempotencyKey)
59
+ const result = await client.consumePoints(
60
+ 'realm-id',
61
+ 'user-id',
62
+ 'client-app-id',
63
+ 100,
64
+ 'Purchase item X',
65
+ 'idempotency-key-123',
66
+ )
67
+ // One transaction per affected Credit Bucket; length 1 for single-pool.
68
+ const primary = result.transactions[0]
69
+ console.log('correlationId:', result.correlationId)
70
+ console.log('remaining balance:', primary.balanceAfter)
71
+ ```
72
+
73
+ Errors throw `HeraldSdkError` with a stable `code`
74
+ (`unauthorized` | `forbidden` | `not-found` | `internal-server-error` |
75
+ `api-error` | `network` | `parse`), plus the HTTP `status` and raw `body`
76
+ when a response was received:
77
+
78
+ ```ts
79
+ import { HeraldSdkError } from 'herald-sdk'
80
+
81
+ try {
82
+ await client.getBalance('realm-id', 'user-id')
83
+ } catch (error) {
84
+ if (error instanceof HeraldSdkError && error.code === 'forbidden') {
85
+ // cross-realm access or insufficient permission
86
+ }
87
+ }
88
+ ```
89
+
90
+ ## License
91
+
92
+ Apache-2.0
package/dist/index.cjs ADDED
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ // src/errors.ts
4
+ var HeraldSdkError = class extends Error {
5
+ /** Stable machine-readable category; prefer this over string-matching the message. */
6
+ code;
7
+ /** HTTP status when the error came from a response; undefined for network errors. */
8
+ status;
9
+ /** Raw response body text when the error came from a response. */
10
+ body;
11
+ constructor(code, status, body) {
12
+ const label = status !== void 0 ? `${code} (${status})` : code;
13
+ super(body ? `Herald SDK error: ${label}: ${body}` : `Herald SDK error: ${label}`);
14
+ this.name = "HeraldSdkError";
15
+ this.code = code;
16
+ this.status = status;
17
+ this.body = body;
18
+ }
19
+ };
20
+
21
+ // src/client.ts
22
+ var TOKEN_EXPIRY_THRESHOLD_MS = 3e5;
23
+ function permissionCacheKey(req) {
24
+ return JSON.stringify({ accessToken: req.accessToken, clientId: req.clientId, rules: req.rules });
25
+ }
26
+ async function handleResponse(response) {
27
+ const text = await response.text();
28
+ const status = response.status;
29
+ if (status === 401) throw new HeraldSdkError("unauthorized", status, text);
30
+ if (status === 403) throw new HeraldSdkError("forbidden", status, text);
31
+ if (status === 404) throw new HeraldSdkError("not-found", status, text);
32
+ if (status === 500) throw new HeraldSdkError("internal-server-error", status, text);
33
+ if (status >= 200 && status < 300) {
34
+ try {
35
+ return JSON.parse(text);
36
+ } catch (cause) {
37
+ throw new HeraldSdkError("parse", status, `invalid JSON body: ${String(cause)}`);
38
+ }
39
+ }
40
+ throw new HeraldSdkError("api-error", status, text);
41
+ }
42
+ var HeraldClient = class {
43
+ baseUrl;
44
+ apiKey;
45
+ cacheTtlMs;
46
+ permissionCache = /* @__PURE__ */ new Map();
47
+ /** token → cache keys for its checks (Rust `token_index: DashMap`). */
48
+ tokenIndex = /* @__PURE__ */ new Map();
49
+ /** token → last successful check timestamp (Rust `token_cache`; the Rust
50
+ * tuple also stored the response, but only the timestamp is ever read). */
51
+ tokenLastSeen = /* @__PURE__ */ new Map();
52
+ constructor(baseUrl, apiKey, cacheTtlSeconds) {
53
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
54
+ this.apiKey = apiKey;
55
+ this.cacheTtlMs = (cacheTtlSeconds ?? 300) * 1e3;
56
+ }
57
+ async requestJson(method, path, options = {}) {
58
+ let url = `${this.baseUrl}${path}`;
59
+ if (options.query) {
60
+ const search = new URLSearchParams(options.query).toString();
61
+ if (search) url += `?${search}`;
62
+ }
63
+ const headers = { "X-API-Key": this.apiKey };
64
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
65
+ let response;
66
+ try {
67
+ response = await fetch(url, {
68
+ method,
69
+ headers,
70
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
71
+ });
72
+ } catch (cause) {
73
+ throw new HeraldSdkError("network", void 0, String(cause));
74
+ }
75
+ return handleResponse(response);
76
+ }
77
+ // --- Permission check (with cache) ---
78
+ /**
79
+ * Check whether a user (identified by their browser access token) is allowed
80
+ * an action. Results are cached per exact request for the client's TTL;
81
+ * a token not checked for over 5 minutes has its cache invalidated first.
82
+ */
83
+ async checkPermission(req) {
84
+ if (this.isTokenExpired(req.accessToken)) {
85
+ this.invalidateCache(req.accessToken);
86
+ }
87
+ const cached = this.getCached(req);
88
+ if (cached) return cached;
89
+ const response = await this.requestJson("POST", "/api/ext/permission/check", {
90
+ body: req
91
+ });
92
+ const now = Date.now();
93
+ this.tokenLastSeen.set(req.accessToken, now);
94
+ const key = permissionCacheKey(req);
95
+ let keys = this.tokenIndex.get(req.accessToken);
96
+ if (!keys) {
97
+ keys = /* @__PURE__ */ new Set();
98
+ this.tokenIndex.set(req.accessToken, keys);
99
+ }
100
+ keys.add(key);
101
+ this.permissionCache.set(key, { response, expiresAtMs: now + this.cacheTtlMs });
102
+ return response;
103
+ }
104
+ /** Rust `is_token_expired`: seen before, and more than 5 minutes ago. */
105
+ isTokenExpired(token) {
106
+ const at = this.tokenLastSeen.get(token);
107
+ return at !== void 0 && Date.now() - at > TOKEN_EXPIRY_THRESHOLD_MS;
108
+ }
109
+ /** Cached response for an exact request, lazily evicting expired entries
110
+ * (the Map counterpart of moka's TTL + eviction listener). */
111
+ getCached(req) {
112
+ const key = permissionCacheKey(req);
113
+ const entry = this.permissionCache.get(key);
114
+ if (!entry) return void 0;
115
+ if (Date.now() > entry.expiresAtMs) {
116
+ this.permissionCache.delete(key);
117
+ this.dropIndexEntry(req.accessToken, key);
118
+ return void 0;
119
+ }
120
+ return entry.response;
121
+ }
122
+ dropIndexEntry(token, key) {
123
+ const keys = this.tokenIndex.get(token);
124
+ if (!keys) return;
125
+ keys.delete(key);
126
+ if (keys.size === 0) this.tokenIndex.delete(token);
127
+ }
128
+ /** Drop every cached permission check for a token (e.g. after you know the
129
+ * user's permissions changed). Unlike the Rust crate this is synchronous —
130
+ * `await` it if you prefer; the return value is `void` either way. */
131
+ invalidateCache(token) {
132
+ const keys = this.tokenIndex.get(token);
133
+ if (!keys) return;
134
+ for (const key of keys) this.permissionCache.delete(key);
135
+ this.tokenIndex.delete(token);
136
+ }
137
+ // --- Billing ---
138
+ /** Subscription detail for a client app. */
139
+ getSubscription(realmId, clientAppId) {
140
+ return this.requestJson("GET", `/api/ext/bill/${encodeURIComponent(realmId)}/client/${encodeURIComponent(clientAppId)}/subscription`);
141
+ }
142
+ // --- Points ---
143
+ /** User points balance. */
144
+ getBalance(realmId, userId) {
145
+ return this.requestJson("GET", `/api/ext/points/${encodeURIComponent(realmId)}/balance`, {
146
+ query: { userId }
147
+ });
148
+ }
149
+ /** Consume points from a user's account. `idempotencyKey` prevents double
150
+ * charges when the same logical consume is retried. */
151
+ consumePoints(realmId, userId, clientAppId, amount, description, idempotencyKey) {
152
+ return this.requestJson("POST", `/api/ext/points/${encodeURIComponent(realmId)}/consume`, {
153
+ body: {
154
+ userId,
155
+ clientAppId,
156
+ amount,
157
+ description,
158
+ idempotencyKey
159
+ }
160
+ });
161
+ }
162
+ /** Grant points to a user. `bucketId` is REQUIRED: every grant must target
163
+ * an explicit Credit Bucket. `reason` must be non-empty; `validityDays`
164
+ * omitted means permanent. */
165
+ grantPoints(realmId, userId, bucketId, amount, reason, validityDays) {
166
+ return this.requestJson("POST", `/api/ext/points/${encodeURIComponent(realmId)}/grant`, {
167
+ body: {
168
+ userId,
169
+ bucketId,
170
+ amount,
171
+ reason,
172
+ validityDays
173
+ }
174
+ });
175
+ }
176
+ // --- Realms ---
177
+ createRealm(request) {
178
+ return this.requestJson("POST", "/api/ext/realms", { body: request });
179
+ }
180
+ async listRealms() {
181
+ const body = await this.requestJson("GET", "/api/ext/realms");
182
+ return body.realms;
183
+ }
184
+ getRealm(realmId) {
185
+ return this.requestJson("GET", `/api/ext/realms/${encodeURIComponent(realmId)}`);
186
+ }
187
+ // --- Users ---
188
+ createUser(realmId, request) {
189
+ return this.requestJson("POST", `/api/ext/realms/${encodeURIComponent(realmId)}/users`, { body: request });
190
+ }
191
+ async listUsers(realmId) {
192
+ const body = await this.requestJson(
193
+ "GET",
194
+ `/api/ext/realms/${encodeURIComponent(realmId)}/users`
195
+ );
196
+ return body.items;
197
+ }
198
+ getUser(realmId, userId) {
199
+ return this.requestJson(
200
+ "GET",
201
+ `/api/ext/realms/${encodeURIComponent(realmId)}/users/${encodeURIComponent(userId)}`
202
+ );
203
+ }
204
+ // --- Client apps ---
205
+ createClientApp(realmId, request) {
206
+ return this.requestJson("POST", `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`, {
207
+ body: request
208
+ });
209
+ }
210
+ async listClientApps(realmId) {
211
+ const body = await this.requestJson(
212
+ "GET",
213
+ `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`
214
+ );
215
+ return body.clientApps;
216
+ }
217
+ getClientApp(realmId, clientAppId) {
218
+ return this.requestJson(
219
+ "GET",
220
+ `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps/${encodeURIComponent(clientAppId)}`
221
+ );
222
+ }
223
+ };
224
+
225
+ exports.HeraldClient = HeraldClient;
226
+ exports.HeraldSdkError = HeraldSdkError;
227
+ //# sourceMappingURL=index.cjs.map
228
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/client.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Error type for the Herald Node SDK, mirroring the Rust `herald_sdk::Error`\n * variants (`sdk/rust/src/lib.rs`): every non-2xx or transport failure is\n * thrown as a `HeraldSdkError` carrying a stable machine-readable `code`.\n */\n\nexport type HeraldSdkErrorCode =\n /** Fetch-level transport failure (Rust: `Error::Reqwest`). */\n | 'network'\n /** 401 — invalid API key (Rust: `Error::Unauthorized`). */\n | 'unauthorized'\n /** 403 — e.g. cross-realm access or insufficient permission (Rust: `Error::Forbidden`). */\n | 'forbidden'\n /** 404 (Rust: `Error::NotFound`). */\n | 'not-found'\n /** 500 (Rust: `Error::InternalServerError`). */\n | 'internal-server-error'\n /** Any other non-2xx status (Rust: `Error::ApiError`). */\n | 'api-error'\n /** 2xx with a non-JSON body (Rust: `Error::SerdeJson`). */\n | 'parse'\n\nexport class HeraldSdkError extends Error {\n /** Stable machine-readable category; prefer this over string-matching the message. */\n readonly code: HeraldSdkErrorCode\n /** HTTP status when the error came from a response; undefined for network errors. */\n readonly status?: number\n /** Raw response body text when the error came from a response. */\n readonly body?: string\n\n constructor(code: HeraldSdkErrorCode, status: number | undefined, body: string | undefined) {\n const label = status !== undefined ? `${code} (${status})` : code\n super(body ? `Herald SDK error: ${label}: ${body}` : `Herald SDK error: ${label}`)\n this.name = 'HeraldSdkError'\n this.code = code\n this.status = status\n this.body = body\n }\n}\n","/**\n * Herald server SDK client — a 1:1 TypeScript port of the Rust `herald-sdk`\n * crate (`sdk/rust/src/lib.rs`), which is this SDK's source of truth.\n *\n * Auth model: every call carries the realm/service `X-API-Key` header against\n * the external API surface (`/api/ext/*`). `checkPermission` additionally\n * mirrors the Rust caching behaviour exactly:\n *\n * - a TTL cache keyed by the full request (token + clientId + rules);\n * - a token→keys index so `invalidateCache(token)` drops every cached check\n * for that token;\n * - a 300s \"token snapshot is stale\" heuristic: once a token has been seen\n * at least once more than 5 minutes ago, its cached entries are\n * invalidated before the next check (Rust `is_token_expired`).\n *\n * HTTP layer: native `fetch` (Node 18+), hand-rolled — same deliberate choice\n * as the Rust crate (no OpenAPI-generated client), keeping the package at zero\n * runtime dependencies and its types in lockstep with the crate's.\n */\n\nimport { HeraldSdkError } from './errors'\nimport type {\n ClientAppInfo,\n ClientAppItem,\n ConsumePointsResponse,\n CreateClientAppSdkRequest,\n CreateRealmSdkRequest,\n CreateUserSdkRequest,\n GrantPointsResponse,\n PermissionCheckRequest,\n PermissionCheckResponse,\n PointsBalanceResponse,\n RealmInfo,\n RealmItem,\n SubscriptionDetail,\n UserInfo,\n} from './types'\n\n/** Rust `is_token_expired` threshold: 5 minutes, independent of the cache TTL. */\nconst TOKEN_EXPIRY_THRESHOLD_MS = 300_000\n\ninterface CacheEntry {\n response: PermissionCheckResponse\n /** Lazy TTL: `Date.now()` after which the entry is treated as evicted. */\n expiresAtMs: number\n}\n\n/**\n * Cache key for a permission check. `rules: undefined` and `rules: []` are\n * DIFFERENT keys (Rust: `Option<Vec<Rule>>` participates in `Hash`/`Eq`), and\n * rule order is significant — `JSON.stringify` preserves both distinctions.\n */\nfunction permissionCacheKey(req: PermissionCheckRequest): string {\n return JSON.stringify({ accessToken: req.accessToken, clientId: req.clientId, rules: req.rules })\n}\n\n/** Map a `fetch` response onto the Rust `handle_response` semantics. */\nasync function handleResponse<T>(response: Response): Promise<T> {\n const text = await response.text()\n const status = response.status\n if (status === 401) throw new HeraldSdkError('unauthorized', status, text)\n if (status === 403) throw new HeraldSdkError('forbidden', status, text)\n if (status === 404) throw new HeraldSdkError('not-found', status, text)\n if (status === 500) throw new HeraldSdkError('internal-server-error', status, text)\n if (status >= 200 && status < 300) {\n try {\n return JSON.parse(text) as T\n } catch (cause) {\n throw new HeraldSdkError('parse', status, `invalid JSON body: ${String(cause)}`)\n }\n }\n throw new HeraldSdkError('api-error', status, text)\n}\n\ninterface RequestOptions {\n query?: Record<string, string>\n body?: unknown\n}\n\nexport class HeraldClient {\n private readonly baseUrl: string\n private readonly apiKey: string\n private readonly cacheTtlMs: number\n private readonly permissionCache = new Map<string, CacheEntry>()\n /** token → cache keys for its checks (Rust `token_index: DashMap`). */\n private readonly tokenIndex = new Map<string, Set<string>>()\n /** token → last successful check timestamp (Rust `token_cache`; the Rust\n * tuple also stored the response, but only the timestamp is ever read). */\n private readonly tokenLastSeen = new Map<string, number>()\n\n constructor(baseUrl: string, apiKey: string, cacheTtlSeconds?: number) {\n this.baseUrl = baseUrl.replace(/\\/+$/, '')\n this.apiKey = apiKey\n this.cacheTtlMs = (cacheTtlSeconds ?? 300) * 1000\n }\n\n private async requestJson<T>(method: 'GET' | 'POST', path: string, options: RequestOptions = {}): Promise<T> {\n let url = `${this.baseUrl}${path}`\n if (options.query) {\n const search = new URLSearchParams(options.query).toString()\n if (search) url += `?${search}`\n }\n const headers: Record<string, string> = { 'X-API-Key': this.apiKey }\n if (options.body !== undefined) headers['Content-Type'] = 'application/json'\n\n let response: Response\n try {\n response = await fetch(url, {\n method,\n headers,\n body: options.body !== undefined ? JSON.stringify(options.body) : undefined,\n })\n } catch (cause) {\n throw new HeraldSdkError('network', undefined, String(cause))\n }\n return handleResponse<T>(response)\n }\n\n // --- Permission check (with cache) ---\n\n /**\n * Check whether a user (identified by their browser access token) is allowed\n * an action. Results are cached per exact request for the client's TTL;\n * a token not checked for over 5 minutes has its cache invalidated first.\n */\n async checkPermission(req: PermissionCheckRequest): Promise<PermissionCheckResponse> {\n if (this.isTokenExpired(req.accessToken)) {\n this.invalidateCache(req.accessToken)\n }\n\n const cached = this.getCached(req)\n if (cached) return cached\n\n const response = await this.requestJson<PermissionCheckResponse>('POST', '/api/ext/permission/check', {\n body: req,\n })\n\n const now = Date.now()\n this.tokenLastSeen.set(req.accessToken, now)\n const key = permissionCacheKey(req)\n let keys = this.tokenIndex.get(req.accessToken)\n if (!keys) {\n keys = new Set()\n this.tokenIndex.set(req.accessToken, keys)\n }\n keys.add(key)\n this.permissionCache.set(key, { response, expiresAtMs: now + this.cacheTtlMs })\n return response\n }\n\n /** Rust `is_token_expired`: seen before, and more than 5 minutes ago. */\n private isTokenExpired(token: string): boolean {\n const at = this.tokenLastSeen.get(token)\n return at !== undefined && Date.now() - at > TOKEN_EXPIRY_THRESHOLD_MS\n }\n\n /** Cached response for an exact request, lazily evicting expired entries\n * (the Map counterpart of moka's TTL + eviction listener). */\n private getCached(req: PermissionCheckRequest): PermissionCheckResponse | undefined {\n const key = permissionCacheKey(req)\n const entry = this.permissionCache.get(key)\n if (!entry) return undefined\n if (Date.now() > entry.expiresAtMs) {\n this.permissionCache.delete(key)\n this.dropIndexEntry(req.accessToken, key)\n return undefined\n }\n return entry.response\n }\n\n private dropIndexEntry(token: string, key: string): void {\n const keys = this.tokenIndex.get(token)\n if (!keys) return\n keys.delete(key)\n if (keys.size === 0) this.tokenIndex.delete(token)\n }\n\n /** Drop every cached permission check for a token (e.g. after you know the\n * user's permissions changed). Unlike the Rust crate this is synchronous —\n * `await` it if you prefer; the return value is `void` either way. */\n invalidateCache(token: string): void {\n const keys = this.tokenIndex.get(token)\n if (!keys) return\n for (const key of keys) this.permissionCache.delete(key)\n this.tokenIndex.delete(token)\n }\n\n // --- Billing ---\n\n /** Subscription detail for a client app. */\n getSubscription(realmId: string, clientAppId: string): Promise<SubscriptionDetail> {\n return this.requestJson('GET', `/api/ext/bill/${encodeURIComponent(realmId)}/client/${encodeURIComponent(clientAppId)}/subscription`)\n }\n\n // --- Points ---\n\n /** User points balance. */\n getBalance(realmId: string, userId: string): Promise<PointsBalanceResponse> {\n return this.requestJson('GET', `/api/ext/points/${encodeURIComponent(realmId)}/balance`, {\n query: { userId },\n })\n }\n\n /** Consume points from a user's account. `idempotencyKey` prevents double\n * charges when the same logical consume is retried. */\n consumePoints(\n realmId: string,\n userId: string,\n clientAppId: string,\n amount: number,\n description?: string,\n idempotencyKey?: string,\n ): Promise<ConsumePointsResponse> {\n return this.requestJson('POST', `/api/ext/points/${encodeURIComponent(realmId)}/consume`, {\n body: {\n userId,\n clientAppId,\n amount,\n description,\n idempotencyKey,\n },\n })\n }\n\n /** Grant points to a user. `bucketId` is REQUIRED: every grant must target\n * an explicit Credit Bucket. `reason` must be non-empty; `validityDays`\n * omitted means permanent. */\n grantPoints(\n realmId: string,\n userId: string,\n bucketId: string,\n amount: number,\n reason: string,\n validityDays?: number,\n ): Promise<GrantPointsResponse> {\n return this.requestJson('POST', `/api/ext/points/${encodeURIComponent(realmId)}/grant`, {\n body: {\n userId,\n bucketId,\n amount,\n reason,\n validityDays,\n },\n })\n }\n\n // --- Realms ---\n\n createRealm(request: CreateRealmSdkRequest): Promise<RealmInfo> {\n return this.requestJson('POST', '/api/ext/realms', { body: request })\n }\n\n async listRealms(): Promise<RealmItem[]> {\n const body = await this.requestJson<{ realms: RealmItem[] }>('GET', '/api/ext/realms')\n return body.realms\n }\n\n getRealm(realmId: string): Promise<RealmInfo> {\n return this.requestJson('GET', `/api/ext/realms/${encodeURIComponent(realmId)}`)\n }\n\n // --- Users ---\n\n createUser(realmId: string, request: CreateUserSdkRequest): Promise<UserInfo> {\n return this.requestJson('POST', `/api/ext/realms/${encodeURIComponent(realmId)}/users`, { body: request })\n }\n\n async listUsers(realmId: string): Promise<UserInfo[]> {\n const body = await this.requestJson<{ items: UserInfo[] }>(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/users`,\n )\n return body.items\n }\n\n getUser(realmId: string, userId: string): Promise<UserInfo> {\n return this.requestJson(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/users/${encodeURIComponent(userId)}`,\n )\n }\n\n // --- Client apps ---\n\n createClientApp(realmId: string, request: CreateClientAppSdkRequest): Promise<ClientAppInfo> {\n return this.requestJson('POST', `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`, {\n body: request,\n })\n }\n\n async listClientApps(realmId: string): Promise<ClientAppItem[]> {\n const body = await this.requestJson<{ clientApps: ClientAppItem[] }>(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`,\n )\n return body.clientApps\n }\n\n getClientApp(realmId: string, clientAppId: string): Promise<ClientAppInfo> {\n return this.requestJson(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps/${encodeURIComponent(clientAppId)}`,\n )\n }\n}\n"]}
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Wire types for the Herald external API (`/api/ext/*`).
3
+ *
4
+ * Ported 1:1 from the Rust `herald-sdk` crate (`sdk/rust/src/lib.rs`), which
5
+ * is the source of truth for this SDK: field names follow the backend's
6
+ * camelCase JSON contract, optionality mirrors the Rust `Option` fields (fields
7
+ * the backend always emits as `null` are typed `| null`; fields the backend
8
+ * omits when absent are optional `?`).
9
+ */
10
+ interface Rule {
11
+ resource: string;
12
+ action: string;
13
+ }
14
+ /** `POST /api/ext/permission/check` request. */
15
+ interface PermissionCheckRequest {
16
+ /** Browser access token issued by `/api/auth/{realmId}/login`. */
17
+ accessToken: string;
18
+ rules?: Rule[];
19
+ clientId: string;
20
+ }
21
+ interface PermissionCheckResponse {
22
+ allowed: boolean;
23
+ userId?: string;
24
+ }
25
+ interface SubscriptionDetail {
26
+ id: string;
27
+ clientAppId: string | null;
28
+ status: string;
29
+ entitlementKey: string;
30
+ paymentProvider: string;
31
+ /** Provider price id bound to this subscription; omitted for price-less
32
+ * providers (Creem) or when the subscription has no bound price yet. */
33
+ externalPriceId?: string;
34
+ currentPeriodStart: string | null;
35
+ currentPeriodEnd: string | null;
36
+ cancelAt: string | null;
37
+ cancelAtPeriodEnd: boolean | null;
38
+ createdAt: string;
39
+ updatedAt: string;
40
+ }
41
+ interface PointsBalanceResponse {
42
+ userId: string;
43
+ balance: number;
44
+ totalPaidGranted?: number;
45
+ totalRecharged: number;
46
+ totalConsumed: number;
47
+ unit: string;
48
+ updatedAt: string;
49
+ }
50
+ /** Per-bucket transaction inside a multi-bucket consume response.
51
+ *
52
+ * Single-pool consume → `transactions` has length 1 (structure unified with
53
+ * the multi-bucket case). `amount` is the deduction magnitude (positive). */
54
+ interface BucketTransaction {
55
+ transactionId: string;
56
+ bucketId: string;
57
+ walletId: string;
58
+ userId: string;
59
+ amount: number;
60
+ balanceAfter: number;
61
+ }
62
+ /** Ledger-level allocation detail for a consume. */
63
+ interface AllocationDetail {
64
+ bucketId: string;
65
+ walletId: string;
66
+ ledgerId: string;
67
+ creditType: string;
68
+ allocatedAmount: number;
69
+ }
70
+ /** Points consume response (per-bucket multi-transaction shape). */
71
+ interface ConsumePointsResponse {
72
+ userId: string;
73
+ amount: number;
74
+ correlationId: string;
75
+ transactions: BucketTransaction[];
76
+ allocations: AllocationDetail[];
77
+ }
78
+ /** Points grant response. */
79
+ interface GrantPointsResponse {
80
+ transactionId: string;
81
+ userId: string;
82
+ bucketId: string;
83
+ amount: number;
84
+ grantedBalance: number;
85
+ balance: number;
86
+ expiresAt?: string;
87
+ }
88
+ /** Per-credit-type balances (`balancesByType`). */
89
+ interface BalancesByType {
90
+ topup?: number;
91
+ subscription?: number;
92
+ registration?: number;
93
+ freePeriodic?: number;
94
+ granted?: number;
95
+ }
96
+ /** Quota window read view (`QuotaWindowView`).
97
+ *
98
+ * One row per distinct window `key` for a (user, bucket). `key` is the stable
99
+ * display identity derived from the window length (e.g. `5h`/`week`/`month`),
100
+ * NOT a row ordinal. `isTightest` flags the minimum-remaining window (the
101
+ * spendable-from-quota constraint); `exhausted` flags `remaining == 0`.
102
+ * `resetsAt` is an ISO8601 string (matches the SDK's string-date convention). */
103
+ interface QuotaWindowView {
104
+ /** Stable display key (config-derived, not row ordinal). */
105
+ key: string;
106
+ limit: number;
107
+ used: number;
108
+ remaining: number;
109
+ /** Sliding window length in seconds (month ≈ 30d). */
110
+ windowSeconds: number;
111
+ /** Approximate next reset point of the window (ISO8601); omitted when no
112
+ * consume has occurred in the window yet. */
113
+ resetsAt?: string;
114
+ /** True if this window is the minimum-remaining (tightest) constraint. */
115
+ isTightest: boolean;
116
+ /** True if `remaining == 0`. */
117
+ exhausted: boolean;
118
+ }
119
+ /** Wallet balances grouped by Credit Bucket (`WalletByBucket`).
120
+ *
121
+ * For the admin (`billing/points/wallets`) view, `userId` is populated and
122
+ * rows group per `(user, bucket)`; for the `users/me/points/wallets` view,
123
+ * `userId` is the calling user. */
124
+ interface WalletByBucket {
125
+ bucketId?: string | null;
126
+ name?: string | null;
127
+ enabled?: boolean | null;
128
+ userId: string;
129
+ balancesByType: BalancesByType;
130
+ /** Currently spendable total for this bucket = window-available
131
+ * (`spendableFromQuota`) + pool balance (`spendableFromPool`). */
132
+ bucketTotal: number;
133
+ /** Per-window quota view for this (user, bucket); omitted for a pool-only
134
+ * bucket (no active subscription / free-periodic quota entitlement). */
135
+ quotaWindows?: QuotaWindowView[];
136
+ /** Window-quota available amount = minimum `remaining` across
137
+ * `quotaWindows` (the tightest constraint); omitted for pool-only buckets. */
138
+ spendableFromQuota?: number;
139
+ /** Pool-side balance sum (topup + registration + granted credit types) for
140
+ * this bucket; omitted for window-only buckets with no pool balance. */
141
+ spendableFromPool?: number;
142
+ }
143
+ interface AdminUserSdkInput {
144
+ email: string;
145
+ password: string;
146
+ }
147
+ /** Request body for creating a realm. */
148
+ interface CreateRealmSdkRequest {
149
+ name: string;
150
+ description?: string | null;
151
+ adminUser: AdminUserSdkInput;
152
+ }
153
+ interface AdminUserSdkOutput {
154
+ id: string;
155
+ email: string;
156
+ role: string;
157
+ }
158
+ /** Realm detail (create/get response). */
159
+ interface RealmInfo {
160
+ id: string;
161
+ name: string;
162
+ description: string | null;
163
+ adminUser: AdminUserSdkOutput | null;
164
+ createdAt: string;
165
+ updatedAt: string;
166
+ }
167
+ /** Realm list item. */
168
+ interface RealmItem {
169
+ id: string;
170
+ name: string;
171
+ description: string | null;
172
+ createdAt: string;
173
+ updatedAt: string;
174
+ }
175
+ /** Request body for creating a user. */
176
+ interface CreateUserSdkRequest {
177
+ email: string;
178
+ password: string;
179
+ nickname?: string | null;
180
+ }
181
+ /** User info (create/get/list response). */
182
+ interface UserInfo {
183
+ id: string;
184
+ email: string;
185
+ nickname: string | null;
186
+ status: number;
187
+ createdAt: string;
188
+ }
189
+ /** Request body for creating a client app. */
190
+ interface CreateClientAppSdkRequest {
191
+ name: string;
192
+ description?: string | null;
193
+ redirectUris: string[];
194
+ }
195
+ /** Client app detail (create/get response). */
196
+ interface ClientAppInfo {
197
+ id: string;
198
+ clientId: string;
199
+ clientSecret: string | null;
200
+ name: string;
201
+ description: string | null;
202
+ redirectUris: string[];
203
+ enabled: boolean;
204
+ createdAt: string;
205
+ }
206
+ /** Client app list item. */
207
+ interface ClientAppItem {
208
+ id: string;
209
+ clientId: string;
210
+ name: string;
211
+ enabled: boolean;
212
+ createdAt: string;
213
+ }
214
+
215
+ /**
216
+ * Herald server SDK client — a 1:1 TypeScript port of the Rust `herald-sdk`
217
+ * crate (`sdk/rust/src/lib.rs`), which is this SDK's source of truth.
218
+ *
219
+ * Auth model: every call carries the realm/service `X-API-Key` header against
220
+ * the external API surface (`/api/ext/*`). `checkPermission` additionally
221
+ * mirrors the Rust caching behaviour exactly:
222
+ *
223
+ * - a TTL cache keyed by the full request (token + clientId + rules);
224
+ * - a token→keys index so `invalidateCache(token)` drops every cached check
225
+ * for that token;
226
+ * - a 300s "token snapshot is stale" heuristic: once a token has been seen
227
+ * at least once more than 5 minutes ago, its cached entries are
228
+ * invalidated before the next check (Rust `is_token_expired`).
229
+ *
230
+ * HTTP layer: native `fetch` (Node 18+), hand-rolled — same deliberate choice
231
+ * as the Rust crate (no OpenAPI-generated client), keeping the package at zero
232
+ * runtime dependencies and its types in lockstep with the crate's.
233
+ */
234
+
235
+ declare class HeraldClient {
236
+ private readonly baseUrl;
237
+ private readonly apiKey;
238
+ private readonly cacheTtlMs;
239
+ private readonly permissionCache;
240
+ /** token → cache keys for its checks (Rust `token_index: DashMap`). */
241
+ private readonly tokenIndex;
242
+ /** token → last successful check timestamp (Rust `token_cache`; the Rust
243
+ * tuple also stored the response, but only the timestamp is ever read). */
244
+ private readonly tokenLastSeen;
245
+ constructor(baseUrl: string, apiKey: string, cacheTtlSeconds?: number);
246
+ private requestJson;
247
+ /**
248
+ * Check whether a user (identified by their browser access token) is allowed
249
+ * an action. Results are cached per exact request for the client's TTL;
250
+ * a token not checked for over 5 minutes has its cache invalidated first.
251
+ */
252
+ checkPermission(req: PermissionCheckRequest): Promise<PermissionCheckResponse>;
253
+ /** Rust `is_token_expired`: seen before, and more than 5 minutes ago. */
254
+ private isTokenExpired;
255
+ /** Cached response for an exact request, lazily evicting expired entries
256
+ * (the Map counterpart of moka's TTL + eviction listener). */
257
+ private getCached;
258
+ private dropIndexEntry;
259
+ /** Drop every cached permission check for a token (e.g. after you know the
260
+ * user's permissions changed). Unlike the Rust crate this is synchronous —
261
+ * `await` it if you prefer; the return value is `void` either way. */
262
+ invalidateCache(token: string): void;
263
+ /** Subscription detail for a client app. */
264
+ getSubscription(realmId: string, clientAppId: string): Promise<SubscriptionDetail>;
265
+ /** User points balance. */
266
+ getBalance(realmId: string, userId: string): Promise<PointsBalanceResponse>;
267
+ /** Consume points from a user's account. `idempotencyKey` prevents double
268
+ * charges when the same logical consume is retried. */
269
+ consumePoints(realmId: string, userId: string, clientAppId: string, amount: number, description?: string, idempotencyKey?: string): Promise<ConsumePointsResponse>;
270
+ /** Grant points to a user. `bucketId` is REQUIRED: every grant must target
271
+ * an explicit Credit Bucket. `reason` must be non-empty; `validityDays`
272
+ * omitted means permanent. */
273
+ grantPoints(realmId: string, userId: string, bucketId: string, amount: number, reason: string, validityDays?: number): Promise<GrantPointsResponse>;
274
+ createRealm(request: CreateRealmSdkRequest): Promise<RealmInfo>;
275
+ listRealms(): Promise<RealmItem[]>;
276
+ getRealm(realmId: string): Promise<RealmInfo>;
277
+ createUser(realmId: string, request: CreateUserSdkRequest): Promise<UserInfo>;
278
+ listUsers(realmId: string): Promise<UserInfo[]>;
279
+ getUser(realmId: string, userId: string): Promise<UserInfo>;
280
+ createClientApp(realmId: string, request: CreateClientAppSdkRequest): Promise<ClientAppInfo>;
281
+ listClientApps(realmId: string): Promise<ClientAppItem[]>;
282
+ getClientApp(realmId: string, clientAppId: string): Promise<ClientAppInfo>;
283
+ }
284
+
285
+ /**
286
+ * Error type for the Herald Node SDK, mirroring the Rust `herald_sdk::Error`
287
+ * variants (`sdk/rust/src/lib.rs`): every non-2xx or transport failure is
288
+ * thrown as a `HeraldSdkError` carrying a stable machine-readable `code`.
289
+ */
290
+ type HeraldSdkErrorCode =
291
+ /** Fetch-level transport failure (Rust: `Error::Reqwest`). */
292
+ 'network'
293
+ /** 401 — invalid API key (Rust: `Error::Unauthorized`). */
294
+ | 'unauthorized'
295
+ /** 403 — e.g. cross-realm access or insufficient permission (Rust: `Error::Forbidden`). */
296
+ | 'forbidden'
297
+ /** 404 (Rust: `Error::NotFound`). */
298
+ | 'not-found'
299
+ /** 500 (Rust: `Error::InternalServerError`). */
300
+ | 'internal-server-error'
301
+ /** Any other non-2xx status (Rust: `Error::ApiError`). */
302
+ | 'api-error'
303
+ /** 2xx with a non-JSON body (Rust: `Error::SerdeJson`). */
304
+ | 'parse';
305
+ declare class HeraldSdkError extends Error {
306
+ /** Stable machine-readable category; prefer this over string-matching the message. */
307
+ readonly code: HeraldSdkErrorCode;
308
+ /** HTTP status when the error came from a response; undefined for network errors. */
309
+ readonly status?: number;
310
+ /** Raw response body text when the error came from a response. */
311
+ readonly body?: string;
312
+ constructor(code: HeraldSdkErrorCode, status: number | undefined, body: string | undefined);
313
+ }
314
+
315
+ export { type AdminUserSdkInput, type AdminUserSdkOutput, type AllocationDetail, type BalancesByType, type BucketTransaction, type ClientAppInfo, type ClientAppItem, type ConsumePointsResponse, type CreateClientAppSdkRequest, type CreateRealmSdkRequest, type CreateUserSdkRequest, type GrantPointsResponse, HeraldClient, HeraldSdkError, type HeraldSdkErrorCode, type PermissionCheckRequest, type PermissionCheckResponse, type PointsBalanceResponse, type QuotaWindowView, type RealmInfo, type RealmItem, type Rule, type SubscriptionDetail, type UserInfo, type WalletByBucket };
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Wire types for the Herald external API (`/api/ext/*`).
3
+ *
4
+ * Ported 1:1 from the Rust `herald-sdk` crate (`sdk/rust/src/lib.rs`), which
5
+ * is the source of truth for this SDK: field names follow the backend's
6
+ * camelCase JSON contract, optionality mirrors the Rust `Option` fields (fields
7
+ * the backend always emits as `null` are typed `| null`; fields the backend
8
+ * omits when absent are optional `?`).
9
+ */
10
+ interface Rule {
11
+ resource: string;
12
+ action: string;
13
+ }
14
+ /** `POST /api/ext/permission/check` request. */
15
+ interface PermissionCheckRequest {
16
+ /** Browser access token issued by `/api/auth/{realmId}/login`. */
17
+ accessToken: string;
18
+ rules?: Rule[];
19
+ clientId: string;
20
+ }
21
+ interface PermissionCheckResponse {
22
+ allowed: boolean;
23
+ userId?: string;
24
+ }
25
+ interface SubscriptionDetail {
26
+ id: string;
27
+ clientAppId: string | null;
28
+ status: string;
29
+ entitlementKey: string;
30
+ paymentProvider: string;
31
+ /** Provider price id bound to this subscription; omitted for price-less
32
+ * providers (Creem) or when the subscription has no bound price yet. */
33
+ externalPriceId?: string;
34
+ currentPeriodStart: string | null;
35
+ currentPeriodEnd: string | null;
36
+ cancelAt: string | null;
37
+ cancelAtPeriodEnd: boolean | null;
38
+ createdAt: string;
39
+ updatedAt: string;
40
+ }
41
+ interface PointsBalanceResponse {
42
+ userId: string;
43
+ balance: number;
44
+ totalPaidGranted?: number;
45
+ totalRecharged: number;
46
+ totalConsumed: number;
47
+ unit: string;
48
+ updatedAt: string;
49
+ }
50
+ /** Per-bucket transaction inside a multi-bucket consume response.
51
+ *
52
+ * Single-pool consume → `transactions` has length 1 (structure unified with
53
+ * the multi-bucket case). `amount` is the deduction magnitude (positive). */
54
+ interface BucketTransaction {
55
+ transactionId: string;
56
+ bucketId: string;
57
+ walletId: string;
58
+ userId: string;
59
+ amount: number;
60
+ balanceAfter: number;
61
+ }
62
+ /** Ledger-level allocation detail for a consume. */
63
+ interface AllocationDetail {
64
+ bucketId: string;
65
+ walletId: string;
66
+ ledgerId: string;
67
+ creditType: string;
68
+ allocatedAmount: number;
69
+ }
70
+ /** Points consume response (per-bucket multi-transaction shape). */
71
+ interface ConsumePointsResponse {
72
+ userId: string;
73
+ amount: number;
74
+ correlationId: string;
75
+ transactions: BucketTransaction[];
76
+ allocations: AllocationDetail[];
77
+ }
78
+ /** Points grant response. */
79
+ interface GrantPointsResponse {
80
+ transactionId: string;
81
+ userId: string;
82
+ bucketId: string;
83
+ amount: number;
84
+ grantedBalance: number;
85
+ balance: number;
86
+ expiresAt?: string;
87
+ }
88
+ /** Per-credit-type balances (`balancesByType`). */
89
+ interface BalancesByType {
90
+ topup?: number;
91
+ subscription?: number;
92
+ registration?: number;
93
+ freePeriodic?: number;
94
+ granted?: number;
95
+ }
96
+ /** Quota window read view (`QuotaWindowView`).
97
+ *
98
+ * One row per distinct window `key` for a (user, bucket). `key` is the stable
99
+ * display identity derived from the window length (e.g. `5h`/`week`/`month`),
100
+ * NOT a row ordinal. `isTightest` flags the minimum-remaining window (the
101
+ * spendable-from-quota constraint); `exhausted` flags `remaining == 0`.
102
+ * `resetsAt` is an ISO8601 string (matches the SDK's string-date convention). */
103
+ interface QuotaWindowView {
104
+ /** Stable display key (config-derived, not row ordinal). */
105
+ key: string;
106
+ limit: number;
107
+ used: number;
108
+ remaining: number;
109
+ /** Sliding window length in seconds (month ≈ 30d). */
110
+ windowSeconds: number;
111
+ /** Approximate next reset point of the window (ISO8601); omitted when no
112
+ * consume has occurred in the window yet. */
113
+ resetsAt?: string;
114
+ /** True if this window is the minimum-remaining (tightest) constraint. */
115
+ isTightest: boolean;
116
+ /** True if `remaining == 0`. */
117
+ exhausted: boolean;
118
+ }
119
+ /** Wallet balances grouped by Credit Bucket (`WalletByBucket`).
120
+ *
121
+ * For the admin (`billing/points/wallets`) view, `userId` is populated and
122
+ * rows group per `(user, bucket)`; for the `users/me/points/wallets` view,
123
+ * `userId` is the calling user. */
124
+ interface WalletByBucket {
125
+ bucketId?: string | null;
126
+ name?: string | null;
127
+ enabled?: boolean | null;
128
+ userId: string;
129
+ balancesByType: BalancesByType;
130
+ /** Currently spendable total for this bucket = window-available
131
+ * (`spendableFromQuota`) + pool balance (`spendableFromPool`). */
132
+ bucketTotal: number;
133
+ /** Per-window quota view for this (user, bucket); omitted for a pool-only
134
+ * bucket (no active subscription / free-periodic quota entitlement). */
135
+ quotaWindows?: QuotaWindowView[];
136
+ /** Window-quota available amount = minimum `remaining` across
137
+ * `quotaWindows` (the tightest constraint); omitted for pool-only buckets. */
138
+ spendableFromQuota?: number;
139
+ /** Pool-side balance sum (topup + registration + granted credit types) for
140
+ * this bucket; omitted for window-only buckets with no pool balance. */
141
+ spendableFromPool?: number;
142
+ }
143
+ interface AdminUserSdkInput {
144
+ email: string;
145
+ password: string;
146
+ }
147
+ /** Request body for creating a realm. */
148
+ interface CreateRealmSdkRequest {
149
+ name: string;
150
+ description?: string | null;
151
+ adminUser: AdminUserSdkInput;
152
+ }
153
+ interface AdminUserSdkOutput {
154
+ id: string;
155
+ email: string;
156
+ role: string;
157
+ }
158
+ /** Realm detail (create/get response). */
159
+ interface RealmInfo {
160
+ id: string;
161
+ name: string;
162
+ description: string | null;
163
+ adminUser: AdminUserSdkOutput | null;
164
+ createdAt: string;
165
+ updatedAt: string;
166
+ }
167
+ /** Realm list item. */
168
+ interface RealmItem {
169
+ id: string;
170
+ name: string;
171
+ description: string | null;
172
+ createdAt: string;
173
+ updatedAt: string;
174
+ }
175
+ /** Request body for creating a user. */
176
+ interface CreateUserSdkRequest {
177
+ email: string;
178
+ password: string;
179
+ nickname?: string | null;
180
+ }
181
+ /** User info (create/get/list response). */
182
+ interface UserInfo {
183
+ id: string;
184
+ email: string;
185
+ nickname: string | null;
186
+ status: number;
187
+ createdAt: string;
188
+ }
189
+ /** Request body for creating a client app. */
190
+ interface CreateClientAppSdkRequest {
191
+ name: string;
192
+ description?: string | null;
193
+ redirectUris: string[];
194
+ }
195
+ /** Client app detail (create/get response). */
196
+ interface ClientAppInfo {
197
+ id: string;
198
+ clientId: string;
199
+ clientSecret: string | null;
200
+ name: string;
201
+ description: string | null;
202
+ redirectUris: string[];
203
+ enabled: boolean;
204
+ createdAt: string;
205
+ }
206
+ /** Client app list item. */
207
+ interface ClientAppItem {
208
+ id: string;
209
+ clientId: string;
210
+ name: string;
211
+ enabled: boolean;
212
+ createdAt: string;
213
+ }
214
+
215
+ /**
216
+ * Herald server SDK client — a 1:1 TypeScript port of the Rust `herald-sdk`
217
+ * crate (`sdk/rust/src/lib.rs`), which is this SDK's source of truth.
218
+ *
219
+ * Auth model: every call carries the realm/service `X-API-Key` header against
220
+ * the external API surface (`/api/ext/*`). `checkPermission` additionally
221
+ * mirrors the Rust caching behaviour exactly:
222
+ *
223
+ * - a TTL cache keyed by the full request (token + clientId + rules);
224
+ * - a token→keys index so `invalidateCache(token)` drops every cached check
225
+ * for that token;
226
+ * - a 300s "token snapshot is stale" heuristic: once a token has been seen
227
+ * at least once more than 5 minutes ago, its cached entries are
228
+ * invalidated before the next check (Rust `is_token_expired`).
229
+ *
230
+ * HTTP layer: native `fetch` (Node 18+), hand-rolled — same deliberate choice
231
+ * as the Rust crate (no OpenAPI-generated client), keeping the package at zero
232
+ * runtime dependencies and its types in lockstep with the crate's.
233
+ */
234
+
235
+ declare class HeraldClient {
236
+ private readonly baseUrl;
237
+ private readonly apiKey;
238
+ private readonly cacheTtlMs;
239
+ private readonly permissionCache;
240
+ /** token → cache keys for its checks (Rust `token_index: DashMap`). */
241
+ private readonly tokenIndex;
242
+ /** token → last successful check timestamp (Rust `token_cache`; the Rust
243
+ * tuple also stored the response, but only the timestamp is ever read). */
244
+ private readonly tokenLastSeen;
245
+ constructor(baseUrl: string, apiKey: string, cacheTtlSeconds?: number);
246
+ private requestJson;
247
+ /**
248
+ * Check whether a user (identified by their browser access token) is allowed
249
+ * an action. Results are cached per exact request for the client's TTL;
250
+ * a token not checked for over 5 minutes has its cache invalidated first.
251
+ */
252
+ checkPermission(req: PermissionCheckRequest): Promise<PermissionCheckResponse>;
253
+ /** Rust `is_token_expired`: seen before, and more than 5 minutes ago. */
254
+ private isTokenExpired;
255
+ /** Cached response for an exact request, lazily evicting expired entries
256
+ * (the Map counterpart of moka's TTL + eviction listener). */
257
+ private getCached;
258
+ private dropIndexEntry;
259
+ /** Drop every cached permission check for a token (e.g. after you know the
260
+ * user's permissions changed). Unlike the Rust crate this is synchronous —
261
+ * `await` it if you prefer; the return value is `void` either way. */
262
+ invalidateCache(token: string): void;
263
+ /** Subscription detail for a client app. */
264
+ getSubscription(realmId: string, clientAppId: string): Promise<SubscriptionDetail>;
265
+ /** User points balance. */
266
+ getBalance(realmId: string, userId: string): Promise<PointsBalanceResponse>;
267
+ /** Consume points from a user's account. `idempotencyKey` prevents double
268
+ * charges when the same logical consume is retried. */
269
+ consumePoints(realmId: string, userId: string, clientAppId: string, amount: number, description?: string, idempotencyKey?: string): Promise<ConsumePointsResponse>;
270
+ /** Grant points to a user. `bucketId` is REQUIRED: every grant must target
271
+ * an explicit Credit Bucket. `reason` must be non-empty; `validityDays`
272
+ * omitted means permanent. */
273
+ grantPoints(realmId: string, userId: string, bucketId: string, amount: number, reason: string, validityDays?: number): Promise<GrantPointsResponse>;
274
+ createRealm(request: CreateRealmSdkRequest): Promise<RealmInfo>;
275
+ listRealms(): Promise<RealmItem[]>;
276
+ getRealm(realmId: string): Promise<RealmInfo>;
277
+ createUser(realmId: string, request: CreateUserSdkRequest): Promise<UserInfo>;
278
+ listUsers(realmId: string): Promise<UserInfo[]>;
279
+ getUser(realmId: string, userId: string): Promise<UserInfo>;
280
+ createClientApp(realmId: string, request: CreateClientAppSdkRequest): Promise<ClientAppInfo>;
281
+ listClientApps(realmId: string): Promise<ClientAppItem[]>;
282
+ getClientApp(realmId: string, clientAppId: string): Promise<ClientAppInfo>;
283
+ }
284
+
285
+ /**
286
+ * Error type for the Herald Node SDK, mirroring the Rust `herald_sdk::Error`
287
+ * variants (`sdk/rust/src/lib.rs`): every non-2xx or transport failure is
288
+ * thrown as a `HeraldSdkError` carrying a stable machine-readable `code`.
289
+ */
290
+ type HeraldSdkErrorCode =
291
+ /** Fetch-level transport failure (Rust: `Error::Reqwest`). */
292
+ 'network'
293
+ /** 401 — invalid API key (Rust: `Error::Unauthorized`). */
294
+ | 'unauthorized'
295
+ /** 403 — e.g. cross-realm access or insufficient permission (Rust: `Error::Forbidden`). */
296
+ | 'forbidden'
297
+ /** 404 (Rust: `Error::NotFound`). */
298
+ | 'not-found'
299
+ /** 500 (Rust: `Error::InternalServerError`). */
300
+ | 'internal-server-error'
301
+ /** Any other non-2xx status (Rust: `Error::ApiError`). */
302
+ | 'api-error'
303
+ /** 2xx with a non-JSON body (Rust: `Error::SerdeJson`). */
304
+ | 'parse';
305
+ declare class HeraldSdkError extends Error {
306
+ /** Stable machine-readable category; prefer this over string-matching the message. */
307
+ readonly code: HeraldSdkErrorCode;
308
+ /** HTTP status when the error came from a response; undefined for network errors. */
309
+ readonly status?: number;
310
+ /** Raw response body text when the error came from a response. */
311
+ readonly body?: string;
312
+ constructor(code: HeraldSdkErrorCode, status: number | undefined, body: string | undefined);
313
+ }
314
+
315
+ export { type AdminUserSdkInput, type AdminUserSdkOutput, type AllocationDetail, type BalancesByType, type BucketTransaction, type ClientAppInfo, type ClientAppItem, type ConsumePointsResponse, type CreateClientAppSdkRequest, type CreateRealmSdkRequest, type CreateUserSdkRequest, type GrantPointsResponse, HeraldClient, HeraldSdkError, type HeraldSdkErrorCode, type PermissionCheckRequest, type PermissionCheckResponse, type PointsBalanceResponse, type QuotaWindowView, type RealmInfo, type RealmItem, type Rule, type SubscriptionDetail, type UserInfo, type WalletByBucket };
package/dist/index.js ADDED
@@ -0,0 +1,225 @@
1
+ // src/errors.ts
2
+ var HeraldSdkError = class extends Error {
3
+ /** Stable machine-readable category; prefer this over string-matching the message. */
4
+ code;
5
+ /** HTTP status when the error came from a response; undefined for network errors. */
6
+ status;
7
+ /** Raw response body text when the error came from a response. */
8
+ body;
9
+ constructor(code, status, body) {
10
+ const label = status !== void 0 ? `${code} (${status})` : code;
11
+ super(body ? `Herald SDK error: ${label}: ${body}` : `Herald SDK error: ${label}`);
12
+ this.name = "HeraldSdkError";
13
+ this.code = code;
14
+ this.status = status;
15
+ this.body = body;
16
+ }
17
+ };
18
+
19
+ // src/client.ts
20
+ var TOKEN_EXPIRY_THRESHOLD_MS = 3e5;
21
+ function permissionCacheKey(req) {
22
+ return JSON.stringify({ accessToken: req.accessToken, clientId: req.clientId, rules: req.rules });
23
+ }
24
+ async function handleResponse(response) {
25
+ const text = await response.text();
26
+ const status = response.status;
27
+ if (status === 401) throw new HeraldSdkError("unauthorized", status, text);
28
+ if (status === 403) throw new HeraldSdkError("forbidden", status, text);
29
+ if (status === 404) throw new HeraldSdkError("not-found", status, text);
30
+ if (status === 500) throw new HeraldSdkError("internal-server-error", status, text);
31
+ if (status >= 200 && status < 300) {
32
+ try {
33
+ return JSON.parse(text);
34
+ } catch (cause) {
35
+ throw new HeraldSdkError("parse", status, `invalid JSON body: ${String(cause)}`);
36
+ }
37
+ }
38
+ throw new HeraldSdkError("api-error", status, text);
39
+ }
40
+ var HeraldClient = class {
41
+ baseUrl;
42
+ apiKey;
43
+ cacheTtlMs;
44
+ permissionCache = /* @__PURE__ */ new Map();
45
+ /** token → cache keys for its checks (Rust `token_index: DashMap`). */
46
+ tokenIndex = /* @__PURE__ */ new Map();
47
+ /** token → last successful check timestamp (Rust `token_cache`; the Rust
48
+ * tuple also stored the response, but only the timestamp is ever read). */
49
+ tokenLastSeen = /* @__PURE__ */ new Map();
50
+ constructor(baseUrl, apiKey, cacheTtlSeconds) {
51
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
52
+ this.apiKey = apiKey;
53
+ this.cacheTtlMs = (cacheTtlSeconds ?? 300) * 1e3;
54
+ }
55
+ async requestJson(method, path, options = {}) {
56
+ let url = `${this.baseUrl}${path}`;
57
+ if (options.query) {
58
+ const search = new URLSearchParams(options.query).toString();
59
+ if (search) url += `?${search}`;
60
+ }
61
+ const headers = { "X-API-Key": this.apiKey };
62
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
63
+ let response;
64
+ try {
65
+ response = await fetch(url, {
66
+ method,
67
+ headers,
68
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
69
+ });
70
+ } catch (cause) {
71
+ throw new HeraldSdkError("network", void 0, String(cause));
72
+ }
73
+ return handleResponse(response);
74
+ }
75
+ // --- Permission check (with cache) ---
76
+ /**
77
+ * Check whether a user (identified by their browser access token) is allowed
78
+ * an action. Results are cached per exact request for the client's TTL;
79
+ * a token not checked for over 5 minutes has its cache invalidated first.
80
+ */
81
+ async checkPermission(req) {
82
+ if (this.isTokenExpired(req.accessToken)) {
83
+ this.invalidateCache(req.accessToken);
84
+ }
85
+ const cached = this.getCached(req);
86
+ if (cached) return cached;
87
+ const response = await this.requestJson("POST", "/api/ext/permission/check", {
88
+ body: req
89
+ });
90
+ const now = Date.now();
91
+ this.tokenLastSeen.set(req.accessToken, now);
92
+ const key = permissionCacheKey(req);
93
+ let keys = this.tokenIndex.get(req.accessToken);
94
+ if (!keys) {
95
+ keys = /* @__PURE__ */ new Set();
96
+ this.tokenIndex.set(req.accessToken, keys);
97
+ }
98
+ keys.add(key);
99
+ this.permissionCache.set(key, { response, expiresAtMs: now + this.cacheTtlMs });
100
+ return response;
101
+ }
102
+ /** Rust `is_token_expired`: seen before, and more than 5 minutes ago. */
103
+ isTokenExpired(token) {
104
+ const at = this.tokenLastSeen.get(token);
105
+ return at !== void 0 && Date.now() - at > TOKEN_EXPIRY_THRESHOLD_MS;
106
+ }
107
+ /** Cached response for an exact request, lazily evicting expired entries
108
+ * (the Map counterpart of moka's TTL + eviction listener). */
109
+ getCached(req) {
110
+ const key = permissionCacheKey(req);
111
+ const entry = this.permissionCache.get(key);
112
+ if (!entry) return void 0;
113
+ if (Date.now() > entry.expiresAtMs) {
114
+ this.permissionCache.delete(key);
115
+ this.dropIndexEntry(req.accessToken, key);
116
+ return void 0;
117
+ }
118
+ return entry.response;
119
+ }
120
+ dropIndexEntry(token, key) {
121
+ const keys = this.tokenIndex.get(token);
122
+ if (!keys) return;
123
+ keys.delete(key);
124
+ if (keys.size === 0) this.tokenIndex.delete(token);
125
+ }
126
+ /** Drop every cached permission check for a token (e.g. after you know the
127
+ * user's permissions changed). Unlike the Rust crate this is synchronous —
128
+ * `await` it if you prefer; the return value is `void` either way. */
129
+ invalidateCache(token) {
130
+ const keys = this.tokenIndex.get(token);
131
+ if (!keys) return;
132
+ for (const key of keys) this.permissionCache.delete(key);
133
+ this.tokenIndex.delete(token);
134
+ }
135
+ // --- Billing ---
136
+ /** Subscription detail for a client app. */
137
+ getSubscription(realmId, clientAppId) {
138
+ return this.requestJson("GET", `/api/ext/bill/${encodeURIComponent(realmId)}/client/${encodeURIComponent(clientAppId)}/subscription`);
139
+ }
140
+ // --- Points ---
141
+ /** User points balance. */
142
+ getBalance(realmId, userId) {
143
+ return this.requestJson("GET", `/api/ext/points/${encodeURIComponent(realmId)}/balance`, {
144
+ query: { userId }
145
+ });
146
+ }
147
+ /** Consume points from a user's account. `idempotencyKey` prevents double
148
+ * charges when the same logical consume is retried. */
149
+ consumePoints(realmId, userId, clientAppId, amount, description, idempotencyKey) {
150
+ return this.requestJson("POST", `/api/ext/points/${encodeURIComponent(realmId)}/consume`, {
151
+ body: {
152
+ userId,
153
+ clientAppId,
154
+ amount,
155
+ description,
156
+ idempotencyKey
157
+ }
158
+ });
159
+ }
160
+ /** Grant points to a user. `bucketId` is REQUIRED: every grant must target
161
+ * an explicit Credit Bucket. `reason` must be non-empty; `validityDays`
162
+ * omitted means permanent. */
163
+ grantPoints(realmId, userId, bucketId, amount, reason, validityDays) {
164
+ return this.requestJson("POST", `/api/ext/points/${encodeURIComponent(realmId)}/grant`, {
165
+ body: {
166
+ userId,
167
+ bucketId,
168
+ amount,
169
+ reason,
170
+ validityDays
171
+ }
172
+ });
173
+ }
174
+ // --- Realms ---
175
+ createRealm(request) {
176
+ return this.requestJson("POST", "/api/ext/realms", { body: request });
177
+ }
178
+ async listRealms() {
179
+ const body = await this.requestJson("GET", "/api/ext/realms");
180
+ return body.realms;
181
+ }
182
+ getRealm(realmId) {
183
+ return this.requestJson("GET", `/api/ext/realms/${encodeURIComponent(realmId)}`);
184
+ }
185
+ // --- Users ---
186
+ createUser(realmId, request) {
187
+ return this.requestJson("POST", `/api/ext/realms/${encodeURIComponent(realmId)}/users`, { body: request });
188
+ }
189
+ async listUsers(realmId) {
190
+ const body = await this.requestJson(
191
+ "GET",
192
+ `/api/ext/realms/${encodeURIComponent(realmId)}/users`
193
+ );
194
+ return body.items;
195
+ }
196
+ getUser(realmId, userId) {
197
+ return this.requestJson(
198
+ "GET",
199
+ `/api/ext/realms/${encodeURIComponent(realmId)}/users/${encodeURIComponent(userId)}`
200
+ );
201
+ }
202
+ // --- Client apps ---
203
+ createClientApp(realmId, request) {
204
+ return this.requestJson("POST", `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`, {
205
+ body: request
206
+ });
207
+ }
208
+ async listClientApps(realmId) {
209
+ const body = await this.requestJson(
210
+ "GET",
211
+ `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`
212
+ );
213
+ return body.clientApps;
214
+ }
215
+ getClientApp(realmId, clientAppId) {
216
+ return this.requestJson(
217
+ "GET",
218
+ `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps/${encodeURIComponent(clientAppId)}`
219
+ );
220
+ }
221
+ };
222
+
223
+ export { HeraldClient, HeraldSdkError };
224
+ //# sourceMappingURL=index.js.map
225
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/client.ts"],"names":[],"mappings":";AAsBO,IAAM,cAAA,GAAN,cAA6B,KAAA,CAAM;AAAA;AAAA,EAE/B,IAAA;AAAA;AAAA,EAEA,MAAA;AAAA;AAAA,EAEA,IAAA;AAAA,EAET,WAAA,CAAY,IAAA,EAA0B,MAAA,EAA4B,IAAA,EAA0B;AAC1F,IAAA,MAAM,QAAQ,MAAA,KAAW,MAAA,GAAY,GAAG,IAAI,CAAA,EAAA,EAAK,MAAM,CAAA,CAAA,CAAA,GAAM,IAAA;AAC7D,IAAA,KAAA,CAAM,IAAA,GAAO,qBAAqB,KAAK,CAAA,EAAA,EAAK,IAAI,CAAA,CAAA,GAAK,CAAA,kBAAA,EAAqB,KAAK,CAAA,CAAE,CAAA;AACjF,IAAA,IAAA,CAAK,IAAA,GAAO,gBAAA;AACZ,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAAA,EACd;AACF;;;ACCA,IAAM,yBAAA,GAA4B,GAAA;AAalC,SAAS,mBAAmB,GAAA,EAAqC;AAC/D,EAAA,OAAO,IAAA,CAAK,SAAA,CAAU,EAAE,WAAA,EAAa,GAAA,CAAI,WAAA,EAAa,QAAA,EAAU,GAAA,CAAI,QAAA,EAAU,KAAA,EAAO,GAAA,CAAI,KAAA,EAAO,CAAA;AAClG;AAGA,eAAe,eAAkB,QAAA,EAAgC;AAC/D,EAAA,MAAM,IAAA,GAAO,MAAM,QAAA,CAAS,IAAA,EAAK;AACjC,EAAA,MAAM,SAAS,QAAA,CAAS,MAAA;AACxB,EAAA,IAAI,WAAW,GAAA,EAAK,MAAM,IAAI,cAAA,CAAe,cAAA,EAAgB,QAAQ,IAAI,CAAA;AACzE,EAAA,IAAI,WAAW,GAAA,EAAK,MAAM,IAAI,cAAA,CAAe,WAAA,EAAa,QAAQ,IAAI,CAAA;AACtE,EAAA,IAAI,WAAW,GAAA,EAAK,MAAM,IAAI,cAAA,CAAe,WAAA,EAAa,QAAQ,IAAI,CAAA;AACtE,EAAA,IAAI,WAAW,GAAA,EAAK,MAAM,IAAI,cAAA,CAAe,uBAAA,EAAyB,QAAQ,IAAI,CAAA;AAClF,EAAA,IAAI,MAAA,IAAU,GAAA,IAAO,MAAA,GAAS,GAAA,EAAK;AACjC,IAAA,IAAI;AACF,MAAA,OAAO,IAAA,CAAK,MAAM,IAAI,CAAA;AAAA,IACxB,SAAS,KAAA,EAAO;AACd,MAAA,MAAM,IAAI,eAAe,OAAA,EAAS,MAAA,EAAQ,sBAAsB,MAAA,CAAO,KAAK,CAAC,CAAA,CAAE,CAAA;AAAA,IACjF;AAAA,EACF;AACA,EAAA,MAAM,IAAI,cAAA,CAAe,WAAA,EAAa,MAAA,EAAQ,IAAI,CAAA;AACpD;AAOO,IAAM,eAAN,MAAmB;AAAA,EACP,OAAA;AAAA,EACA,MAAA;AAAA,EACA,UAAA;AAAA,EACA,eAAA,uBAAsB,GAAA,EAAwB;AAAA;AAAA,EAE9C,UAAA,uBAAiB,GAAA,EAAyB;AAAA;AAAA;AAAA,EAG1C,aAAA,uBAAoB,GAAA,EAAoB;AAAA,EAEzD,WAAA,CAAY,OAAA,EAAiB,MAAA,EAAgB,eAAA,EAA0B;AACrE,IAAA,IAAA,CAAK,OAAA,GAAU,OAAA,CAAQ,OAAA,CAAQ,MAAA,EAAQ,EAAE,CAAA;AACzC,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,UAAA,GAAA,CAAc,mBAAmB,GAAA,IAAO,GAAA;AAAA,EAC/C;AAAA,EAEA,MAAc,WAAA,CAAe,MAAA,EAAwB,IAAA,EAAc,OAAA,GAA0B,EAAC,EAAe;AAC3G,IAAA,IAAI,GAAA,GAAM,CAAA,EAAG,IAAA,CAAK,OAAO,GAAG,IAAI,CAAA,CAAA;AAChC,IAAA,IAAI,QAAQ,KAAA,EAAO;AACjB,MAAA,MAAM,SAAS,IAAI,eAAA,CAAgB,OAAA,CAAQ,KAAK,EAAE,QAAA,EAAS;AAC3D,MAAA,IAAI,MAAA,EAAQ,GAAA,IAAO,CAAA,CAAA,EAAI,MAAM,CAAA,CAAA;AAAA,IAC/B;AACA,IAAA,MAAM,OAAA,GAAkC,EAAE,WAAA,EAAa,IAAA,CAAK,MAAA,EAAO;AACnE,IAAA,IAAI,OAAA,CAAQ,IAAA,KAAS,MAAA,EAAW,OAAA,CAAQ,cAAc,CAAA,GAAI,kBAAA;AAE1D,IAAA,IAAI,QAAA;AACJ,IAAA,IAAI;AACF,MAAA,QAAA,GAAW,MAAM,MAAM,GAAA,EAAK;AAAA,QAC1B,MAAA;AAAA,QACA,OAAA;AAAA,QACA,IAAA,EAAM,QAAQ,IAAA,KAAS,KAAA,CAAA,GAAY,KAAK,SAAA,CAAU,OAAA,CAAQ,IAAI,CAAA,GAAI,KAAA;AAAA,OACnE,CAAA;AAAA,IACH,SAAS,KAAA,EAAO;AACd,MAAA,MAAM,IAAI,cAAA,CAAe,SAAA,EAAW,MAAA,EAAW,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,IAC9D;AACA,IAAA,OAAO,eAAkB,QAAQ,CAAA;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,gBAAgB,GAAA,EAA+D;AACnF,IAAA,IAAI,IAAA,CAAK,cAAA,CAAe,GAAA,CAAI,WAAW,CAAA,EAAG;AACxC,MAAA,IAAA,CAAK,eAAA,CAAgB,IAAI,WAAW,CAAA;AAAA,IACtC;AAEA,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,SAAA,CAAU,GAAG,CAAA;AACjC,IAAA,IAAI,QAAQ,OAAO,MAAA;AAEnB,IAAA,MAAM,QAAA,GAAW,MAAM,IAAA,CAAK,WAAA,CAAqC,QAAQ,2BAAA,EAA6B;AAAA,MACpG,IAAA,EAAM;AAAA,KACP,CAAA;AAED,IAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,IAAA,IAAA,CAAK,aAAA,CAAc,GAAA,CAAI,GAAA,CAAI,WAAA,EAAa,GAAG,CAAA;AAC3C,IAAA,MAAM,GAAA,GAAM,mBAAmB,GAAG,CAAA;AAClC,IAAA,IAAI,IAAA,GAAO,IAAA,CAAK,UAAA,CAAW,GAAA,CAAI,IAAI,WAAW,CAAA;AAC9C,IAAA,IAAI,CAAC,IAAA,EAAM;AACT,MAAA,IAAA,uBAAW,GAAA,EAAI;AACf,MAAA,IAAA,CAAK,UAAA,CAAW,GAAA,CAAI,GAAA,CAAI,WAAA,EAAa,IAAI,CAAA;AAAA,IAC3C;AACA,IAAA,IAAA,CAAK,IAAI,GAAG,CAAA;AACZ,IAAA,IAAA,CAAK,eAAA,CAAgB,IAAI,GAAA,EAAK,EAAE,UAAU,WAAA,EAAa,GAAA,GAAM,IAAA,CAAK,UAAA,EAAY,CAAA;AAC9E,IAAA,OAAO,QAAA;AAAA,EACT;AAAA;AAAA,EAGQ,eAAe,KAAA,EAAwB;AAC7C,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,aAAA,CAAc,GAAA,CAAI,KAAK,CAAA;AACvC,IAAA,OAAO,EAAA,KAAO,MAAA,IAAa,IAAA,CAAK,GAAA,KAAQ,EAAA,GAAK,yBAAA;AAAA,EAC/C;AAAA;AAAA;AAAA,EAIQ,UAAU,GAAA,EAAkE;AAClF,IAAA,MAAM,GAAA,GAAM,mBAAmB,GAAG,CAAA;AAClC,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,eAAA,CAAgB,GAAA,CAAI,GAAG,CAAA;AAC1C,IAAA,IAAI,CAAC,OAAO,OAAO,MAAA;AACnB,IAAA,IAAI,IAAA,CAAK,GAAA,EAAI,GAAI,KAAA,CAAM,WAAA,EAAa;AAClC,MAAA,IAAA,CAAK,eAAA,CAAgB,OAAO,GAAG,CAAA;AAC/B,MAAA,IAAA,CAAK,cAAA,CAAe,GAAA,CAAI,WAAA,EAAa,GAAG,CAAA;AACxC,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,OAAO,KAAA,CAAM,QAAA;AAAA,EACf;AAAA,EAEQ,cAAA,CAAe,OAAe,GAAA,EAAmB;AACvD,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,UAAA,CAAW,GAAA,CAAI,KAAK,CAAA;AACtC,IAAA,IAAI,CAAC,IAAA,EAAM;AACX,IAAA,IAAA,CAAK,OAAO,GAAG,CAAA;AACf,IAAA,IAAI,KAAK,IAAA,KAAS,CAAA,EAAG,IAAA,CAAK,UAAA,CAAW,OAAO,KAAK,CAAA;AAAA,EACnD;AAAA;AAAA;AAAA;AAAA,EAKA,gBAAgB,KAAA,EAAqB;AACnC,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,UAAA,CAAW,GAAA,CAAI,KAAK,CAAA;AACtC,IAAA,IAAI,CAAC,IAAA,EAAM;AACX,IAAA,KAAA,MAAW,GAAA,IAAO,IAAA,EAAM,IAAA,CAAK,eAAA,CAAgB,OAAO,GAAG,CAAA;AACvD,IAAA,IAAA,CAAK,UAAA,CAAW,OAAO,KAAK,CAAA;AAAA,EAC9B;AAAA;AAAA;AAAA,EAKA,eAAA,CAAgB,SAAiB,WAAA,EAAkD;AACjF,IAAA,OAAO,IAAA,CAAK,WAAA,CAAY,KAAA,EAAO,CAAA,cAAA,EAAiB,kBAAA,CAAmB,OAAO,CAAC,CAAA,QAAA,EAAW,kBAAA,CAAmB,WAAW,CAAC,CAAA,aAAA,CAAe,CAAA;AAAA,EACtI;AAAA;AAAA;AAAA,EAKA,UAAA,CAAW,SAAiB,MAAA,EAAgD;AAC1E,IAAA,OAAO,KAAK,WAAA,CAAY,KAAA,EAAO,mBAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,QAAA,CAAA,EAAY;AAAA,MACvF,KAAA,EAAO,EAAE,MAAA;AAAO,KACjB,CAAA;AAAA,EACH;AAAA;AAAA;AAAA,EAIA,cACE,OAAA,EACA,MAAA,EACA,WAAA,EACA,MAAA,EACA,aACA,cAAA,EACgC;AAChC,IAAA,OAAO,KAAK,WAAA,CAAY,MAAA,EAAQ,mBAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,QAAA,CAAA,EAAY;AAAA,MACxF,IAAA,EAAM;AAAA,QACJ,MAAA;AAAA,QACA,WAAA;AAAA,QACA,MAAA;AAAA,QACA,WAAA;AAAA,QACA;AAAA;AACF,KACD,CAAA;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,YACE,OAAA,EACA,MAAA,EACA,QAAA,EACA,MAAA,EACA,QACA,YAAA,EAC8B;AAC9B,IAAA,OAAO,KAAK,WAAA,CAAY,MAAA,EAAQ,mBAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,MAAA,CAAA,EAAU;AAAA,MACtF,IAAA,EAAM;AAAA,QACJ,MAAA;AAAA,QACA,QAAA;AAAA,QACA,MAAA;AAAA,QACA,MAAA;AAAA,QACA;AAAA;AACF,KACD,CAAA;AAAA,EACH;AAAA;AAAA,EAIA,YAAY,OAAA,EAAoD;AAC9D,IAAA,OAAO,KAAK,WAAA,CAAY,MAAA,EAAQ,mBAAmB,EAAE,IAAA,EAAM,SAAS,CAAA;AAAA,EACtE;AAAA,EAEA,MAAM,UAAA,GAAmC;AACvC,IAAA,MAAM,IAAA,GAAO,MAAM,IAAA,CAAK,WAAA,CAAqC,OAAO,iBAAiB,CAAA;AACrF,IAAA,OAAO,IAAA,CAAK,MAAA;AAAA,EACd;AAAA,EAEA,SAAS,OAAA,EAAqC;AAC5C,IAAA,OAAO,KAAK,WAAA,CAAY,KAAA,EAAO,mBAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,CAAE,CAAA;AAAA,EACjF;AAAA;AAAA,EAIA,UAAA,CAAW,SAAiB,OAAA,EAAkD;AAC5E,IAAA,OAAO,IAAA,CAAK,WAAA,CAAY,MAAA,EAAQ,CAAA,gBAAA,EAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,MAAA,CAAA,EAAU,EAAE,IAAA,EAAM,OAAA,EAAS,CAAA;AAAA,EAC3G;AAAA,EAEA,MAAM,UAAU,OAAA,EAAsC;AACpD,IAAA,MAAM,IAAA,GAAO,MAAM,IAAA,CAAK,WAAA;AAAA,MACtB,KAAA;AAAA,MACA,CAAA,gBAAA,EAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,MAAA;AAAA,KAChD;AACA,IAAA,OAAO,IAAA,CAAK,KAAA;AAAA,EACd;AAAA,EAEA,OAAA,CAAQ,SAAiB,MAAA,EAAmC;AAC1D,IAAA,OAAO,IAAA,CAAK,WAAA;AAAA,MACV,KAAA;AAAA,MACA,mBAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,OAAA,EAAU,kBAAA,CAAmB,MAAM,CAAC,CAAA;AAAA,KACpF;AAAA,EACF;AAAA;AAAA,EAIA,eAAA,CAAgB,SAAiB,OAAA,EAA4D;AAC3F,IAAA,OAAO,KAAK,WAAA,CAAY,MAAA,EAAQ,mBAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,YAAA,CAAA,EAAgB;AAAA,MAC5F,IAAA,EAAM;AAAA,KACP,CAAA;AAAA,EACH;AAAA,EAEA,MAAM,eAAe,OAAA,EAA2C;AAC9D,IAAA,MAAM,IAAA,GAAO,MAAM,IAAA,CAAK,WAAA;AAAA,MACtB,KAAA;AAAA,MACA,CAAA,gBAAA,EAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,YAAA;AAAA,KAChD;AACA,IAAA,OAAO,IAAA,CAAK,UAAA;AAAA,EACd;AAAA,EAEA,YAAA,CAAa,SAAiB,WAAA,EAA6C;AACzE,IAAA,OAAO,IAAA,CAAK,WAAA;AAAA,MACV,KAAA;AAAA,MACA,mBAAmB,kBAAA,CAAmB,OAAO,CAAC,CAAA,aAAA,EAAgB,kBAAA,CAAmB,WAAW,CAAC,CAAA;AAAA,KAC/F;AAAA,EACF;AACF","file":"index.js","sourcesContent":["/**\n * Error type for the Herald Node SDK, mirroring the Rust `herald_sdk::Error`\n * variants (`sdk/rust/src/lib.rs`): every non-2xx or transport failure is\n * thrown as a `HeraldSdkError` carrying a stable machine-readable `code`.\n */\n\nexport type HeraldSdkErrorCode =\n /** Fetch-level transport failure (Rust: `Error::Reqwest`). */\n | 'network'\n /** 401 — invalid API key (Rust: `Error::Unauthorized`). */\n | 'unauthorized'\n /** 403 — e.g. cross-realm access or insufficient permission (Rust: `Error::Forbidden`). */\n | 'forbidden'\n /** 404 (Rust: `Error::NotFound`). */\n | 'not-found'\n /** 500 (Rust: `Error::InternalServerError`). */\n | 'internal-server-error'\n /** Any other non-2xx status (Rust: `Error::ApiError`). */\n | 'api-error'\n /** 2xx with a non-JSON body (Rust: `Error::SerdeJson`). */\n | 'parse'\n\nexport class HeraldSdkError extends Error {\n /** Stable machine-readable category; prefer this over string-matching the message. */\n readonly code: HeraldSdkErrorCode\n /** HTTP status when the error came from a response; undefined for network errors. */\n readonly status?: number\n /** Raw response body text when the error came from a response. */\n readonly body?: string\n\n constructor(code: HeraldSdkErrorCode, status: number | undefined, body: string | undefined) {\n const label = status !== undefined ? `${code} (${status})` : code\n super(body ? `Herald SDK error: ${label}: ${body}` : `Herald SDK error: ${label}`)\n this.name = 'HeraldSdkError'\n this.code = code\n this.status = status\n this.body = body\n }\n}\n","/**\n * Herald server SDK client — a 1:1 TypeScript port of the Rust `herald-sdk`\n * crate (`sdk/rust/src/lib.rs`), which is this SDK's source of truth.\n *\n * Auth model: every call carries the realm/service `X-API-Key` header against\n * the external API surface (`/api/ext/*`). `checkPermission` additionally\n * mirrors the Rust caching behaviour exactly:\n *\n * - a TTL cache keyed by the full request (token + clientId + rules);\n * - a token→keys index so `invalidateCache(token)` drops every cached check\n * for that token;\n * - a 300s \"token snapshot is stale\" heuristic: once a token has been seen\n * at least once more than 5 minutes ago, its cached entries are\n * invalidated before the next check (Rust `is_token_expired`).\n *\n * HTTP layer: native `fetch` (Node 18+), hand-rolled — same deliberate choice\n * as the Rust crate (no OpenAPI-generated client), keeping the package at zero\n * runtime dependencies and its types in lockstep with the crate's.\n */\n\nimport { HeraldSdkError } from './errors'\nimport type {\n ClientAppInfo,\n ClientAppItem,\n ConsumePointsResponse,\n CreateClientAppSdkRequest,\n CreateRealmSdkRequest,\n CreateUserSdkRequest,\n GrantPointsResponse,\n PermissionCheckRequest,\n PermissionCheckResponse,\n PointsBalanceResponse,\n RealmInfo,\n RealmItem,\n SubscriptionDetail,\n UserInfo,\n} from './types'\n\n/** Rust `is_token_expired` threshold: 5 minutes, independent of the cache TTL. */\nconst TOKEN_EXPIRY_THRESHOLD_MS = 300_000\n\ninterface CacheEntry {\n response: PermissionCheckResponse\n /** Lazy TTL: `Date.now()` after which the entry is treated as evicted. */\n expiresAtMs: number\n}\n\n/**\n * Cache key for a permission check. `rules: undefined` and `rules: []` are\n * DIFFERENT keys (Rust: `Option<Vec<Rule>>` participates in `Hash`/`Eq`), and\n * rule order is significant — `JSON.stringify` preserves both distinctions.\n */\nfunction permissionCacheKey(req: PermissionCheckRequest): string {\n return JSON.stringify({ accessToken: req.accessToken, clientId: req.clientId, rules: req.rules })\n}\n\n/** Map a `fetch` response onto the Rust `handle_response` semantics. */\nasync function handleResponse<T>(response: Response): Promise<T> {\n const text = await response.text()\n const status = response.status\n if (status === 401) throw new HeraldSdkError('unauthorized', status, text)\n if (status === 403) throw new HeraldSdkError('forbidden', status, text)\n if (status === 404) throw new HeraldSdkError('not-found', status, text)\n if (status === 500) throw new HeraldSdkError('internal-server-error', status, text)\n if (status >= 200 && status < 300) {\n try {\n return JSON.parse(text) as T\n } catch (cause) {\n throw new HeraldSdkError('parse', status, `invalid JSON body: ${String(cause)}`)\n }\n }\n throw new HeraldSdkError('api-error', status, text)\n}\n\ninterface RequestOptions {\n query?: Record<string, string>\n body?: unknown\n}\n\nexport class HeraldClient {\n private readonly baseUrl: string\n private readonly apiKey: string\n private readonly cacheTtlMs: number\n private readonly permissionCache = new Map<string, CacheEntry>()\n /** token → cache keys for its checks (Rust `token_index: DashMap`). */\n private readonly tokenIndex = new Map<string, Set<string>>()\n /** token → last successful check timestamp (Rust `token_cache`; the Rust\n * tuple also stored the response, but only the timestamp is ever read). */\n private readonly tokenLastSeen = new Map<string, number>()\n\n constructor(baseUrl: string, apiKey: string, cacheTtlSeconds?: number) {\n this.baseUrl = baseUrl.replace(/\\/+$/, '')\n this.apiKey = apiKey\n this.cacheTtlMs = (cacheTtlSeconds ?? 300) * 1000\n }\n\n private async requestJson<T>(method: 'GET' | 'POST', path: string, options: RequestOptions = {}): Promise<T> {\n let url = `${this.baseUrl}${path}`\n if (options.query) {\n const search = new URLSearchParams(options.query).toString()\n if (search) url += `?${search}`\n }\n const headers: Record<string, string> = { 'X-API-Key': this.apiKey }\n if (options.body !== undefined) headers['Content-Type'] = 'application/json'\n\n let response: Response\n try {\n response = await fetch(url, {\n method,\n headers,\n body: options.body !== undefined ? JSON.stringify(options.body) : undefined,\n })\n } catch (cause) {\n throw new HeraldSdkError('network', undefined, String(cause))\n }\n return handleResponse<T>(response)\n }\n\n // --- Permission check (with cache) ---\n\n /**\n * Check whether a user (identified by their browser access token) is allowed\n * an action. Results are cached per exact request for the client's TTL;\n * a token not checked for over 5 minutes has its cache invalidated first.\n */\n async checkPermission(req: PermissionCheckRequest): Promise<PermissionCheckResponse> {\n if (this.isTokenExpired(req.accessToken)) {\n this.invalidateCache(req.accessToken)\n }\n\n const cached = this.getCached(req)\n if (cached) return cached\n\n const response = await this.requestJson<PermissionCheckResponse>('POST', '/api/ext/permission/check', {\n body: req,\n })\n\n const now = Date.now()\n this.tokenLastSeen.set(req.accessToken, now)\n const key = permissionCacheKey(req)\n let keys = this.tokenIndex.get(req.accessToken)\n if (!keys) {\n keys = new Set()\n this.tokenIndex.set(req.accessToken, keys)\n }\n keys.add(key)\n this.permissionCache.set(key, { response, expiresAtMs: now + this.cacheTtlMs })\n return response\n }\n\n /** Rust `is_token_expired`: seen before, and more than 5 minutes ago. */\n private isTokenExpired(token: string): boolean {\n const at = this.tokenLastSeen.get(token)\n return at !== undefined && Date.now() - at > TOKEN_EXPIRY_THRESHOLD_MS\n }\n\n /** Cached response for an exact request, lazily evicting expired entries\n * (the Map counterpart of moka's TTL + eviction listener). */\n private getCached(req: PermissionCheckRequest): PermissionCheckResponse | undefined {\n const key = permissionCacheKey(req)\n const entry = this.permissionCache.get(key)\n if (!entry) return undefined\n if (Date.now() > entry.expiresAtMs) {\n this.permissionCache.delete(key)\n this.dropIndexEntry(req.accessToken, key)\n return undefined\n }\n return entry.response\n }\n\n private dropIndexEntry(token: string, key: string): void {\n const keys = this.tokenIndex.get(token)\n if (!keys) return\n keys.delete(key)\n if (keys.size === 0) this.tokenIndex.delete(token)\n }\n\n /** Drop every cached permission check for a token (e.g. after you know the\n * user's permissions changed). Unlike the Rust crate this is synchronous —\n * `await` it if you prefer; the return value is `void` either way. */\n invalidateCache(token: string): void {\n const keys = this.tokenIndex.get(token)\n if (!keys) return\n for (const key of keys) this.permissionCache.delete(key)\n this.tokenIndex.delete(token)\n }\n\n // --- Billing ---\n\n /** Subscription detail for a client app. */\n getSubscription(realmId: string, clientAppId: string): Promise<SubscriptionDetail> {\n return this.requestJson('GET', `/api/ext/bill/${encodeURIComponent(realmId)}/client/${encodeURIComponent(clientAppId)}/subscription`)\n }\n\n // --- Points ---\n\n /** User points balance. */\n getBalance(realmId: string, userId: string): Promise<PointsBalanceResponse> {\n return this.requestJson('GET', `/api/ext/points/${encodeURIComponent(realmId)}/balance`, {\n query: { userId },\n })\n }\n\n /** Consume points from a user's account. `idempotencyKey` prevents double\n * charges when the same logical consume is retried. */\n consumePoints(\n realmId: string,\n userId: string,\n clientAppId: string,\n amount: number,\n description?: string,\n idempotencyKey?: string,\n ): Promise<ConsumePointsResponse> {\n return this.requestJson('POST', `/api/ext/points/${encodeURIComponent(realmId)}/consume`, {\n body: {\n userId,\n clientAppId,\n amount,\n description,\n idempotencyKey,\n },\n })\n }\n\n /** Grant points to a user. `bucketId` is REQUIRED: every grant must target\n * an explicit Credit Bucket. `reason` must be non-empty; `validityDays`\n * omitted means permanent. */\n grantPoints(\n realmId: string,\n userId: string,\n bucketId: string,\n amount: number,\n reason: string,\n validityDays?: number,\n ): Promise<GrantPointsResponse> {\n return this.requestJson('POST', `/api/ext/points/${encodeURIComponent(realmId)}/grant`, {\n body: {\n userId,\n bucketId,\n amount,\n reason,\n validityDays,\n },\n })\n }\n\n // --- Realms ---\n\n createRealm(request: CreateRealmSdkRequest): Promise<RealmInfo> {\n return this.requestJson('POST', '/api/ext/realms', { body: request })\n }\n\n async listRealms(): Promise<RealmItem[]> {\n const body = await this.requestJson<{ realms: RealmItem[] }>('GET', '/api/ext/realms')\n return body.realms\n }\n\n getRealm(realmId: string): Promise<RealmInfo> {\n return this.requestJson('GET', `/api/ext/realms/${encodeURIComponent(realmId)}`)\n }\n\n // --- Users ---\n\n createUser(realmId: string, request: CreateUserSdkRequest): Promise<UserInfo> {\n return this.requestJson('POST', `/api/ext/realms/${encodeURIComponent(realmId)}/users`, { body: request })\n }\n\n async listUsers(realmId: string): Promise<UserInfo[]> {\n const body = await this.requestJson<{ items: UserInfo[] }>(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/users`,\n )\n return body.items\n }\n\n getUser(realmId: string, userId: string): Promise<UserInfo> {\n return this.requestJson(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/users/${encodeURIComponent(userId)}`,\n )\n }\n\n // --- Client apps ---\n\n createClientApp(realmId: string, request: CreateClientAppSdkRequest): Promise<ClientAppInfo> {\n return this.requestJson('POST', `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`, {\n body: request,\n })\n }\n\n async listClientApps(realmId: string): Promise<ClientAppItem[]> {\n const body = await this.requestJson<{ clientApps: ClientAppItem[] }>(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps`,\n )\n return body.clientApps\n }\n\n getClientApp(realmId: string, clientAppId: string): Promise<ClientAppInfo> {\n return this.requestJson(\n 'GET',\n `/api/ext/realms/${encodeURIComponent(realmId)}/client-apps/${encodeURIComponent(clientAppId)}`,\n )\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "herald-sdk",
3
+ "version": "0.5.1",
4
+ "description": "Node.js SDK for Herald — multi-tenant auth, billing & points API client. Server-side TypeScript counterpart of the Rust herald-sdk crate. Zero runtime dependencies.",
5
+ "type": "module",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.cjs"
12
+ }
13
+ },
14
+ "main": "./dist/index.cjs",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/timzaak/herald",
25
+ "directory": "sdk/node"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "type-check": "tsc --noEmit",
31
+ "test": "vitest",
32
+ "test:run": "vitest run"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.0.0",
36
+ "msw": "^2.7.1",
37
+ "tsup": "^8.3.5",
38
+ "typescript": "^5.9.3",
39
+ "vitest": "^4.1.7"
40
+ }
41
+ }