@productcraft/heimdall 0.1.0 → 0.2.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 CHANGED
@@ -23,11 +23,17 @@ The SDK splits into three caller contexts.
23
23
  ### 1. Workspace-wide admin
24
24
 
25
25
  ```ts
26
- // /v1/apps, /v1/idp/*, /v1/stats/me
26
+ // /v1/apps, /v1/stats/me
27
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();
28
+
29
+ // Create requires display_name + workspace_id (not `name`).
30
+ await heimdall.apps.create({
31
+ display_name: "My App",
32
+ slug: "my-app",
33
+ workspace_id: "<workspace-uuid>",
34
+ });
35
+
36
+ const stats = await heimdall.stats.get();
31
37
  ```
32
38
 
33
39
  ### 2. App-scoped admin — `heimdall.app(appId)`
@@ -35,25 +41,31 @@ await heimdall.stats.get();
35
41
  Pre-binds the appId path param so resource methods read like `app.endUsers.list()`.
36
42
 
37
43
  ```ts
38
- const app = heimdall.app("app_xyz_uuid");
44
+ const app = heimdall.app("<app-uuid>");
39
45
 
40
- // EndUsers
46
+ // EndUsers — profile updates only carry { display_name?, email? }.
47
+ // Status / role transitions are separate calls.
41
48
  const users = await app.endUsers.list({ limit: "20", cursor: "..." });
42
- await app.endUsers.update(userId, { status: "active" });
49
+ await app.endUsers.update(userId, { display_name: "Alice Smith" });
50
+ await app.endUsers.updateStatus(userId, { status: "active" });
43
51
  await app.endUsers.revokeAllSessions(userId);
44
52
 
45
- // Roles / Permissions
46
- await app.roles.create({ name: "admin", permissions: ["billing.read"] });
53
+ // Roles `CreateRoleDto` is { name, description? }. Permissions are
54
+ // bound separately, after the role exists.
55
+ await app.roles.create({ name: "admin", description: "Billing admin" });
56
+ await app.roles.setPermissions("admin", { permissions: ["billing.read"] });
47
57
  await app.roles.assign({ userId, roleName: "admin" });
48
58
  await app.permissions.list();
49
59
 
50
- // API keys + M2M creds
51
- await app.apiKeys.create({ name: "ci" });
60
+ // API keys permissions[] is required even if empty.
61
+ await app.apiKeys.create({ name: "ci", permissions: [] });
62
+
63
+ // M2M credentials
52
64
  const m2m = await app.credentials.create({ name: "backend-svc" });
53
65
 
54
- // Audit + invites + auth config
66
+ // Audit + invites + auth config (camelCase post-pipe)
55
67
  await app.auditLogs.list({ limit: "100" });
56
- await app.authConfig.update({ passwordPolicy: { minLength: 12 } });
68
+ await app.authConfig.update({ passwordMinLength: 12 });
57
69
  ```
58
70
 
59
71
  ### 3. Consumer-side (BFF) — `heimdall.consumer(appSlug)`
@@ -63,16 +75,23 @@ For backend route handlers mediating auth between your SPA and Heimdall. Pre-bin
63
75
  ```ts
64
76
  const consumer = heimdall.consumer("my-app-slug");
65
77
 
66
- // Sign-in flows
78
+ // Sign-in
67
79
  const { access_token, refresh_token } = await consumer.auth.signin({
68
80
  identifier: "alice@example.com",
69
81
  password: "...",
70
82
  });
71
- await consumer.auth.signup({ identifier, password });
83
+
84
+ // Sign-up requires { email, password, username, display_name? } — not `identifier`.
85
+ await consumer.auth.signup({
86
+ email: "alice@example.com",
87
+ password: "...",
88
+ username: "alice",
89
+ });
90
+
72
91
  await consumer.auth.refresh({ refresh_token });
73
92
  await consumer.auth.logout({ refresh_token });
74
93
  await consumer.auth.requestReset({ email });
75
- await consumer.auth.resetPassword({ code, newPassword });
94
+ await consumer.auth.resetPassword({ token, new_password: "..." });
76
95
 
77
96
  // Sign in with Apple (native iOS flow). See "Federated sign-in" below.
78
97
  await consumer.auth.signinWithProvider({
@@ -206,11 +225,12 @@ import { jwtVerify } from "jose";
206
225
 
207
226
  const scope = heimdall.consumer("my-app-slug");
208
227
  const { payload } = await jwtVerify(token, scope.jwks.getKey, {
209
- issuer: scope.expectedIssuer,
210
- audience: "my-app",
228
+ issuer: scope.expectedIssuer, // the literal string "heimdall"
211
229
  });
212
230
  ```
213
231
 
232
+ Heimdall Consumer-API tokens are not minted with an `aud` claim — the per-app boundary is enforced by the JWKS, not the issuer string or the audience. If you mint custom tokens with the heimdall key and want to enforce an audience, pass `audience` to `jose.jwtVerify` or `scope.verifyToken(token, { audience: "..." })` per-call.
233
+
214
234
  ### Passport integration
215
235
 
216
236
  Use the companion package [`@productcraft/heimdall-passport`](../heimdall-passport):
@@ -263,8 +283,6 @@ new Heimdall({
263
283
  baseUrl: "https://api.heimdall.example.test",
264
284
  // Custom fetch (undici with retry, mock in tests, ...)
265
285
  fetch: customFetch,
266
- // Expected JWT `aud` claim for `consumer(slug).verifyToken(...)`. Optional.
267
- expectedAudience: "my-app",
268
286
  // JWKS cache TTL — default 10 minutes
269
287
  jwksTtlMs: 10 * 60 * 1000,
270
288
  });
package/dist/index.cjs CHANGED
@@ -1416,7 +1416,8 @@ function getConsumerVerifyControllerVerifyUrl({
1416
1416
  }
1417
1417
  async function consumerVerifyControllerVerify({
1418
1418
  appSlug,
1419
- data
1419
+ data,
1420
+ headers
1420
1421
  }, config = {}) {
1421
1422
  const { client: request = client, ...requestConfig } = config;
1422
1423
  const requestData = data;
@@ -1424,7 +1425,8 @@ async function consumerVerifyControllerVerify({
1424
1425
  method: "POST",
1425
1426
  url: getConsumerVerifyControllerVerifyUrl({ appSlug }).url.toString(),
1426
1427
  data: requestData,
1427
- ...requestConfig
1428
+ ...requestConfig,
1429
+ headers: { ...headers, ...requestConfig.headers }
1428
1430
  });
1429
1431
  return res.data;
1430
1432
  }
@@ -1439,7 +1441,8 @@ function getConsumerVerifyControllerAuthorizeUrl({
1439
1441
  }
1440
1442
  async function consumerVerifyControllerAuthorize({
1441
1443
  appSlug,
1442
- data
1444
+ data,
1445
+ headers
1443
1446
  }, config = {}) {
1444
1447
  const { client: request = client, ...requestConfig } = config;
1445
1448
  const requestData = data;
@@ -1447,7 +1450,8 @@ async function consumerVerifyControllerAuthorize({
1447
1450
  method: "POST",
1448
1451
  url: getConsumerVerifyControllerAuthorizeUrl({ appSlug }).url.toString(),
1449
1452
  data: requestData,
1450
- ...requestConfig
1453
+ ...requestConfig,
1454
+ headers: { ...headers, ...requestConfig.headers }
1451
1455
  });
1452
1456
  return res.data;
1453
1457
  }
@@ -1465,7 +1469,8 @@ function getConsumerVerifyControllerAuthorizeBatchUrl({
1465
1469
  }
1466
1470
  async function consumerVerifyControllerAuthorizeBatch({
1467
1471
  appSlug,
1468
- data
1472
+ data,
1473
+ headers
1469
1474
  }, config = {}) {
1470
1475
  const { client: request = client, ...requestConfig } = config;
1471
1476
  const requestData = data;
@@ -1475,7 +1480,8 @@ async function consumerVerifyControllerAuthorizeBatch({
1475
1480
  appSlug
1476
1481
  }).url.toString(),
1477
1482
  data: requestData,
1478
- ...requestConfig
1483
+ ...requestConfig,
1484
+ headers: { ...headers, ...requestConfig.headers }
1479
1485
  });
1480
1486
  return res.data;
1481
1487
  }
@@ -1656,21 +1662,49 @@ function translateJoseError(err) {
1656
1662
  }
1657
1663
 
1658
1664
  // src/scopes/consumer.ts
1665
+ var HEIMDALL_LEGACY_ISSUER = "heimdall";
1659
1666
  var ConsumerScope = class {
1660
1667
  /** The appSlug bound to this scope. */
1661
1668
  appSlug;
1662
- /** Default iss claim expected on tokens issued by this app's Heimdall instance. */
1669
+ /**
1670
+ * Issuer the Heimdall Consumer API stamps on every token for this
1671
+ * app — the public Heimdall API base joined with the app slug
1672
+ * (e.g. `https://api.heimdall.productcraft.co/acme`). Pin it in
1673
+ * your local verifier so a token minted for another app on the
1674
+ * platform cannot pass.
1675
+ *
1676
+ * `scope.verifyToken` already enforces this for you. Pass it as a
1677
+ * second-position issuer if you're wiring `jose.jwtVerify`,
1678
+ * `passport-jwt`, or PyJWT yourself.
1679
+ */
1663
1680
  expectedIssuer;
1664
- /** Default aud claim. Undefined unless the caller sets it (skip aud check). */
1681
+ /**
1682
+ * Audience the Consumer API stamps on every token for this app —
1683
+ * literally the app slug (e.g. `"acme"`). Pin it in your verifier
1684
+ * the same way as `expectedIssuer`. `scope.verifyToken` enforces
1685
+ * it by default.
1686
+ */
1665
1687
  expectedAudience;
1688
+ /**
1689
+ * Both accepted issuer strings (`expectedIssuer` + the legacy
1690
+ * `'heimdall'` literal). `verifyToken` passes this to jose so tokens
1691
+ * minted before the 2026-05-24 per-app-issuer migration keep
1692
+ * verifying alongside fresh ones — useful for the ~1-hour transition
1693
+ * window per access-token TTL, and the longer session TTL on
1694
+ * refresh tokens.
1695
+ */
1696
+ acceptedIssuers;
1666
1697
  /** jose-compatible JWKS resolver. Drop into `jose.jwtVerify`, passport-jwt, etc. */
1667
1698
  jwks;
1668
1699
  client;
1669
1700
  constructor(appSlug, internals, opts = {}) {
1670
1701
  this.appSlug = appSlug;
1671
1702
  this.client = internals.client;
1672
- this.expectedIssuer = `${internals.baseUrl}/${appSlug}`;
1673
- this.expectedAudience = opts.audience;
1703
+ const apiOrigin = new URL(internals.baseUrl);
1704
+ apiOrigin.pathname = `/${appSlug}`;
1705
+ this.expectedIssuer = apiOrigin.toString().replace(/\/$/, "");
1706
+ this.expectedAudience = appSlug;
1707
+ this.acceptedIssuers = [this.expectedIssuer, HEIMDALL_LEGACY_ISSUER];
1674
1708
  this.jwks = new JwksCache({
1675
1709
  url: new URL(`/${appSlug}/v1/.well-known/jwks.json`, internals.baseUrl),
1676
1710
  ttlMs: opts.jwksTtlMs,
@@ -1686,10 +1720,20 @@ var ConsumerScope = class {
1686
1720
  const res = await this.client({ method, url, data: body, params });
1687
1721
  return res.data;
1688
1722
  }
1689
- /** Verify a Heimdall-issued JWT against this app's JWKS. */
1723
+ /**
1724
+ * Verify a Heimdall-issued JWT against this app's JWKS.
1725
+ *
1726
+ * Checks the signature, expiry, `iss`, and `aud`. Accepts both the
1727
+ * per-app issuer URL (`expectedIssuer`) and the legacy `'heimdall'`
1728
+ * literal during the issuer-migration transition window — callers
1729
+ * who want to refuse legacy tokens can override with
1730
+ * `{ issuer: scope.expectedIssuer }`. Audience defaults to the app
1731
+ * slug (`expectedAudience`); pass `{ audience: false }` (in an
1732
+ * options override) to skip the audience check entirely.
1733
+ */
1690
1734
  verifyToken(token, opts = {}) {
1691
1735
  return verifyHeimdallToken(token, this.jwks.getKey, {
1692
- issuer: this.expectedIssuer,
1736
+ issuer: this.acceptedIssuers,
1693
1737
  audience: this.expectedAudience,
1694
1738
  ...opts
1695
1739
  });
@@ -1802,16 +1846,23 @@ var ConsumerScope = class {
1802
1846
  // (typically called by the customer's backend, not the user agent)
1803
1847
  // ─────────────────────────────────────────────────────────────
1804
1848
  verify = {
1849
+ /**
1850
+ * The kubb-generated client makes `headers.authorization` a required
1851
+ * arg because the server controllers accept the bearer as a fallback
1852
+ * to `body.token`. We stub an empty string here — the HTTP client's
1853
+ * auth middleware overrides whatever's passed with the configured
1854
+ * credential (`PCAuth.apiKey` / `bearer` / `cookie`).
1855
+ */
1805
1856
  verify: (data) => consumerVerifyControllerVerify(
1806
- { appSlug: this.appSlug, data },
1857
+ { appSlug: this.appSlug, data, headers: { authorization: "" } },
1807
1858
  { client: this.client }
1808
1859
  ),
1809
1860
  authorize: (data) => consumerVerifyControllerAuthorize(
1810
- { appSlug: this.appSlug, data },
1861
+ { appSlug: this.appSlug, data, headers: { authorization: "" } },
1811
1862
  { client: this.client }
1812
1863
  ),
1813
1864
  authorizeBatch: (data) => consumerVerifyControllerAuthorizeBatch(
1814
- { appSlug: this.appSlug, data },
1865
+ { appSlug: this.appSlug, data, headers: { authorization: "" } },
1815
1866
  { client: this.client }
1816
1867
  )
1817
1868
  };
@@ -1831,7 +1882,7 @@ function getAppControllerListMyAppsUrl() {
1831
1882
  const res = { method: "GET", url: `/v1/apps` };
1832
1883
  return res;
1833
1884
  }
1834
- async function appControllerListMyApps({ params }, config = {}) {
1885
+ async function appControllerListMyApps({ params } = {}, config = {}) {
1835
1886
  const { client: request = client, ...requestConfig } = config;
1836
1887
  const mappedParams = params ? {
1837
1888
  limit: params.limit,
@@ -1891,7 +1942,7 @@ function getStatsControllerGetMyStatsUrl() {
1891
1942
  const res = { method: "GET", url: `/v1/stats/me` };
1892
1943
  return res;
1893
1944
  }
1894
- async function statsControllerGetMyStats({ params }, config = {}) {
1945
+ async function statsControllerGetMyStats({ params } = {}, config = {}) {
1895
1946
  const { client: request = client, ...requestConfig } = config;
1896
1947
  const mappedParams = params ? { workspace_id: params.workspaceId } : void 0;
1897
1948
  const res = await request({
@@ -1918,7 +1969,6 @@ var Heimdall = class {
1918
1969
  fetch: this.fetch
1919
1970
  });
1920
1971
  this.jwtConfig = {
1921
- audience: config.expectedAudience,
1922
1972
  jwksTtlMs: config.jwksTtlMs
1923
1973
  };
1924
1974
  }
@@ -1981,6 +2031,7 @@ var Heimdall = class {
1981
2031
 
1982
2032
  exports.AppScope = AppScope;
1983
2033
  exports.ConsumerScope = ConsumerScope;
2034
+ exports.HEIMDALL_LEGACY_ISSUER = HEIMDALL_LEGACY_ISSUER;
1984
2035
  exports.Heimdall = Heimdall;
1985
2036
  exports.HeimdallHttpError = HeimdallHttpError;
1986
2037
  exports.JwksCache = JwksCache;