@productcraft/heimdall 0.0.2 → 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.
package/README.md ADDED
@@ -0,0 +1,281 @@
1
+ # @productcraft/heimdall
2
+
3
+ Typed Node.js SDK for the [ProductCraft Heimdall](https://productcraft.co) auth platform.
4
+
5
+ ```bash
6
+ npm install @productcraft/heimdall
7
+ ```
8
+
9
+ Server-side only. For React / Next.js apps, call this from your backend (BFF pattern) — the SDK ships an API key in headers.
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { Heimdall } from "@productcraft/heimdall";
15
+
16
+ const heimdall = new Heimdall({
17
+ auth: { type: "apiKey", key: process.env.PCFT_KEY! },
18
+ });
19
+ ```
20
+
21
+ The SDK splits into three caller contexts.
22
+
23
+ ### 1. Workspace-wide admin
24
+
25
+ ```ts
26
+ // /v1/apps, /v1/idp/*, /v1/stats/me
27
+ const apps = await heimdall.apps.list();
28
+ await heimdall.apps.create({ name: "My App", slug: "my-app" });
29
+ await heimdall.idp.list();
30
+ await heimdall.stats.get();
31
+ ```
32
+
33
+ ### 2. App-scoped admin — `heimdall.app(appId)`
34
+
35
+ Pre-binds the appId path param so resource methods read like `app.endUsers.list()`.
36
+
37
+ ```ts
38
+ const app = heimdall.app("app_xyz_uuid");
39
+
40
+ // EndUsers
41
+ const users = await app.endUsers.list({ limit: "20", cursor: "..." });
42
+ await app.endUsers.update(userId, { status: "active" });
43
+ await app.endUsers.revokeAllSessions(userId);
44
+
45
+ // Roles / Permissions
46
+ await app.roles.create({ name: "admin", permissions: ["billing.read"] });
47
+ await app.roles.assign({ userId, roleName: "admin" });
48
+ await app.permissions.list();
49
+
50
+ // API keys + M2M creds
51
+ await app.apiKeys.create({ name: "ci" });
52
+ const m2m = await app.credentials.create({ name: "backend-svc" });
53
+
54
+ // Audit + invites + auth config
55
+ await app.auditLogs.list({ limit: "100" });
56
+ await app.authConfig.update({ passwordPolicy: { minLength: 12 } });
57
+ ```
58
+
59
+ ### 3. Consumer-side (BFF) — `heimdall.consumer(appSlug)`
60
+
61
+ For backend route handlers mediating auth between your SPA and Heimdall. Pre-binds the appSlug.
62
+
63
+ ```ts
64
+ const consumer = heimdall.consumer("my-app-slug");
65
+
66
+ // Sign-in flows
67
+ const { access_token, refresh_token } = await consumer.auth.signin({
68
+ identifier: "alice@example.com",
69
+ password: "...",
70
+ });
71
+ await consumer.auth.signup({ identifier, password });
72
+ await consumer.auth.refresh({ refresh_token });
73
+ await consumer.auth.logout({ refresh_token });
74
+ await consumer.auth.requestReset({ email });
75
+ await consumer.auth.resetPassword({ code, newPassword });
76
+
77
+ // Sign in with Apple (native iOS flow). See "Federated sign-in" below.
78
+ await consumer.auth.signinWithProvider({
79
+ provider: "apple",
80
+ id_token: identityTokenFromAppleSdk,
81
+ nonce: rawNonceClientGenerated,
82
+ user: { name: "Alice Doe" }, // first sign-in only
83
+ });
84
+
85
+ // Me — for the signed-in EndUser (pass their token via auth config)
86
+ await consumer.me.getProfile();
87
+ await consumer.me.listSessions();
88
+ await consumer.me.revokeSession(sessionId);
89
+
90
+ // Verify (server-to-server permission checks)
91
+ await consumer.verify.verify({ token });
92
+ await consumer.verify.authorize({ token, permission: "billing.read" });
93
+
94
+ // M2M (client_credentials grant)
95
+ await consumer.oauth.clientCredentials({ clientId, clientSecret, scope });
96
+ ```
97
+
98
+ > **Wire-shape note.** Heimdall's JSON wire is snake_case, and the SDK returns response objects as-is (e.g. `access_token`, `refresh_token`, `token_type`, `expires_in`). Request DTOs follow the same convention; some convenience fields (`identifier`, `password`, `nonce`) are unaffected, but anywhere the spec uses an underscore the SDK does too.
99
+
100
+ ## Federated sign-in (Apple, Google)
101
+
102
+ `consumer.auth.signinWithProvider` lets a BFF exchange a provider-issued identity token for a Heimdall `{ access_token, refresh_token }` pair. The endpoint creates the EndUser on first sign-in and returns the same account on subsequent ones — the SDK is a thin wrapper over Heimdall's `POST /{appSlug}/v1/auth/oauth/{provider}` which does the heavy lifting (Apple JWKS verification, issuer pinning, audience validation against the app's configured native client ids, server-side nonce replay protection, account creation / linking).
103
+
104
+ ```ts
105
+ import { Heimdall, HeimdallHttpError } from "@productcraft/heimdall";
106
+
107
+ const heimdall = new Heimdall();
108
+ const consumer = heimdall.consumer("my-app-slug");
109
+
110
+ const tokens = await consumer.auth.signinWithProvider({
111
+ provider: "apple",
112
+ // The value of `ASAuthorizationAppleIDCredential.identityToken` after
113
+ // UTF-8 decoding the data; pass through your BFF unchanged.
114
+ id_token: identityToken,
115
+ // Raw nonce the client generated and SHA-256-hashed into the
116
+ // authorize request. Heimdall recomputes sha256(nonce) and compares
117
+ // to the token's `nonce` claim.
118
+ nonce: rawNonce,
119
+ // Apple sends `{ name, email }` ONLY on the very first sign-in.
120
+ // Pass through on that call; omit on subsequent ones.
121
+ user: { name: "Alice Doe", email: "alice@example.com" },
122
+ });
123
+ ```
124
+
125
+ ### Account linking
126
+
127
+ When the verified provider claim's `email` matches an existing EndUser's verified primary email, the app's `oauth_link_policy` decides the outcome:
128
+
129
+ | Policy | Behavior |
130
+ |---|---|
131
+ | `auto` (default) | Silently link the provider identity to the existing account. Only when the provider claims `email_verified: true` AND the email is **not** an Apple private relay. |
132
+ | `confirm` | Refuse with `409 link_required`. UI should sign the user in via their original method, then bind the provider identity through a follow-up flow. |
133
+ | `reject` | Refuse with `409 account_exists_with_different_provider`. |
134
+
135
+ Configure per-app via the auth-config endpoints (Heimdall-admin → "Auth config" → "OAuth link policy").
136
+
137
+ ### Apple private-relay emails
138
+
139
+ Apple often returns `*@privaterelay.appleid.com` for users who hide their email. Heimdall persists it **as-is** on the EndUser's primary email contact — you don't need to translate it on your side. Private-relay addresses never participate in `auto`-link (they're nominally verified but not deliverable through channels you control).
140
+
141
+ ### First-sign-in name fields
142
+
143
+ Apple only sends `givenName` / `familyName` on the very first sign-in. Pass them as a combined string through `user.name` on that call — Heimdall persists it to the EndUser's display name. Subsequent sign-ins should omit `user` entirely; Heimdall keeps whatever was stored on the first call.
144
+
145
+ ### Error handling
146
+
147
+ ```ts
148
+ try {
149
+ await consumer.auth.signinWithProvider({ provider: "apple", id_token, nonce });
150
+ } catch (err) {
151
+ if (err instanceof HeimdallHttpError) {
152
+ // err.status: 401 = bad signature / issuer / audience / nonce / expired
153
+ // 409 = link_required | account_exists_with_different_provider
154
+ // 4xx/5xx = other surface errors
155
+ // err.data: parsed JSON body with `code` / `message` for the 409 family
156
+ }
157
+ throw err;
158
+ }
159
+ ```
160
+
161
+ `HeimdallHttpError` is the same exception family every other surface method throws — one filter handles all of them.
162
+
163
+ ### Google (and future providers)
164
+
165
+ The Heimdall API uses a single `POST /{appSlug}/v1/auth/oauth/{provider}` endpoint for every supported IdP — the `provider` URL segment selects the verifier. Once Google is enabled on Heimdall's side, the SDK call becomes:
166
+
167
+ ```ts
168
+ await consumer.auth.signinWithProvider({
169
+ provider: "google",
170
+ id_token: tokenFromGoogleSignInSdk,
171
+ nonce: rawNonce,
172
+ });
173
+ ```
174
+
175
+ No SDK upgrade required — the per-provider TS enum widens automatically with the next spec refresh.
176
+
177
+ ## JWT verification
178
+
179
+ Every Heimdall app publishes a JWKS at `/{appSlug}/v1/.well-known/jwks.json`. The SDK gives you a verified-claims helper and a jose-compatible JWKS resolver.
180
+
181
+ ### One-line verify (the 80% case)
182
+
183
+ ```ts
184
+ import { Heimdall, JwtExpiredError } from "@productcraft/heimdall";
185
+
186
+ const heimdall = new Heimdall();
187
+ const scope = heimdall.consumer("my-app-slug");
188
+
189
+ try {
190
+ const claims = await scope.verifyToken(token);
191
+ // claims.sub, claims.email, claims.role, claims.permissions, ...
192
+ } catch (err) {
193
+ if (err instanceof JwtExpiredError) { /* trigger refresh */ }
194
+ throw err;
195
+ }
196
+ ```
197
+
198
+ Behind the scenes: JWKS fetched once, cached in-memory (10 min TTL by default), singleflighted so 100 concurrent verifies do 1 fetch, auto-refetched if a token's `kid` isn't in the cached JWKS (rotation handling).
199
+
200
+ ### Building blocks for `jose`, NestJS guards, Hono, etc.
201
+
202
+ `scope.jwks.getKey` is a [jose-compatible](https://github.com/panva/jose) key resolver — pass it anywhere a `GetKeyFunction` is expected.
203
+
204
+ ```ts
205
+ import { jwtVerify } from "jose";
206
+
207
+ const scope = heimdall.consumer("my-app-slug");
208
+ const { payload } = await jwtVerify(token, scope.jwks.getKey, {
209
+ issuer: scope.expectedIssuer,
210
+ audience: "my-app",
211
+ });
212
+ ```
213
+
214
+ ### Passport integration
215
+
216
+ Use the companion package [`@productcraft/heimdall-passport`](../heimdall-passport):
217
+
218
+ ```bash
219
+ npm install @productcraft/heimdall-passport passport-jwt
220
+ ```
221
+
222
+ ```ts
223
+ import passportJwt from "passport-jwt";
224
+ import { createPassportSecretOrKeyProvider } from "@productcraft/heimdall-passport";
225
+
226
+ const scope = heimdall.consumer("my-app-slug");
227
+ new passportJwt.Strategy(
228
+ {
229
+ jwtFromRequest: passportJwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
230
+ secretOrKeyProvider: createPassportSecretOrKeyProvider(scope),
231
+ issuer: scope.expectedIssuer,
232
+ algorithms: ["ES256"],
233
+ },
234
+ (payload, done) => done(null, payload),
235
+ );
236
+ ```
237
+
238
+ ## Error handling
239
+
240
+ ```ts
241
+ import {
242
+ JwtVerifyError, // base — catch this if you don't care which sub-kind
243
+ JwtInvalidError,
244
+ JwtExpiredError,
245
+ JwtNotYetValidError,
246
+ JwtIssuerMismatchError,
247
+ JwtAudienceMismatchError,
248
+ JwksKeyNotFoundError,
249
+ JwksFetchError,
250
+ HeimdallHttpError, // any non-2xx HTTP response
251
+ } from "@productcraft/heimdall";
252
+ ```
253
+
254
+ ## Configuration
255
+
256
+ ```ts
257
+ new Heimdall({
258
+ // Auth credential the SDK presents to Heimdall
259
+ auth: { type: "apiKey", key: "pcft_live_..." }
260
+ | { type: "bearer", token: "eyJ..." }
261
+ | { type: "cookie", value: "auth_token=..." },
262
+ // Override the prod base URL — useful for dev / staging
263
+ baseUrl: "https://api.heimdall.example.test",
264
+ // Custom fetch (undici with retry, mock in tests, ...)
265
+ fetch: customFetch,
266
+ // Expected JWT `aud` claim for `consumer(slug).verifyToken(...)`. Optional.
267
+ expectedAudience: "my-app",
268
+ // JWKS cache TTL — default 10 minutes
269
+ jwksTtlMs: 10 * 60 * 1000,
270
+ });
271
+ ```
272
+
273
+ ## How this SDK is built
274
+
275
+ Generated from the live OpenAPI spec at `https://api.heimdall.productcraft.co/docs-json`. The 4,000+ lines of types + per-operation client functions in `src/_generated/` are produced by [kubb](https://kubb.dev/); the thin resource classes (~600 lines of `app.ts` / `consumer.ts`) wrap those into the namespace structure shown above.
276
+
277
+ When the spec changes, the nightly `spec-refresh` workflow re-runs codegen and opens a PR with the diff. Type-safe consumers get the latest API surface automatically.
278
+
279
+ ## License
280
+
281
+ [MIT](../../LICENSE).