@canopy-io/node 0.8.0 → 0.9.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,11 @@ 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 GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
1157
+ var PARENTS_PATH = "/api/v1/nodes/parents";
1123
1158
  var LocalAuthorizer = class {
1124
1159
  client;
1125
1160
  ttlMs;
@@ -1139,7 +1174,9 @@ var LocalAuthorizer = class {
1139
1174
  pendingTree;
1140
1175
  stats = {
1141
1176
  grantFetches: 0,
1177
+ grantPages: 0,
1142
1178
  treeRequests: 0,
1179
+ treePages: 0,
1143
1180
  treeNotModified: 0
1144
1181
  };
1145
1182
  constructor(client, options = {}) {
@@ -1274,16 +1311,36 @@ var LocalAuthorizer = class {
1274
1311
  this.pendingGrants.set(identityId, read);
1275
1312
  return read;
1276
1313
  }
1314
+ /**
1315
+ * Every page of the identity's grant roots, folded into permission → nodes.
1316
+ *
1317
+ * Each row is one permission at one node, so a permission granted at three
1318
+ * nodes arrives as three rows, possibly on different pages. Stopping after
1319
+ * the first page would under-report what the identity holds: a false deny,
1320
+ * the safe direction, but a wrong answer all the same.
1321
+ */
1277
1322
  async fetchGrantRoots(identityId) {
1278
1323
  this.stats.grantFetches += 1;
1279
- const response = await this.client.request(
1280
- "GET",
1281
- `/api/v1/identities/${encodeURIComponent(identityId)}/grants`,
1282
- this.readOptions
1324
+ const path = GRANTS_PATH(identityId);
1325
+ const rows = paginate(
1326
+ (query) => {
1327
+ this.stats.grantPages += 1;
1328
+ return this.client.request("GET", path, {
1329
+ ...this.readOptions,
1330
+ query
1331
+ });
1332
+ },
1333
+ { page: 1, take: PAGE_SIZE },
1334
+ { maxPages: MAX_PAGES }
1283
1335
  );
1284
1336
  const roots = /* @__PURE__ */ new Map();
1285
- for (const row of response.items ?? []) {
1286
- roots.set(row.permission, new Set(row.nodes));
1337
+ for await (const row of rows) {
1338
+ const nodes = roots.get(row.permission);
1339
+ if (nodes) {
1340
+ nodes.add(row.node_id);
1341
+ } else {
1342
+ roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
1343
+ }
1287
1344
  }
1288
1345
  this.grants.set(identityId, {
1289
1346
  roots,
@@ -1308,43 +1365,104 @@ var LocalAuthorizer = class {
1308
1365
  * Revalidate rather than re-read. The hierarchy is the expensive half and
1309
1366
  * the one that changes least, so the common case is a `304` and no transfer
1310
1367
  * at all — the tree stays in memory and only its expiry moves.
1368
+ *
1369
+ * The edges come a page at a time, so only the first page is conditional:
1370
+ * its validator covers the whole hierarchy, and a `304` to it means no page
1371
+ * has changed. On a `200` the remaining pages are read plainly and the map
1372
+ * is rebuilt from every page.
1373
+ *
1374
+ * A walk over several pages is not one snapshot. A node added or removed
1375
+ * between two page reads shifts the rows under the offset, so a row can be
1376
+ * skipped, which would leave a node out of the map until the next refresh.
1377
+ * So the walk is confirmed by asking for the first page again with the
1378
+ * validator it came with: a `304` proves nothing changed while the pages
1379
+ * were read. A `200` is a fresh first page, and the walk starts again from
1380
+ * it, up to {@link MAX_TREE_WALKS} times.
1311
1381
  */
1312
1382
  async fetchTree() {
1313
1383
  this.stats.treeRequests += 1;
1314
1384
  const held = this.tree;
1315
- const result = await this.client.requestConditional(
1385
+ let first = await this.parentsFirstPage(held?.etag ?? void 0);
1386
+ if (!first.modified) {
1387
+ if (held) {
1388
+ this.stats.treeNotModified += 1;
1389
+ this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
1390
+ return held.parents;
1391
+ }
1392
+ return this.keepTree([], null);
1393
+ }
1394
+ for (let walk = 1; ; walk++) {
1395
+ const edges = await this.parentsRemainingPages(first.data);
1396
+ if (!spansPages(first.data) || first.etag === null) {
1397
+ return this.keepTree(edges, first.etag);
1398
+ }
1399
+ const confirm = await this.parentsFirstPage(first.etag);
1400
+ if (!confirm.modified) {
1401
+ return this.keepTree(edges, first.etag);
1402
+ }
1403
+ if (walk >= MAX_TREE_WALKS) {
1404
+ return this.keepTree(edges, null);
1405
+ }
1406
+ first = confirm;
1407
+ }
1408
+ }
1409
+ parentsFirstPage(etag) {
1410
+ this.stats.treePages += 1;
1411
+ return this.client.requestConditional(
1316
1412
  "GET",
1317
- "/api/v1/nodes/parents",
1318
- held?.etag ?? void 0,
1319
- this.readOptions
1413
+ PARENTS_PATH,
1414
+ etag,
1415
+ { ...this.readOptions, query: { page: 1, take: PAGE_SIZE } }
1416
+ );
1417
+ }
1418
+ /** The first page's edges followed by every later page's. */
1419
+ async parentsRemainingPages(first) {
1420
+ const edges = [...first.items];
1421
+ if (!spansPages(first)) {
1422
+ return edges;
1423
+ }
1424
+ const rest = paginate(
1425
+ (query) => {
1426
+ this.stats.treePages += 1;
1427
+ return this.client.request("GET", PARENTS_PATH, {
1428
+ ...this.readOptions,
1429
+ query
1430
+ });
1431
+ },
1432
+ { page: first.pagination.page + 1, take: PAGE_SIZE },
1433
+ { maxPages: MAX_PAGES - 1 }
1320
1434
  );
1321
- if (!result.modified && held) {
1322
- this.stats.treeNotModified += 1;
1323
- this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
1324
- return held.parents;
1435
+ for await (const page of rest.pages()) {
1436
+ for (const edge of page.items) {
1437
+ edges.push(edge);
1438
+ }
1439
+ }
1440
+ return edges;
1441
+ }
1442
+ keepTree(edges, etag) {
1443
+ const ids = /* @__PURE__ */ new Map();
1444
+ for (const edge of edges) {
1445
+ ids.set(edge.id, edge.id);
1325
1446
  }
1326
1447
  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);
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
- );
1338
- }
1448
+ for (const edge of edges) {
1449
+ const parent = edge.parent_node_id ?? null;
1450
+ parents.set(
1451
+ edge.id,
1452
+ parent === null ? null : ids.get(parent) ?? parent
1453
+ );
1339
1454
  }
1340
1455
  this.tree = {
1341
1456
  parents,
1342
- etag: result.modified ? result.etag : held?.etag ?? null,
1457
+ etag,
1343
1458
  expiresAt: this.now() + this.ttlMs
1344
1459
  };
1345
1460
  return parents;
1346
1461
  }
1347
1462
  };
1463
+ function spansPages(first) {
1464
+ return first.pagination.has_next_page && first.items.length > 0;
1465
+ }
1348
1466
 
1349
1467
  // src/status.ts
1350
1468
  function tokenErrorStatus(error) {