@canopy-io/node 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @canopy-io/node
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 221e502: `LocalAuthorizer` keeps its copy of the hierarchy in about 40% less memory: one id string per node instead of two. A parsed `GET /api/v1/nodes/parents` response holds each node id twice, as the node's own `id` and again in its children's `parent_node_id`, and the cache used to keep both copies. A 50,000-node hierarchy now costs about 4.6 MB rather than 7.4 MB. Answers are unchanged.
8
+
9
+ ## 0.7.0
10
+
11
+ ### Minor Changes
12
+
13
+ - b336767: **Breaking:** the authorizer's cache window is capped at five minutes. `LocalAuthorizer`'s `ttlMs` (and `CanopyModule`'s `authorizationTtlMs`) above `300_000`, below `0`, or not a number now throws a `RangeError` when the authorizer is created. The window is how long a revoked user keeps access, so a longer one is refused rather than honoured. The default (60 seconds) and `0` (read fresh on every check) are unchanged. `MAX_TTL_MS` is exported.
14
+ - b336767: **Breaking:** `TokenVerifier` no longer defaults its issuer. Every Canopy Environment is its own issuer with its own signing keys (`https://auth.canopy-io.com/environments/<id>`), so the old default, the bare hosted origin, rejected every identity token. Pass `environmentId`, or `issuer` on a custom auth domain or self-hosted instance; the constructor throws with neither or both. The key set is read from the issuer's own `/.well-known/jwks.json`.
15
+
16
+ `CanopyModule` now requires `verify` with `environmentId` (or `issuer`), since it builds the verifier at startup.
17
+
18
+ The generated types follow the published API, which now includes OAuth client management (`/api/v1/oauth-clients`); those routes are typed but not wrapped as a resource, like API key management.
19
+
3
20
  ## 0.6.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -110,8 +110,16 @@ rather than ignored when you have not said which client you are:
110
110
  new TokenVerifier({ audience: process.env.CANOPY_OAUTH_CLIENT_ID });
111
111
  ```
112
112
 
113
- Self-hosted instances set `issuer`. Getting it wrong fails closed: tokens are
114
- rejected, never mistakenly accepted.
113
+ Every Environment is its own issuer, so name yours:
114
+
115
+ ```ts
116
+ new TokenVerifier({ environmentId: process.env.CANOPY_ENVIRONMENT_ID });
117
+ ```
118
+
119
+ On a custom auth domain or a self-hosted instance, set `issuer` instead
120
+ (`https://auth.acme.com`, or `<origin>/environments/<id>`). The constructor
121
+ throws with neither. Getting it wrong fails closed: tokens are rejected, never
122
+ mistakenly accepted.
115
123
 
116
124
  With an Environment's **organizations** container on, an identity token also
117
125
  names the organization the session is acting in and the one role held there. Read the pair through `orgContext` rather than off the raw claims —
@@ -227,8 +235,9 @@ everything beneath it_, so the roots are never expanded into their descendants.
227
235
 
228
236
  Both are held for at most 60 seconds, which is the delay between an access
229
237
  change and it taking effect, including a moved node. Set `ttlMs` to change it,
230
- knowing that shortening it below the gap between a user's requests saves
231
- nothing — every request then finds the cache expired and refetches.
238
+ up to five minutes (`300_000`); a longer value throws when the authorizer is
239
+ created. Shortening it below the gap between a user's requests saves nothing:
240
+ every request then finds the cache expired and refetches.
232
241
 
233
242
  `canopy.permissions.evaluate` remains for a decision with no staleness at all.
234
243
 
package/dist/index.cjs CHANGED
@@ -84,6 +84,15 @@ function isCanopyTokenError(error) {
84
84
  return error instanceof Error && error.name === "CanopyTokenError";
85
85
  }
86
86
 
87
+ // src/strings.ts
88
+ function withoutTrailingSlashes(value) {
89
+ let end = value.length;
90
+ while (end > 0 && value[end - 1] === "/") {
91
+ end -= 1;
92
+ }
93
+ return value.slice(0, end);
94
+ }
95
+
87
96
  // src/client.ts
88
97
  function isCursorPagination(pagination) {
89
98
  return "next_cursor" in pagination;
@@ -108,7 +117,7 @@ var CanopyClient = class {
108
117
  "CanopyClient requires either `apiKey` (server-to-server) or `accessToken` (a user or identity JWT)."
109
118
  );
110
119
  }
111
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
120
+ this.baseUrl = withoutTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL);
112
121
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
113
122
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
114
123
  this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
@@ -1109,6 +1118,7 @@ var Canopy = class {
1109
1118
 
1110
1119
  // src/authorizer.ts
1111
1120
  var DEFAULT_TTL_MS = 6e4;
1121
+ var MAX_TTL_MS = 3e5;
1112
1122
  var MAX_WALK_DEPTH = 256;
1113
1123
  var LocalAuthorizer = class {
1114
1124
  client;
@@ -1135,6 +1145,11 @@ var LocalAuthorizer = class {
1135
1145
  constructor(client, options = {}) {
1136
1146
  this.client = client;
1137
1147
  this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1148
+ if (!Number.isFinite(this.ttlMs) || this.ttlMs < 0 || this.ttlMs > MAX_TTL_MS) {
1149
+ throw new RangeError(
1150
+ `ttlMs must be between 0 and ${MAX_TTL_MS} (five minutes); got ${String(this.ttlMs)}. It is how long a revoked user keeps access, so it is capped.`
1151
+ );
1152
+ }
1138
1153
  this.now = options.now ?? (() => Date.now());
1139
1154
  this.readOptions = {
1140
1155
  ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
@@ -1310,8 +1325,16 @@ var LocalAuthorizer = class {
1310
1325
  }
1311
1326
  const parents = /* @__PURE__ */ new Map();
1312
1327
  if (result.modified) {
1328
+ const ids = /* @__PURE__ */ new Map();
1313
1329
  for (const edge of result.data.items) {
1314
- parents.set(edge.id, edge.parent_node_id ?? null);
1330
+ ids.set(edge.id, edge.id);
1331
+ }
1332
+ for (const edge of result.data.items) {
1333
+ const parent = edge.parent_node_id ?? null;
1334
+ parents.set(
1335
+ edge.id,
1336
+ parent === null ? null : ids.get(parent) ?? parent
1337
+ );
1315
1338
  }
1316
1339
  }
1317
1340
  this.tree = {
@@ -1324,12 +1347,29 @@ var LocalAuthorizer = class {
1324
1347
  };
1325
1348
 
1326
1349
  // src/verify.ts
1327
- var DEFAULT_ISSUER = "https://auth.canopy-io.com";
1350
+ var HOSTED_ORIGIN = "https://auth.canopy-io.com";
1328
1351
  var DEFAULT_JWKS_CACHE_MAX_AGE_MS = 10 * 60 * 1e3;
1329
1352
  var DEFAULT_JWKS_MIN_REFETCH_INTERVAL_MS = 30 * 1e3;
1330
1353
  var DEFAULT_JWKS_TIMEOUT_MS = 5e3;
1331
1354
  var PRINCIPAL_TYPES = /* @__PURE__ */ new Set(["user", "identity", "api_key", "platform"]);
1332
1355
  var DEFAULT_CLOCK_TOLERANCE_SEC = 60;
1356
+ function resolveIssuer(options) {
1357
+ const { environmentId, issuer } = options;
1358
+ if (environmentId && issuer) {
1359
+ throw new TypeError(
1360
+ "TokenVerifier takes environmentId or issuer, not both."
1361
+ );
1362
+ }
1363
+ if (issuer) {
1364
+ return withoutTrailingSlashes(issuer);
1365
+ }
1366
+ if (environmentId) {
1367
+ return `${HOSTED_ORIGIN}/environments/${environmentId}`;
1368
+ }
1369
+ throw new TypeError(
1370
+ "TokenVerifier needs environmentId (the Environment your tokens come from) or, on a custom auth domain or self-hosted instance, issuer."
1371
+ );
1372
+ }
1333
1373
  function orgContext(claims) {
1334
1374
  const { org_id, org_role } = claims;
1335
1375
  if (typeof org_id !== "string" || org_id === "" || typeof org_role !== "string" || org_role === "") {
@@ -1374,7 +1414,7 @@ var TokenVerifier = class {
1374
1414
  /** In-flight fetch, so a burst of requests triggers one call, not N. */
1375
1415
  inFlight = null;
1376
1416
  constructor(options = {}) {
1377
- this.issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
1417
+ this.issuer = resolveIssuer(options);
1378
1418
  this.audience = options.audience;
1379
1419
  this.jwksUri = options.jwksUri ?? `${this.issuer}/.well-known/jwks.json`;
1380
1420
  this.jwksCacheMaxAgeMs = options.jwksCacheMaxAgeMs ?? DEFAULT_JWKS_CACHE_MAX_AGE_MS;
@@ -1661,6 +1701,7 @@ exports.CanopyError = CanopyError;
1661
1701
  exports.CanopyTokenError = CanopyTokenError;
1662
1702
  exports.Identities = Identities;
1663
1703
  exports.LocalAuthorizer = LocalAuthorizer;
1704
+ exports.MAX_TTL_MS = MAX_TTL_MS;
1664
1705
  exports.Organizations = Organizations;
1665
1706
  exports.Paginator = Paginator;
1666
1707
  exports.Permissions = Permissions;