@canopy-io/node 0.11.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @canopy-io/node
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 51fd41e: Organization invitations name a place and a window. `organizations.createInvite()` takes an optional `role_id` (none admits a member holding nothing, only at the organization node), a `node_id` for an office or team beneath the organization, and `effective_from` / `effective_to`, copied onto the assignment the invitation grants on acceptance. `organizations.addMember()` takes the same `node_id` and dates. Someone already in the Environment, invited into an organization they don't belong to, receives a request to join (`intent: "grant"`) and accepts it with their own sign-in or password. Invitation responses gain `node_name`, `organization_node_id`, `effective_from`, `effective_to` and `invited_by_identity_id`. Types regenerated from the API spec.
8
+
9
+ ## 0.12.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 10d3583: Organization membership is admission, not a role. Identity tokens carry `org_roles` (the names of the roles held at the organization node, sorted, possibly empty) in place of `org_role`; `orgContext(claims)` returns `{ orgId, orgRoles }`, and refuses a token carrying one claim without the other. An empty `orgRoles` is a member holding no role at the organization node, so authorize by evaluating rather than by the list. The conformance bundle gains the `organization-membership` vector: a grant inside an organization counts only while the membership is active, which the grants `LocalAuthorizer` caches already reflect.
14
+
15
+ `organizations.changeMemberRole()` is gone with its route: a member's role at the organization is an assignment at the organization node, written through `assignments.create()`, `assignments.update()` and `assignments.delete()` with the organization node as `node_id`. `assignments.update()` takes `{ ifMatch }` and sends it as `If-Match`; a stale version answers `409 assignment.version_mismatch`. `organizations.removeMember()` deactivates the membership and revokes every role the member held in the organization. Member rows carry `status`, `source`, `joined_at` and `roles[]` in place of the single `role`.
16
+
3
17
  ## 0.11.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -143,8 +143,10 @@ throws with neither. Getting it wrong fails closed: tokens are rejected, never
143
143
  mistakenly accepted.
144
144
 
145
145
  With an Environment's **organizations** container on, an identity token also
146
- 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 —
147
- Canopy mints the two together, and the helper refuses a half-present pair:
146
+ names the organization the session is acting in and the roles held at that
147
+ organization's node. Read the pair through `orgContext` rather than off the raw
148
+ claims — Canopy mints the two together, and the helper refuses a half-present
149
+ pair:
148
150
 
149
151
  ```ts
150
152
  import { orgContext } from "@canopy-io/node";
@@ -153,12 +155,20 @@ const org = orgContext(claims);
153
155
 
154
156
  if (org) {
155
157
  org.orgId; // the organization — also a hierarchy node id
156
- org.orgRole; // the single role held there, by name
158
+ org.orgRoles; // the roles held at its node, by name; [] for a member holding none there
157
159
  }
158
160
  ```
159
161
 
160
162
  `null` is an ordinary answer: every token from an Environment without the
161
- container on, and every identity that belongs to no organization yet.
163
+ container on, and every identity that belongs to no organization yet. An empty
164
+ `orgRoles` is not: membership is admission, and a member may hold roles only
165
+ beneath the organization. Authorize by evaluating, never by the list.
166
+
167
+ A grant inside an organization counts only while the membership is active.
168
+ The grants `LocalAuthorizer` caches already apply that: a deactivated
169
+ membership drops every grant inside the organization from the next read, so
170
+ the authorizer sees it within the cache TTL, and sooner on
171
+ `invalidate(identityId)` when an `organization.member.removed` webhook arrives.
162
172
 
163
173
  Tokens also carry `amr`, how the session was proven (`pwd`, `otp` or `sso`,
164
174
  plus `mfa` once a second factor was verified), so a backend can insist on
@@ -176,6 +186,15 @@ await canopy.organizations.addMember(acme.id, {
176
186
  role_id: ownerRoleId,
177
187
  });
178
188
 
189
+ // Invite by email straight to an office, for a fixed window. Someone already
190
+ // in the Environment receives a request to join, and accepting is their consent.
191
+ await canopy.organizations.createInvite(acme.id, {
192
+ email: "lee@acme.example",
193
+ role_id: agentRoleId,
194
+ node_id: euOfficeId,
195
+ effective_to: "2026-12-31T23:59:59.999Z",
196
+ });
197
+
179
198
  // An organization only tightens the Environment: a second factor where the
180
199
  // Environment leaves it optional, and only some of the Environment's ways in.
181
200
  await canopy.organizations.updatePolicy(acme.id, {
package/dist/index.cjs CHANGED
@@ -473,6 +473,14 @@ function paginate(fetchPage, initial = {}, options = {}) {
473
473
  return new Paginator(fetchPage, initial, options);
474
474
  }
475
475
 
476
+ // src/schema.ts
477
+ function withConcurrency(options = {}) {
478
+ if (options.ifMatch === void 0) {
479
+ return {};
480
+ }
481
+ return { headers: { "If-Match": options.ifMatch } };
482
+ }
483
+
476
484
  // src/resources/assignments.ts
477
485
  var Assignments = class {
478
486
  constructor(client) {
@@ -498,11 +506,16 @@ var Assignments = class {
498
506
  create(input) {
499
507
  return this.client.request("POST", "/api/v1/assignments", { body: input });
500
508
  }
501
- update(id, input) {
509
+ /**
510
+ * Change the role or the window of a grant. Pass the `version` the
511
+ * assignment was read with as `ifMatch`; a stale one answers 409
512
+ * `assignment.version_mismatch` rather than overwriting a newer change.
513
+ */
514
+ update(id, input, options = {}) {
502
515
  return this.client.request(
503
516
  "PATCH",
504
517
  `/api/v1/assignments/${encodeURIComponent(id)}`,
505
- { body: input }
518
+ { body: input, ...withConcurrency(options) }
506
519
  );
507
520
  }
508
521
  delete(id) {
@@ -677,14 +690,6 @@ var Identities = class {
677
690
  }
678
691
  };
679
692
 
680
- // src/schema.ts
681
- function withConcurrency(options = {}) {
682
- if (options.ifMatch === void 0) {
683
- return {};
684
- }
685
- return { headers: { "If-Match": options.ifMatch } };
686
- }
687
-
688
693
  // src/resources/organizations.ts
689
694
  var Organizations = class {
690
695
  constructor(client) {
@@ -734,7 +739,7 @@ var Organizations = class {
734
739
  deleteAll() {
735
740
  return this.client.request("DELETE", "/api/v1/organizations");
736
741
  }
737
- // ── Members: one role per member, held inside the organization only ──
742
+ // ── Members: admission to the organization, apart from the roles held in it ──
738
743
  listMembers(id, query = {}) {
739
744
  return paginate(
740
745
  (params) => this.client.request("GET", `/api/v1/organizations/${enc(id)}/members`, {
@@ -750,14 +755,12 @@ var Organizations = class {
750
755
  { body: input }
751
756
  );
752
757
  }
753
- changeMemberRole(id, identityId, input) {
754
- return this.client.request(
755
- "PATCH",
756
- `/api/v1/organizations/${enc(id)}/members/${enc(identityId)}`,
757
- { body: input }
758
- );
759
- }
760
- /** Revokes that organization's one role; the identity and its other memberships are untouched. */
758
+ /**
759
+ * Deactivates the membership and revokes every role the member holds at
760
+ * the organization's nodes; the identity and its other memberships are
761
+ * untouched. A role in the organization is changed through the
762
+ * organization's assignment routes, not here.
763
+ */
761
764
  removeMember(id, identityId) {
762
765
  return this.client.request(
763
766
  "DELETE",
@@ -1771,11 +1774,11 @@ function resolveIssuer(options) {
1771
1774
  );
1772
1775
  }
1773
1776
  function orgContext(claims) {
1774
- const { org_id, org_role } = claims;
1775
- if (typeof org_id !== "string" || org_id === "" || typeof org_role !== "string" || org_role === "") {
1777
+ const { org_id, org_roles } = claims;
1778
+ if (typeof org_id !== "string" || org_id === "" || !Array.isArray(org_roles) || org_roles.some((role) => typeof role !== "string" || role === "")) {
1776
1779
  return null;
1777
1780
  }
1778
- return { orgId: org_id, orgRole: org_role };
1781
+ return { orgId: org_id, orgRoles: [...org_roles] };
1779
1782
  }
1780
1783
  function decodeBase64Url(value) {
1781
1784
  const padded = value.replace(/-/g, "+").replace(/_/g, "/");