@canopy-io/node 0.8.0 → 0.10.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/dist/index.js CHANGED
@@ -613,11 +613,23 @@ var Identities = class {
613
613
  * newest first: when and where it was signed in, when it expires, and the
614
614
  * organization it is acting in. Read it back after a revoke to confirm,
615
615
  * from your own side, that a sign-out took.
616
+ *
617
+ * Paginated (20 per page by default, `take` up to 100), so this returns a
618
+ * `Paginator`: iterate it for every session, or call `.first()` for the
619
+ * newest. The list takes `page` and `take` only; its order is fixed.
620
+ *
621
+ * ```ts
622
+ * for await (const session of canopy.identities.sessions(id)) { … }
623
+ * ```
616
624
  */
617
- sessions(id) {
618
- return this.client.request(
619
- "GET",
620
- `/api/v1/identities/${encodeURIComponent(id)}/sessions`
625
+ sessions(id, query = {}) {
626
+ return paginate(
627
+ (params) => this.client.request(
628
+ "GET",
629
+ `/api/v1/identities/${encodeURIComponent(id)}/sessions`,
630
+ { query: params }
631
+ ),
632
+ { ...query }
621
633
  );
622
634
  }
623
635
  /**
@@ -913,11 +925,25 @@ var Organizations = class {
913
925
  `/api/v1/organizations/${enc(id)}/directory/activity`
914
926
  );
915
927
  }
916
- /** The groups the provider has pushed, each with its role mapping. */
917
- listDirectoryGroups(id) {
918
- return this.client.request(
919
- "GET",
920
- `/api/v1/organizations/${enc(id)}/directory/groups`
928
+ /**
929
+ * The groups the provider has pushed, each with its role mapping.
930
+ *
931
+ * Paginated (20 per page by default, `take` up to 100), so this returns a
932
+ * `Paginator`. Sorted by display name unless `sort_by: "created"`; `q`
933
+ * searches display names and external ids.
934
+ *
935
+ * ```ts
936
+ * for await (const group of canopy.organizations.listDirectoryGroups(id)) { … }
937
+ * ```
938
+ */
939
+ listDirectoryGroups(id, query = {}) {
940
+ return paginate(
941
+ (params) => this.client.request(
942
+ "GET",
943
+ `/api/v1/organizations/${enc(id)}/directory/groups`,
944
+ { query: params }
945
+ ),
946
+ { ...query }
921
947
  );
922
948
  }
923
949
  /**
@@ -979,6 +1005,10 @@ var Permissions = class {
979
1005
  *
980
1006
  * Prefer this to a loop over `evaluate` when rendering a screen: the checks
981
1007
  * are answered together instead of paying request latency for each.
1008
+ *
1009
+ * One request carries between 1 and 100 checks. More is refused with a
1010
+ * `400` (`CanopyError`, `statusCode` 400) and nothing is evaluated, so split
1011
+ * a larger set into batches of 100.
982
1012
  */
983
1013
  evaluateBulk(input, options = {}) {
984
1014
  return this.client.request("POST", "/api/v1/permissions/evaluate/bulk", {
@@ -1120,6 +1150,21 @@ var Canopy = class {
1120
1150
  var DEFAULT_TTL_MS = 6e4;
1121
1151
  var MAX_TTL_MS = 3e5;
1122
1152
  var MAX_WALK_DEPTH = 256;
1153
+ var PAGE_SIZE = 100;
1154
+ var MAX_PAGES = 1e4;
1155
+ var MAX_TREE_WALKS = 3;
1156
+ var KNOWN_MODEL_VERSIONS = /* @__PURE__ */ new Set([1]);
1157
+ function understood(page, path) {
1158
+ if (!KNOWN_MODEL_VERSIONS.has(page.model_version)) {
1159
+ throw new CanopyAuthorizerError(
1160
+ "authorizer.model_version_unknown",
1161
+ `${path} describes authorization model version ${page.model_version}, which this version of @canopy-io/node does not understand. It knows ${[...KNOWN_MODEL_VERSIONS].join(", ")}. Upgrade @canopy-io/node; until then this authorizer cannot decide, rather than guess.`
1162
+ );
1163
+ }
1164
+ return page;
1165
+ }
1166
+ var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
1167
+ var PARENTS_PATH = "/api/v1/nodes/parents";
1123
1168
  var LocalAuthorizer = class {
1124
1169
  client;
1125
1170
  ttlMs;
@@ -1139,7 +1184,9 @@ var LocalAuthorizer = class {
1139
1184
  pendingTree;
1140
1185
  stats = {
1141
1186
  grantFetches: 0,
1187
+ grantPages: 0,
1142
1188
  treeRequests: 0,
1189
+ treePages: 0,
1143
1190
  treeNotModified: 0
1144
1191
  };
1145
1192
  constructor(client, options = {}) {
@@ -1274,16 +1321,36 @@ var LocalAuthorizer = class {
1274
1321
  this.pendingGrants.set(identityId, read);
1275
1322
  return read;
1276
1323
  }
1324
+ /**
1325
+ * Every page of the identity's grant roots, folded into permission → nodes.
1326
+ *
1327
+ * Each row is one permission at one node, so a permission granted at three
1328
+ * nodes arrives as three rows, possibly on different pages. Stopping after
1329
+ * the first page would under-report what the identity holds: a false deny,
1330
+ * the safe direction, but a wrong answer all the same.
1331
+ */
1277
1332
  async fetchGrantRoots(identityId) {
1278
1333
  this.stats.grantFetches += 1;
1279
- const response = await this.client.request(
1280
- "GET",
1281
- `/api/v1/identities/${encodeURIComponent(identityId)}/grants`,
1282
- this.readOptions
1334
+ const path = GRANTS_PATH(identityId);
1335
+ const rows = paginate(
1336
+ (query) => {
1337
+ this.stats.grantPages += 1;
1338
+ return this.client.request("GET", path, {
1339
+ ...this.readOptions,
1340
+ query
1341
+ }).then((page) => understood(page, path));
1342
+ },
1343
+ { page: 1, take: PAGE_SIZE },
1344
+ { maxPages: MAX_PAGES }
1283
1345
  );
1284
1346
  const roots = /* @__PURE__ */ new Map();
1285
- for (const row of response.items ?? []) {
1286
- roots.set(row.permission, new Set(row.nodes));
1347
+ for await (const row of rows) {
1348
+ const nodes = roots.get(row.permission);
1349
+ if (nodes) {
1350
+ nodes.add(row.node_id);
1351
+ } else {
1352
+ roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
1353
+ }
1287
1354
  }
1288
1355
  this.grants.set(identityId, {
1289
1356
  roots,
@@ -1308,43 +1375,107 @@ var LocalAuthorizer = class {
1308
1375
  * Revalidate rather than re-read. The hierarchy is the expensive half and
1309
1376
  * the one that changes least, so the common case is a `304` and no transfer
1310
1377
  * at all — the tree stays in memory and only its expiry moves.
1378
+ *
1379
+ * The edges come a page at a time, so only the first page is conditional:
1380
+ * its validator covers the whole hierarchy, and a `304` to it means no page
1381
+ * has changed. On a `200` the remaining pages are read plainly and the map
1382
+ * is rebuilt from every page.
1383
+ *
1384
+ * A walk over several pages is not one snapshot. A node added or removed
1385
+ * between two page reads shifts the rows under the offset, so a row can be
1386
+ * skipped, which would leave a node out of the map until the next refresh.
1387
+ * So the walk is confirmed by asking for the first page again with the
1388
+ * validator it came with: a `304` proves nothing changed while the pages
1389
+ * were read. A `200` is a fresh first page, and the walk starts again from
1390
+ * it, up to {@link MAX_TREE_WALKS} times.
1311
1391
  */
1312
1392
  async fetchTree() {
1313
1393
  this.stats.treeRequests += 1;
1314
1394
  const held = this.tree;
1315
- const result = await this.client.requestConditional(
1316
- "GET",
1317
- "/api/v1/nodes/parents",
1318
- held?.etag ?? void 0,
1319
- this.readOptions
1320
- );
1321
- if (!result.modified && held) {
1322
- this.stats.treeNotModified += 1;
1323
- this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
1324
- return held.parents;
1395
+ let first = await this.parentsFirstPage(held?.etag ?? void 0);
1396
+ if (!first.modified) {
1397
+ if (held) {
1398
+ this.stats.treeNotModified += 1;
1399
+ this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
1400
+ return held.parents;
1401
+ }
1402
+ return this.keepTree([], null);
1325
1403
  }
1326
- const parents = /* @__PURE__ */ new Map();
1327
- if (result.modified) {
1328
- const ids = /* @__PURE__ */ new Map();
1329
- for (const edge of result.data.items) {
1330
- ids.set(edge.id, edge.id);
1404
+ for (let walk = 1; ; walk++) {
1405
+ const edges = await this.parentsRemainingPages(first.data);
1406
+ if (!spansPages(first.data) || first.etag === null) {
1407
+ return this.keepTree(edges, first.etag);
1331
1408
  }
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
- );
1409
+ const confirm = await this.parentsFirstPage(first.etag);
1410
+ if (!confirm.modified) {
1411
+ return this.keepTree(edges, first.etag);
1338
1412
  }
1413
+ if (walk >= MAX_TREE_WALKS) {
1414
+ return this.keepTree(edges, null);
1415
+ }
1416
+ first = confirm;
1417
+ }
1418
+ }
1419
+ parentsFirstPage(etag) {
1420
+ this.stats.treePages += 1;
1421
+ return this.client.requestConditional("GET", PARENTS_PATH, etag, {
1422
+ ...this.readOptions,
1423
+ query: { page: 1, take: PAGE_SIZE }
1424
+ }).then((result) => {
1425
+ if (result.modified) {
1426
+ understood(result.data, PARENTS_PATH);
1427
+ }
1428
+ return result;
1429
+ });
1430
+ }
1431
+ /** The first page's edges followed by every later page's. */
1432
+ async parentsRemainingPages(first) {
1433
+ const edges = [...first.items];
1434
+ if (!spansPages(first)) {
1435
+ return edges;
1436
+ }
1437
+ const rest = paginate(
1438
+ (query) => {
1439
+ this.stats.treePages += 1;
1440
+ return this.client.request("GET", PARENTS_PATH, {
1441
+ ...this.readOptions,
1442
+ query
1443
+ }).then((page) => understood(page, PARENTS_PATH));
1444
+ },
1445
+ { page: first.pagination.page + 1, take: PAGE_SIZE },
1446
+ { maxPages: MAX_PAGES - 1 }
1447
+ );
1448
+ for await (const page of rest.pages()) {
1449
+ for (const edge of page.items) {
1450
+ edges.push(edge);
1451
+ }
1452
+ }
1453
+ return edges;
1454
+ }
1455
+ keepTree(edges, etag) {
1456
+ const ids = /* @__PURE__ */ new Map();
1457
+ for (const edge of edges) {
1458
+ ids.set(edge.id, edge.id);
1459
+ }
1460
+ const parents = /* @__PURE__ */ new Map();
1461
+ for (const edge of edges) {
1462
+ const parent = edge.parent_node_id ?? null;
1463
+ parents.set(
1464
+ edge.id,
1465
+ parent === null ? null : ids.get(parent) ?? parent
1466
+ );
1339
1467
  }
1340
1468
  this.tree = {
1341
1469
  parents,
1342
- etag: result.modified ? result.etag : held?.etag ?? null,
1470
+ etag,
1343
1471
  expiresAt: this.now() + this.ttlMs
1344
1472
  };
1345
1473
  return parents;
1346
1474
  }
1347
1475
  };
1476
+ function spansPages(first) {
1477
+ return first.pagination.has_next_page && first.items.length > 0;
1478
+ }
1348
1479
 
1349
1480
  // src/status.ts
1350
1481
  function tokenErrorStatus(error) {