@rakomi/node 0.0.0 → 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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -1
  3. package/SECURITY.md +206 -0
  4. package/dist/agents.d.ts +90 -0
  5. package/dist/agents.js +203 -0
  6. package/dist/anonymous.d.ts +50 -0
  7. package/dist/anonymous.js +105 -0
  8. package/dist/ciba.d.ts +97 -0
  9. package/dist/ciba.js +282 -0
  10. package/dist/client.d.ts +93 -0
  11. package/dist/client.js +202 -0
  12. package/dist/credentials.d.ts +87 -0
  13. package/dist/credentials.js +104 -0
  14. package/dist/device.d.ts +76 -0
  15. package/dist/device.js +244 -0
  16. package/dist/doctor.d.ts +11 -0
  17. package/dist/doctor.js +135 -0
  18. package/dist/dpop-session.d.ts +90 -0
  19. package/dist/dpop-session.js +127 -0
  20. package/dist/dpop.d.ts +24 -0
  21. package/dist/dpop.js +51 -0
  22. package/dist/env-detect.d.ts +11 -0
  23. package/dist/env-detect.js +26 -0
  24. package/dist/errors.d.ts +307 -0
  25. package/dist/errors.js +385 -0
  26. package/dist/eudi.d.ts +23 -0
  27. package/dist/eudi.js +27 -0
  28. package/dist/flags.d.ts +50 -0
  29. package/dist/flags.js +173 -0
  30. package/dist/guards.d.ts +16 -0
  31. package/dist/guards.js +104 -0
  32. package/dist/index.d.ts +30 -0
  33. package/dist/index.js +18 -0
  34. package/dist/internal/canonical-url.d.ts +13 -0
  35. package/dist/internal/canonical-url.js +52 -0
  36. package/dist/internal/shared-constants.d.ts +3 -0
  37. package/dist/internal/shared-constants.js +3 -0
  38. package/dist/jwks-cache.d.ts +31 -0
  39. package/dist/jwks-cache.js +135 -0
  40. package/dist/link.d.ts +73 -0
  41. package/dist/link.js +262 -0
  42. package/dist/middleware.d.ts +21 -0
  43. package/dist/middleware.js +84 -0
  44. package/dist/oauth.d.ts +46 -0
  45. package/dist/oauth.js +457 -0
  46. package/dist/rbac.d.ts +12 -0
  47. package/dist/rbac.js +20 -0
  48. package/dist/token-exchange.d.ts +65 -0
  49. package/dist/token-exchange.js +163 -0
  50. package/dist/types.d.ts +436 -0
  51. package/dist/types.js +1 -0
  52. package/dist/verify-publisher-webhook.d.ts +25 -0
  53. package/dist/verify-publisher-webhook.js +47 -0
  54. package/dist/verify-token.d.ts +3 -0
  55. package/dist/verify-token.js +148 -0
  56. package/dist/verify-webhook.d.ts +7 -0
  57. package/dist/verify-webhook.js +101 -0
  58. package/package.json +61 -5
  59. package/sbom.cdx.json +52 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * RFC 8693 Token Exchange helper.
3
+ *
4
+ * Higher-level wrapper around POST /oauth/token with grant_type =
5
+ * `urn:ietf:params:oauth:grant-type:token-exchange`. Authenticates with the
6
+ * SDK client's pre-configured `clientId` + `clientSecret` (HTTP Basic) — the
7
+ * agent client MUST be registered with `clientType: 'agent'` and
8
+ * `grantTypes: ['urn:ietf:params:oauth:grant-type:token-exchange']`.
9
+ *
10
+ * Returns a typed Result (never throws on known API failures); error classes
11
+ * map RFC 8693 / RFC 6749 §5.2 codes for ergonomic catch-handling.
12
+ *
13
+ * **Server-side only** agent client_secret MUST NEVER be embedded in browser
14
+ * or mobile client code. Agents run server-side; if you need a browser-side
15
+ * agent flow, use CIBA.
16
+ */
17
+ import { TOKEN_EXCHANGE_ACCESS_TOKEN_TYPE, TOKEN_EXCHANGE_GRANT_TYPE, } from './internal/shared-constants.js';
18
+ export class TokenExchangeError extends Error {
19
+ code;
20
+ description;
21
+ constructor(code, description) {
22
+ super(`${code}: ${description}`);
23
+ this.code = code;
24
+ this.description = description;
25
+ }
26
+ }
27
+ export class TokenExchangeInvalidGrantError extends TokenExchangeError {
28
+ constructor(description) { super('invalid_grant', description); }
29
+ }
30
+ export class TokenExchangeUnauthorizedClientError extends TokenExchangeError {
31
+ constructor(description) { super('unauthorized_client', description); }
32
+ }
33
+ export class TokenExchangeInvalidScopeError extends TokenExchangeError {
34
+ constructor(description) { super('invalid_scope', description); }
35
+ }
36
+ export class TokenExchangeRateLimitedError extends TokenExchangeError {
37
+ constructor(description) { super('rate_limited', description); }
38
+ }
39
+ export class TokenExchangeInvalidClientError extends TokenExchangeError {
40
+ constructor(description) { super('invalid_client', description); }
41
+ }
42
+ function makeError(code, description) {
43
+ return {
44
+ code: `token_exchange/${code}`,
45
+ message: description,
46
+ suggestion: code === 'invalid_grant'
47
+ ? 'The user token is not exchangeable. Confirm it is a freshly-issued user access token (not an M2M, anonymous, or already-delegated token).'
48
+ : code === 'unauthorized_client'
49
+ ? "Confirm the OAuth client was registered with clientType='agent' and grantTypes=['urn:ietf:params:oauth:grant-type:token-exchange']."
50
+ : code === 'invalid_scope'
51
+ ? 'Requested scope is empty after intersection with the user grant and agent allowlist. Reduce requested scopes or extend the agent allowlist.'
52
+ : code === 'rate_limited'
53
+ ? 'Per-agent_client / per-subject-token rate limit exceeded. Slow down or increase the limit on the dashboard.'
54
+ : 'See description; consult Starlight docs for token-exchange grant.',
55
+ docs_url: 'https://docs.rakomi.dev/oauth/token-exchange',
56
+ };
57
+ }
58
+ function basicAuth(clientId, secret) {
59
+ const raw = `${clientId}:${secret}`;
60
+ if (typeof Buffer !== 'undefined')
61
+ return `Basic ${Buffer.from(raw).toString('base64')}`;
62
+ return `Basic ${btoa(raw)}`;
63
+ }
64
+ export async function exchangeTokenViaApi(ctx, options) {
65
+ const params = new URLSearchParams();
66
+ params.set('grant_type', TOKEN_EXCHANGE_GRANT_TYPE);
67
+ params.set('subject_token', options.subjectToken);
68
+ params.set('subject_token_type', TOKEN_EXCHANGE_ACCESS_TOKEN_TYPE);
69
+ if (options.scope !== undefined) {
70
+ const scopeStr = Array.isArray(options.scope) ? options.scope.join(' ') : options.scope;
71
+ if (scopeStr.length > 0)
72
+ params.set('scope', scopeStr);
73
+ }
74
+ if (options.audience !== undefined && options.audience.length > 0) {
75
+ params.set('audience', options.audience);
76
+ }
77
+ let res;
78
+ try {
79
+ res = await fetch(`${ctx.baseUrl}/oauth/token`, {
80
+ method: 'POST',
81
+ redirect: 'error',
82
+ headers: {
83
+ 'Content-Type': 'application/x-www-form-urlencoded',
84
+ Authorization: basicAuth(ctx.clientId, ctx.clientSecret),
85
+ },
86
+ body: params.toString(),
87
+ });
88
+ }
89
+ catch (err) {
90
+ return {
91
+ ok: false,
92
+ error: {
93
+ code: 'token_exchange/network_error',
94
+ message: err?.message ?? 'Network error',
95
+ suggestion: 'Verify Rakomi base URL is reachable and that DNS / TLS is healthy.',
96
+ docs_url: 'https://docs.rakomi.dev/oauth/token-exchange',
97
+ },
98
+ };
99
+ }
100
+ let body = {};
101
+ try {
102
+ body = await res.json();
103
+ }
104
+ catch {
105
+ }
106
+ if (res.ok) {
107
+ const accessToken = body.access_token;
108
+ const issuedTokenType = body.issued_token_type;
109
+ const expiresIn = body.expires_in;
110
+ const tokenType = body.token_type;
111
+ const scope = body.scope;
112
+ if (typeof accessToken !== 'string' ||
113
+ issuedTokenType !== TOKEN_EXCHANGE_ACCESS_TOKEN_TYPE ||
114
+ tokenType !== 'Bearer' ||
115
+ typeof expiresIn !== 'number' ||
116
+ typeof scope !== 'string') {
117
+ return {
118
+ ok: false,
119
+ error: {
120
+ code: 'token_exchange/malformed_response',
121
+ message: 'Server returned a 200 response that does not match RFC 8693 §2.2.1 shape',
122
+ suggestion: 'This is a server-side bug — file an issue at https://github.com/rakomidev/rakomi-js.',
123
+ docs_url: 'https://docs.rakomi.dev/oauth/token-exchange',
124
+ },
125
+ };
126
+ }
127
+ return {
128
+ ok: true,
129
+ data: {
130
+ accessToken,
131
+ issuedTokenType: TOKEN_EXCHANGE_ACCESS_TOKEN_TYPE,
132
+ tokenType: 'Bearer',
133
+ expiresIn,
134
+ scope,
135
+ },
136
+ };
137
+ }
138
+ const rawCode = body.error ?? `http_${res.status}`;
139
+ const code = res.status === 429 && rawCode === 'invalid_request' ? 'rate_limited' : rawCode;
140
+ const description = body.error_description ?? `HTTP ${res.status}`;
141
+ return { ok: false, error: makeError(code, description) };
142
+ }
143
+ /**
144
+ * Throwing variant — used by `client.tokens.exchange` per (typed errors).
145
+ * Maps the Result to `TokenExchange*Error` instances.
146
+ */
147
+ export async function exchangeTokenOrThrow(ctx, options) {
148
+ const result = await exchangeTokenViaApi(ctx, options);
149
+ if (result.ok)
150
+ return result.data;
151
+ const code = result.error.code.replace(/^token_exchange\//, '');
152
+ switch (code) {
153
+ case 'invalid_grant': throw new TokenExchangeInvalidGrantError(result.error.message);
154
+ case 'unauthorized_client': throw new TokenExchangeUnauthorizedClientError(result.error.message);
155
+ case 'invalid_scope': throw new TokenExchangeInvalidScopeError(result.error.message);
156
+ case 'rate_limited':
157
+ throw new TokenExchangeRateLimitedError(result.error.message);
158
+ case 'invalid_client':
159
+ throw new TokenExchangeInvalidClientError(result.error.message);
160
+ default:
161
+ throw new TokenExchangeError(code, result.error.message);
162
+ }
163
+ }
@@ -0,0 +1,436 @@
1
+ /** Environment mode for error verbosity in middleware responses. */
2
+ export type SdkEnvironment = 'development' | 'production';
3
+ /**
4
+ * SDK configuration options for Rakomi client.
5
+ */
6
+ export interface RakomiConfig {
7
+ /** API key (must start with `ca_live_` or `ca_test_`) */
8
+ apiKey: string;
9
+ /** Base URL for the Rakomi API (defaults to https://api.rakomi.com) */
10
+ baseUrl?: string;
11
+ /** Clock tolerance in seconds for JWT expiry checks (default: 30, max: 120) */
12
+ clockTolerance?: number;
13
+ /** Override environment detection (default: auto-detect from request hostname) */
14
+ environment?: SdkEnvironment;
15
+ /** Webhook signing secret from Rakomi dashboard (per-tenant) */
16
+ webhookSecret?: string;
17
+ /** Webhook timestamp tolerance in seconds (default: 300, max: 600) */
18
+ webhookTolerance?: number;
19
+ /** OAuth client ID (required for exchangeCode/refreshToken) */
20
+ clientId?: string;
21
+ /** OAuth client secret (required for exchangeCode/refreshToken) */
22
+ clientSecret?: string;
23
+ }
24
+ /**
25
+ * Session metadata — computed from JWT claims after successful verifyToken.
26
+ * Provides session lifecycle information for building session-aware UIs.
27
+ *
28
+ * isExpiringSoon uses effectiveExpiresIn = min(token.expiresIn, maxLifetimeRemaining)
29
+ * to match React SDK formula and prevent Next.js SSR hydration mismatch.
30
+ *
31
+ * Clock skew note: expiresAt/isExpiringSoon are computed from exp - now. If the verifying
32
+ * server's clock differs from the signing server, values may be slightly off. React SDK is
33
+ * immune — it uses expires_in from the token response body (relative, clock-skew free).
34
+ */
35
+ export interface SessionMetadata {
36
+ /** ISO 8601: when the access token expires (from JWT exp claim). Auto-refresh handles this. */
37
+ expiresAt: string;
38
+ /**
39
+ * ISO 8601: absolute session end. Present only when tenant has a custom max lifetime policy.
40
+ * Unlike expiresAt, this CANNOT be auto-refreshed — the user WILL be signed out.
41
+ */
42
+ maxLifetimeExpiresAt?: string;
43
+ /**
44
+ * True when the session will effectively expire in less than 300 seconds.
45
+ * Computed as min(token.expiresIn, maxLifetimeRemainingSeconds) < 300.
46
+ * Hardcoded 300s for server-side (React SDK configurable via expiringThresholdMinutes).
47
+ */
48
+ isExpiringSoon: boolean;
49
+ }
50
+ /**
51
+ * Token metadata — computed from JWT claims after successful verifyToken().
52
+ *
53
+ * expiresIn is subject to clock skew between the signing server and verifying server.
54
+ * Use for approximate remaining time in server-side rendering (e.g., prefill caches).
55
+ * For client-side use, prefer the React SDK's expiresInSeconds which is clock-skew immune.
56
+ */
57
+ export interface TokenMetadata {
58
+ /** Remaining seconds until the access token expires. Clamped to 0 (never negative). */
59
+ expiresIn: number;
60
+ }
61
+ /** A single org membership entry from the org_memberships JWT claim. */
62
+ export interface OrgMembership {
63
+ org_id: string;
64
+ org_slug: string;
65
+ org_role: string;
66
+ membership_public_metadata?: Record<string, unknown>;
67
+ }
68
+ /**
69
+ * Decoded JWT token payload with camelCase surface.
70
+ * Maps from JWT snake_case claims: sub→userId, tenant_id→tenantId, sid→sessionId.
71
+ */
72
+ export interface TokenPayload {
73
+ userId: string;
74
+ /** User email. Absent for M2M tokens (client_credentials flow has no user context). */
75
+ email?: string;
76
+ tenantId: string;
77
+ /** Session ID. Absent for M2M tokens (client_credentials flow creates no session). */
78
+ sessionId?: string;
79
+ iss: string;
80
+ aud: string;
81
+ exp: number;
82
+ iat: number;
83
+ jti: string;
84
+ /** True when user authenticated with a second factor (TOTP or recovery code). Omitted if false. */
85
+ mfaVerified?: boolean;
86
+ /** ISO 8601 timestamp of the MFA verification. Preserved across token refreshes. */
87
+ mfaVerifiedAt?: string;
88
+ /** Authentication Method Reference (RFC 8176). e.g. ['pwd'], ['pwd', 'otp', 'mfa'] */
89
+ amr?: string[];
90
+ /** Authentication Context Class Reference (OIDC Core). 'aal1' = single-factor, 'aal2' = multi-factor.
91
+ * 'eidas_high' when the user authenticated via an EU Digital Identity Wallet. */
92
+ acr?: string;
93
+ /** EU member-state issuer of the verified credential (EUDI Wallet login). */
94
+ credential_issuer?: string;
95
+ /** VERIFIED eIDAS level of assurance from the credential ('low'|'substantial'|'high'). */
96
+ assurance_level?: 'low' | 'substantial' | 'high';
97
+ /** Unix timestamp of initial authentication. Stays constant across token refreshes (RFC 9470). */
98
+ authTime?: number;
99
+ /** RBAC role keys assigned to the user (immutable slugs, e.g. ['editor', 'moderator']) */
100
+ roles: string[];
101
+ /** Deduplicated, sorted permission strings from all assigned roles (e.g. ['posts:read', 'posts:write']) */
102
+ permissions: string[];
103
+ /** Environment slug from rkm_env JWT claim ('live' or 'test'). Absent in pre-15.4 tokens. */
104
+ environment?: string;
105
+ /**
106
+ * Custom public metadata from the public_metadata JWT claim.
107
+ * Present only when non-empty and under 1 KB. Absent from M2M tokens.
108
+ * WARNING: Do not use for authorization decisions — informational only.
109
+ */
110
+ publicMetadata?: Record<string, unknown>;
111
+ /**
112
+ * GDPR Art. 8 minor flag from the `is_minor` JWT claim.
113
+ * ADVISORY ONLY — a derived snapshot HINT, NOT an authorization signal. Enforce minor-protection
114
+ * rules server-side (re-derive from the source age signal). Present only when the platform has
115
+ * computed it (bi-state true/false); ABSENT when minor protection is off or not yet computed — so
116
+ * `undefined` is distinct from `false`. The date of birth is NEVER present in the token.
117
+ */
118
+ isMinor?: boolean;
119
+ /**
120
+ * Session lifecycle metadata. Optional for forward-compatibility with M2M tokens
121
+ * (client_credentials flow has no session concept). Always present for user tokens.
122
+ */
123
+ session?: SessionMetadata;
124
+ /**
125
+ * Token metadata. Optional for forward-compatibility with M2M tokens.
126
+ * Always present for user tokens after successful verifyToken.
127
+ */
128
+ token?: TokenMetadata;
129
+ /**
130
+ * BaaS subscription claim. Present only for end-users with an active BaaS subscription.
131
+ * Absent from M2M tokens and from user tokens when no active subscription exists.
132
+ */
133
+ subscription?: {
134
+ plan_id: string;
135
+ plan_name: string;
136
+ status: string;
137
+ current_period_end: string | null;
138
+ };
139
+ /** Active organization context. Null = no active org (personal mode). Set in (org switching). */
140
+ org_id?: string | null;
141
+ /** Caller's role in the active org. Null when org_id is null. */
142
+ org_role?: string | null;
143
+ /** All org memberships for this user (present when serialized size < 1KB). */
144
+ org_memberships?: OrgMembership[];
145
+ /** True when this is an M2M token (client_credentials grant). Reliable marker — do not rely on absence of sessionId. */
146
+ isM2M?: boolean;
147
+ /** OAuth client ID (service identity). Present on M2M tokens only. */
148
+ clientId?: string;
149
+ /** Granted scopes as array. Present on M2M tokens; use auth.scopes.includes('api:read') for M2M permission checks. */
150
+ scopes?: string[];
151
+ /**
152
+ * True when this is an agent token issued via the RFC 8693 token-exchange
153
+ * grant. The token represents an AI agent acting on behalf of the human
154
+ * `sub` user — `userId`/`email` still identify the human; `agent` carries
155
+ * the acting client's identity. Reliable marker for "agent-mediated action"
156
+ * audit / authorization decisions.
157
+ */
158
+ isAgentToken?: boolean;
159
+ /**
160
+ * agent identity present iff `isAgentToken === true`. Derived
161
+ * from the `act.sub` claim (the agent client's `client_id`) plus the
162
+ * narrowed `scope` claim.
163
+ */
164
+ agent?: {
165
+ /** Agent client's `client_id` (registered in dashboard as `clientType: 'agent'`). */
166
+ clientId: string;
167
+ /** Narrowed scope set granted to this agent token (subset of original user scopes). */
168
+ scopes: string[];
169
+ };
170
+ }
171
+ /**
172
+ * Enriched SDK error with actionable developer guidance.
173
+ */
174
+ export interface SdkError {
175
+ code: string;
176
+ message: string;
177
+ suggestion: string;
178
+ docs_url: string;
179
+ fix_command?: string;
180
+ }
181
+ /**
182
+ * Result type — verifyToken() and verifyWebhook() NEVER throw, always return this.
183
+ */
184
+ export type VerifyResult<T = TokenPayload> = {
185
+ ok: true;
186
+ data: T;
187
+ } | {
188
+ ok: false;
189
+ error: SdkError;
190
+ };
191
+ /**
192
+ * Webhook event payload delivered by Rakomi.
193
+ */
194
+ export interface WebhookEvent {
195
+ /** Delivery ID from X-Rakomi-Delivery-Id */
196
+ id: string;
197
+ /** Event type (dot.lowercase, e.g. `user.created`) */
198
+ type: string;
199
+ /** ISO 8601 timestamp */
200
+ timestamp: string;
201
+ /** Tenant ID */
202
+ tenantId: string;
203
+ /** User ID (optional for system events) */
204
+ userId?: string;
205
+ /** Event severity */
206
+ severity: 'critical' | 'warning' | 'info';
207
+ /** Event-specific data */
208
+ data: Record<string, unknown>;
209
+ /** Event metadata */
210
+ meta: {
211
+ api_version: string;
212
+ event_language?: string;
213
+ tenant_country?: string;
214
+ user_country?: string;
215
+ };
216
+ }
217
+ /**
218
+ * Webhook headers sent with each delivery (Standard Webhooks + Rakomi supplementary).
219
+ * Accepts both exact header names and raw Express headers (lowercased).
220
+ */
221
+ export type WebhookHeaders = {
222
+ 'webhook-id': string;
223
+ 'webhook-signature': string;
224
+ 'webhook-timestamp': string;
225
+ 'x-rakomi-delivery-id'?: string;
226
+ 'x-rakomi-event'?: string;
227
+ 'x-rakomi-attempt'?: string;
228
+ } | Record<string, string | string[] | undefined>;
229
+ /**
230
+ * Verified webhook data returned on successful verification.
231
+ */
232
+ export interface WebhookVerifyData<T = WebhookEvent> {
233
+ /**
234
+ * Standard Webhooks `webhook-id` header — the stable message identity, constant across all retry
235
+ * attempts of a delivery. This is the canonical at-least-once **dedup key** for idempotent
236
+ * processing. Surfaced for BOTH the tenant and publisher transports; always
237
+ * present since `webhook-id` is a required header.
238
+ */
239
+ webhookId: string;
240
+ /**
241
+ * Per-delivery id from `X-Rakomi-Delivery-Id` (falls back to `webhook-id` when absent). Stable
242
+ * across retries of a delivery row but NOT across a reconcile re-enqueue — use for diagnostics /
243
+ * logging, NOT as the primary idempotency key (prefer {@link webhookId}).
244
+ */
245
+ deliveryId: string;
246
+ timestamp: number;
247
+ payload: T;
248
+ }
249
+ /**
250
+ * Publisher webhook event types (transport-eligible + best-effort publisher-scoped).
251
+ * The three audit-only / NOT-webhook-delivered types
252
+ * (`publisher.subprocessor_added`/`_removed`, `compliance.pack_exported`) are deliberately
253
+ * EXCLUDED — a receiver never gets them over the transport.
254
+ *
255
+ * @public — additive-only after the first public release (a removed/renamed member is a MAJOR bump).
256
+ */
257
+ export type PublisherEventType = 'app.installed' | 'app.uninstalled' | 'app.install.scope_bump' | 'app.install.receipts_revoked' | 'publisher.created' | 'publisher.domain_verified' | 'publisher.dpa_accepted' | 'app.created' | 'app.version_published' | 'app.state_changed' | 'publisher.review_requested' | 'publisher.review_denied' | 'publisher.review_stale' | 'publisher.verified' | 'publisher.deverified' | 'publisher.subscription_activated' | 'publisher.subscription_lapsed';
258
+ /**
259
+ * Open-set publisher event type: the known-literal union widened with the base string so a delivery
260
+ * carrying a `type` an older SDK has never heard of still verifies and is well-typed (forward-compat
261
+ * on the event-catalog axis). `(string & {})` keeps literal autocomplete while accepting unknown strings.
262
+ *
263
+ * @public — additive-only after the first public release.
264
+ */
265
+ export type PublisherWebhookEventType = PublisherEventType | (string & {});
266
+ /**
267
+ * The flat publisher-webhook delivery body (the parsed JSON the receiver gets on the wire). Only
268
+ * `publisher_id` + `correlation_id` are always present; the install-scoped fields appear on
269
+ * install-scoped events. Unknown/additional fields are TOLERATED (forward-compat on the payload axis)
270
+ * the verifier authenticates the bytes, then exposes the parsed object without rejecting extra keys.
271
+ * Contains NO end-user PII (operational references / counts only grounding).
272
+ *
273
+ * @public — additive-only after the first public release.
274
+ */
275
+ export interface PublisherWebhookEvent {
276
+ /** Publisher that owns the app / endpoint (resolved server-side, never caller input). */
277
+ publisher_id: string;
278
+ /** Stable cross-table forensic join key (v4) — preserved across reconcile re-enqueue. */
279
+ correlation_id: string;
280
+ /** install-scoped events only. */
281
+ installation_id?: string;
282
+ app_id?: string;
283
+ app_version_id?: string;
284
+ /** Who/what drove the action (`tenant_admin` | `system_drain` | `system_expiry`). */
285
+ actor_axis?: string;
286
+ /** `app.install.scope_bump` only — the install-state transition. */
287
+ install_state_from?: string;
288
+ install_state_to?: string;
289
+ /** `app.install.receipts_revoked` only. */
290
+ revoked_count?: number;
291
+ already_revoked_count?: number;
292
+ /** Forward-compat: additive sender-side fields are tolerated, never rejected. */
293
+ [key: string]: unknown;
294
+ }
295
+ /**
296
+ * Verified publisher-webhook data returned by `verifyPublisherWebhook` on success. Extends the
297
+ * generic verify shape with `eventType` (from the `X-Rakomi-Event` header — the exhaustive
298
+ * `switch (data.eventType)` discriminant) and narrows `payload` to {@link PublisherWebhookEvent}.
299
+ *
300
+ * @public — additive-only after the first public release.
301
+ */
302
+ export interface PublisherWebhookVerifyData {
303
+ /** Stable Standard Webhooks message id — the at-least-once dedup key. */
304
+ webhookId: string;
305
+ /** Per-delivery id (diagnostics/logging) — prefer {@link webhookId} for dedup. */
306
+ deliveryId: string;
307
+ /** Event type from the `X-Rakomi-Event` header (open set). */
308
+ eventType: PublisherWebhookEventType;
309
+ /** Unix seconds from `webhook-timestamp`. */
310
+ timestamp: number;
311
+ /** The flat, verified delivery body. */
312
+ payload: PublisherWebhookEvent;
313
+ }
314
+ /**
315
+ * Options for Express-compatible middleware.
316
+ */
317
+ export interface MiddlewareOptions {
318
+ onError?: (error: SdkError, req: unknown, res: unknown) => void;
319
+ }
320
+ import type { DpopSession } from './dpop-session.js';
321
+ /** PKCE challenge pair for OAuth authorization code flow. */
322
+ export interface PkceChallenge {
323
+ codeVerifier: string;
324
+ codeChallenge: string;
325
+ codeChallengeMethod: 'S256';
326
+ }
327
+ /** Options for building an OAuth authorize URL. */
328
+ export interface AuthorizeUrlOptions {
329
+ clientId: string;
330
+ redirectUri: string;
331
+ codeChallenge: string;
332
+ state: string;
333
+ scope?: string | string[];
334
+ baseUrl?: string;
335
+ }
336
+ /** Token response from OAuth token endpoint. */
337
+ export interface OAuthTokenResponse {
338
+ access_token: string;
339
+ token_type: string;
340
+ expires_in: number;
341
+ refresh_token?: string;
342
+ scope?: string;
343
+ }
344
+ /**
345
+ * Result of a successful in-band DPoP refresh-key ROTATION ({@link rotateRefreshKey}).
346
+ * Extends {@link OAuthTokenResponse} with the {@link rotated} discriminant so the
347
+ * caller can tell whether the key actually re-bound.
348
+ *
349
+ * **Why a discriminant and not a bare success/error:** the rotation request
350
+ * is, on the wire, a `grant_type=refresh_token` POST — on a one-time-use refresh
351
+ * token server (the Rakomi contract) EVERY 200 consumes the presented token and
352
+ * issues a fresh one in the body, *whether or not the server applied the rotation*.
353
+ * A rotation-unaware server therefore returns a perfectly good refreshed session
354
+ * that simply did NOT re-key.
355
+ * The SDK MUST surface that body, never discard it — discarding it leaves the caller
356
+ * holding the now-consumed (dead) token, and the next refresh trips server
357
+ * refresh-reuse detection → nuclear logout (the exact backward-safety property
358
+ * the dual-header design exists to provide for a rotation-unaware server).
359
+ *
360
+ * @public — additive-only after the first public release.
361
+ */
362
+ export interface RotationTokenResponse extends OAuthTokenResponse {
363
+ /**
364
+ * Whether the server actually re-bound the session to the NEW key.
365
+ *
366
+ * - `true` — the server confirmed the rotation (a 200 whose access-token `cnf.jkt`
367
+ * equals the new key's thumbprint); the SDK has atomically swapped its active
368
+ * prover to the new key. Persist these tokens; the new key is now bound.
369
+ * - `false` — the refresh SUCCEEDED and these are fresh, live tokens, but the key
370
+ * was NOT rotated (a rotation-unaware server, or a transport that
371
+ * stripped the `DPoP-Rotate` header). The OLD key is still bound (no half-swap).
372
+ * **You MUST persist the returned `refresh_token`** — the one you presented was
373
+ * consumed (one-time-use) and is now dead; the old key remains live. Re-attempt
374
+ * the rotation later if you still need to re-key.
375
+ */
376
+ rotated: boolean;
377
+ }
378
+ /** Options for exchanging an authorization code for tokens. */
379
+ export interface OAuthExchangeOptions {
380
+ code: string;
381
+ codeVerifier: string;
382
+ redirectUri: string;
383
+ clientId?: string;
384
+ /** Required for confidential clients. Omit for public clients (PKCE is sole proof). */
385
+ clientSecret?: string;
386
+ baseUrl?: string;
387
+ /**
388
+ * Opt a session into RFC 9449 DPoP sender-constraint. When
389
+ * provided, `exchangeCode` attaches a DPoP proof so the server binds the
390
+ * session's `dpop_jkt` to this handle's keypair at issuance — the precondition
391
+ * that makes the FIRST refresh of a bound session succeed. Pass the SAME
392
+ * {@link DpopSession} to every subsequent `refreshToken` for this session.
393
+ * Omit for a Bearer session.
394
+ */
395
+ dpop?: DpopSession;
396
+ }
397
+ /** Options for refreshing an OAuth token. */
398
+ export interface OAuthRefreshOptions {
399
+ refreshToken: string;
400
+ clientId?: string;
401
+ /** Required for confidential clients. Omit for public clients (PKCE is sole proof). */
402
+ clientSecret?: string;
403
+ baseUrl?: string;
404
+ /**
405
+ * The same {@link DpopSession} that was passed to `exchangeCode` for this
406
+ * session. A DPoP proof is attached on refresh IFF the server has confirmed
407
+ * the session is bound (`token_type === "DPoP"`); a Bearer session attaches
408
+ * no proof even when a session is supplied (follow the server's signal, not
409
+ * the SDK's capability). Omit for a Bearer session.
410
+ */
411
+ dpop?: DpopSession;
412
+ }
413
+ /**
414
+ * Options for an in-band DPoP refresh-key ROTATION
415
+ * Distinct from {@link
416
+ * OAuthRefreshOptions} BY API SHAPE: rotation is a dedicated, explicit ceremony
417
+ * ({@link rotateRefreshKey}) — never a flag on an ordinary refresh — so a
418
+ * `DPoP-Rotate` header is structurally impossible to attach speculatively
419
+ * `dpop` is REQUIRED and MUST be a server-confirmed bound session: you
420
+ * cannot re-key a session whose current key the server has not bound.
421
+ */
422
+ export interface OAuthRotateOptions {
423
+ refreshToken: string;
424
+ clientId?: string;
425
+ /** Required for confidential clients. Omit for public clients (PKCE is sole proof). */
426
+ clientSecret?: string;
427
+ baseUrl?: string;
428
+ /**
429
+ * The bound {@link DpopSession} whose key is being rotated. The SDK builds an
430
+ * OLD-key proof (this session's current prover, proving the right to rotate)
431
+ * AND a NEW-key proof (a fresh ephemeral keypair) and co-presents them on one
432
+ * refresh request; the active prover is swapped to the new key ONLY after the
433
+ * server confirms a `cnf.jkt`-matched 200.
434
+ */
435
+ dpop: DpopSession;
436
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import type { PublisherWebhookVerifyData, VerifyResult } from './types.js';
2
+ /**
3
+ * Verify a Rakomi **publisher** webhook delivery — the recommended entry point for publisher apps.
4
+ *
5
+ * A thin, opinionated wrapper over the generic {@link verifyWebhook} — **zero crypto change, zero new
6
+ * dependency** It:
7
+ * (a) pre-binds the generic to {@link PublisherWebhookEvent} (no `<T>` to remember);
8
+ * (b) defaults the replay tolerance to the publisher transport's **300 s** (clamped to the 600 s max);
9
+ * (c) surfaces `eventType` from the `X-Rakomi-Event` header so `switch (data.eventType)` is an
10
+ * exhaustive, open-set-typed discriminant (the body carries NO `type` field grounding);
11
+ * (d) **rejects a non-`rksec_` secret outright** publisher secrets are always `rksec_`-prefixed
12
+ * (reveal-once from the dashboard), closing the legacy raw-UTF-8 downgrade path the generic
13
+ * tenant helper tolerates.
14
+ *
15
+ * NEVER throws — returns the SDK `VerifyResult` discriminated union. The error never embeds the
16
+ * secret, decoded key, or computed HMAC.
17
+ *
18
+ * Idempotency: dedup on `data.webhookId` (the stable Standard Webhooks message id, constant across
19
+ * retries). `data.deliveryId` is per-delivery diagnostics. See the contract doc for the full pattern.
20
+ *
21
+ * @public — additive-only after the first public release (names/params/return shape are SemVer-frozen).
22
+ */
23
+ export declare function verifyPublisherWebhook(body: string | Buffer, headers: Record<string, string | string[] | undefined>, secret: string, options?: {
24
+ tolerance?: number;
25
+ }): Promise<VerifyResult<PublisherWebhookVerifyData>>;
@@ -0,0 +1,47 @@
1
+ import { WEBHOOK_INVALID_SECRET } from './errors.js';
2
+ import { getHeader, verifyWebhook } from './verify-webhook.js';
3
+ const RKSEC_PREFIX = 'rksec_';
4
+ const PUBLISHER_DEFAULT_TOLERANCE = 300;
5
+ const MAX_WEBHOOK_TOLERANCE = 600;
6
+ /**
7
+ * Verify a Rakomi **publisher** webhook delivery — the recommended entry point for publisher apps.
8
+ *
9
+ * A thin, opinionated wrapper over the generic {@link verifyWebhook} — **zero crypto change, zero new
10
+ * dependency** It:
11
+ * (a) pre-binds the generic to {@link PublisherWebhookEvent} (no `<T>` to remember);
12
+ * (b) defaults the replay tolerance to the publisher transport's **300 s** (clamped to the 600 s max);
13
+ * (c) surfaces `eventType` from the `X-Rakomi-Event` header so `switch (data.eventType)` is an
14
+ * exhaustive, open-set-typed discriminant (the body carries NO `type` field grounding);
15
+ * (d) **rejects a non-`rksec_` secret outright** publisher secrets are always `rksec_`-prefixed
16
+ * (reveal-once from the dashboard), closing the legacy raw-UTF-8 downgrade path the generic
17
+ * tenant helper tolerates.
18
+ *
19
+ * NEVER throws — returns the SDK `VerifyResult` discriminated union. The error never embeds the
20
+ * secret, decoded key, or computed HMAC.
21
+ *
22
+ * Idempotency: dedup on `data.webhookId` (the stable Standard Webhooks message id, constant across
23
+ * retries). `data.deliveryId` is per-delivery diagnostics. See the contract doc for the full pattern.
24
+ *
25
+ * @public — additive-only after the first public release (names/params/return shape are SemVer-frozen).
26
+ */
27
+ export async function verifyPublisherWebhook(body, headers, secret, options) {
28
+ if (!secret.startsWith(RKSEC_PREFIX)) {
29
+ return { ok: false, error: WEBHOOK_INVALID_SECRET() };
30
+ }
31
+ const rawTolerance = options?.tolerance ?? PUBLISHER_DEFAULT_TOLERANCE;
32
+ const tolerance = Math.min(Math.max(0, rawTolerance), MAX_WEBHOOK_TOLERANCE);
33
+ const result = await verifyWebhook(body, headers, secret, tolerance);
34
+ if (!result.ok)
35
+ return result;
36
+ const eventType = (getHeader(headers, 'x-rakomi-event') ?? '');
37
+ return {
38
+ ok: true,
39
+ data: {
40
+ webhookId: result.data.webhookId,
41
+ deliveryId: result.data.deliveryId,
42
+ eventType,
43
+ timestamp: result.data.timestamp,
44
+ payload: result.data.payload,
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,3 @@
1
+ import type { JwksCache } from './jwks-cache.js';
2
+ import type { TokenPayload, VerifyResult } from './types.js';
3
+ export declare function verifyToken<T extends TokenPayload = TokenPayload>(token: string, jwksCache: JwksCache, clockTolerance: number, sdkEnvironment?: 'live' | 'test'): Promise<VerifyResult<T>>;