@canopy-io/node 0.1.1 → 0.3.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/README.md CHANGED
@@ -16,7 +16,7 @@ Despite the name, it is not Node-only: the client is `fetch` and nothing else, s
16
16
 
17
17
  ## Features
18
18
 
19
- - **Typed resource wrappers** for `permissions`, `identities`, `roles` and `assignments` — the four every integration touches.
19
+ - **Typed resource wrappers** for `permissions`, `identities`, `roles`, `assignments` and `organizations` — the ones every integration touches.
20
20
  - **The whole API, typed.** Any operation without a wrapper is reachable through `canopy.client.request` with the same envelope handling, error typing and retry policy.
21
21
  - **Both credential types.** An API key (`cnpy_…`, sent as `X-API-Key`) for server-to-server calls, or an identity or portal JWT sent as a bearer token.
22
22
  - **Envelope unwrapping.** All five response shapes are handled, so a call returns the payload rather than a wrapper.
@@ -113,6 +113,43 @@ new TokenVerifier({ audience: process.env.CANOPY_OAUTH_CLIENT_ID });
113
113
  Self-hosted instances set `issuer`. Getting it wrong fails closed: tokens are
114
114
  rejected, never mistakenly accepted.
115
115
 
116
+ With an Environment's **organizations** container on, an identity token also
117
+ 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 —
118
+ Canopy mints the two together, and the helper refuses a half-present pair:
119
+
120
+ ```ts
121
+ import { orgContext } from "@canopy-io/node";
122
+
123
+ const org = orgContext(claims);
124
+
125
+ if (org) {
126
+ org.orgId; // the organization — also a hierarchy node id
127
+ org.orgRole; // the single role held there, by name
128
+ }
129
+ ```
130
+
131
+ `null` is an ordinary answer: every token from an Environment without the
132
+ container on, and every identity that belongs to no organization yet.
133
+
134
+ Tokens also carry `amr`, how the session was proven (`pwd`, `otp` or `sso`,
135
+ plus `mfa` once a second factor was verified), so a backend can insist on
136
+ `claims.amr?.includes("mfa")` before a sensitive action.
137
+
138
+ The organizations themselves are wrapped on the client: provision a tenant,
139
+ manage its members and invitations, tighten its sign-in policy, and bind its
140
+ identity provider.
141
+
142
+ ```ts
143
+ const acme = await canopy.organizations.create({ name: "Acme Corp" });
144
+
145
+ await canopy.organizations.addMember(acme.id, {
146
+ identity_id: identityId,
147
+ role_id: ownerRoleId,
148
+ });
149
+
150
+ await canopy.organizations.updatePolicy(acme.id, { mfa_required: true });
151
+ ```
152
+
116
153
  ### Authorizing without a call per request
117
154
 
118
155
  Asking "may this identity act _here_" on every request puts Canopy in your
package/dist/index.cjs CHANGED
@@ -619,6 +619,162 @@ function withConcurrency(options = {}) {
619
619
  return { headers: { "If-Match": options.ifMatch } };
620
620
  }
621
621
 
622
+ // src/resources/organizations.ts
623
+ var Organizations = class {
624
+ constructor(client) {
625
+ this.client = client;
626
+ }
627
+ client;
628
+ list(query = {}) {
629
+ return paginate(
630
+ (params) => this.client.request("GET", "/api/v1/organizations", {
631
+ query: params
632
+ }),
633
+ { ...query }
634
+ );
635
+ }
636
+ get(id) {
637
+ return this.client.request("GET", `/api/v1/organizations/${enc(id)}`);
638
+ }
639
+ create(input) {
640
+ return this.client.request("POST", "/api/v1/organizations", {
641
+ body: input
642
+ });
643
+ }
644
+ /**
645
+ * Pass `ifMatch` with the organization's current `version` to make a
646
+ * read-modify-write safe — a concurrent edit answers 409 instead of being
647
+ * silently overwritten.
648
+ */
649
+ update(id, input, options = {}) {
650
+ return this.client.request("PATCH", `/api/v1/organizations/${enc(id)}`, {
651
+ body: input,
652
+ ...withConcurrency(options)
653
+ });
654
+ }
655
+ /**
656
+ * Removes the organization with its memberships, pending invitations,
657
+ * policy, connection bindings, and the tree beneath it. Member identities
658
+ * survive.
659
+ */
660
+ delete(id, options = {}) {
661
+ return this.client.request(
662
+ "DELETE",
663
+ `/api/v1/organizations/${enc(id)}`,
664
+ withConcurrency(options)
665
+ );
666
+ }
667
+ /** Empties the container: every organization goes, the step before switching it off. */
668
+ deleteAll() {
669
+ return this.client.request("DELETE", "/api/v1/organizations");
670
+ }
671
+ // ── Members: one role per member, held inside the organization only ──
672
+ listMembers(id, query = {}) {
673
+ return paginate(
674
+ (params) => this.client.request("GET", `/api/v1/organizations/${enc(id)}/members`, {
675
+ query: params
676
+ }),
677
+ { ...query }
678
+ );
679
+ }
680
+ addMember(id, input) {
681
+ return this.client.request(
682
+ "POST",
683
+ `/api/v1/organizations/${enc(id)}/members`,
684
+ { body: input }
685
+ );
686
+ }
687
+ changeMemberRole(id, identityId, input) {
688
+ return this.client.request(
689
+ "PATCH",
690
+ `/api/v1/organizations/${enc(id)}/members/${enc(identityId)}`,
691
+ { body: input }
692
+ );
693
+ }
694
+ /** Revokes that organization's one role; the identity and its other memberships are untouched. */
695
+ removeMember(id, identityId) {
696
+ return this.client.request(
697
+ "DELETE",
698
+ `/api/v1/organizations/${enc(id)}/members/${enc(identityId)}`
699
+ );
700
+ }
701
+ // ── Invitations: pending membership, carrying the role the recipient will hold ──
702
+ listInvites(id, query = {}) {
703
+ return paginate(
704
+ (params) => this.client.request("GET", `/api/v1/organizations/${enc(id)}/invites`, {
705
+ query: params
706
+ }),
707
+ { ...query }
708
+ );
709
+ }
710
+ createInvite(id, input) {
711
+ return this.client.request(
712
+ "POST",
713
+ `/api/v1/organizations/${enc(id)}/invites`,
714
+ { body: input }
715
+ );
716
+ }
717
+ revokeInvite(id, inviteId) {
718
+ return this.client.request(
719
+ "DELETE",
720
+ `/api/v1/organizations/${enc(id)}/invites/${enc(inviteId)}`
721
+ );
722
+ }
723
+ // ── Policy: the organization's tightening of the Environment's sign-in rules ──
724
+ /**
725
+ * The organization's own values (null inherits), the Environment baseline
726
+ * they tighten from, and the effective policy its members sign in under.
727
+ */
728
+ getPolicy(id) {
729
+ return this.client.request(
730
+ "GET",
731
+ `/api/v1/organizations/${enc(id)}/policy`
732
+ );
733
+ }
734
+ /**
735
+ * Can only tighten the Environment: a value that would loosen it answers
736
+ * 400 `organization.policy_loosens`. Pass `ifMatch` with the policy's
737
+ * `version` (0 until the organization sets one) for a safe
738
+ * read-modify-write.
739
+ */
740
+ updatePolicy(id, input, options = {}) {
741
+ return this.client.request(
742
+ "PATCH",
743
+ `/api/v1/organizations/${enc(id)}/policy`,
744
+ { body: input, ...withConcurrency(options) }
745
+ );
746
+ }
747
+ // ── SSO: the organization's own identity provider ──
748
+ listSsoConnections(id) {
749
+ return this.client.request(
750
+ "GET",
751
+ `/api/v1/organizations/${enc(id)}/sso-connections`
752
+ );
753
+ }
754
+ /**
755
+ * Sign-ins through the connection then land in this organization, joining
756
+ * as a member with `default_role_id`. The connection must already be bound
757
+ * to the organization's Environment, and a connection binds to one
758
+ * organization per Environment.
759
+ */
760
+ bindSsoConnection(id, input) {
761
+ return this.client.request(
762
+ "POST",
763
+ `/api/v1/organizations/${enc(id)}/sso-connections`,
764
+ { body: input }
765
+ );
766
+ }
767
+ unbindSsoConnection(id, connectionId) {
768
+ return this.client.request(
769
+ "DELETE",
770
+ `/api/v1/organizations/${enc(id)}/sso-connections/${enc(connectionId)}`
771
+ );
772
+ }
773
+ };
774
+ function enc(segment) {
775
+ return encodeURIComponent(segment);
776
+ }
777
+
622
778
  // src/resources/permissions.ts
623
779
  var Permissions = class {
624
780
  constructor(client) {
@@ -780,12 +936,14 @@ var Canopy = class {
780
936
  identities;
781
937
  roles;
782
938
  assignments;
939
+ organizations;
783
940
  constructor(options) {
784
941
  this.client = new CanopyClient(options);
785
942
  this.permissions = new Permissions(this.client);
786
943
  this.identities = new Identities(this.client);
787
944
  this.roles = new Roles(this.client);
788
945
  this.assignments = new Assignments(this.client);
946
+ this.organizations = new Organizations(this.client);
789
947
  }
790
948
  };
791
949
 
@@ -874,10 +1032,30 @@ var LocalAuthorizer = class {
874
1032
  return { ...this.stats };
875
1033
  }
876
1034
  /**
877
- * Drop everything held. Not needed in normal operation, where entries expire
878
- * on their own; useful in tests and after a known change.
1035
+ * Drop what is held so the next evaluate refetches.
1036
+ *
1037
+ * With an `identityId`, only that identity's grants are dropped — the
1038
+ * cached hierarchy and every other identity's entries stay warm. This is
1039
+ * the shape an assignment webhook wants: the event names the identity
1040
+ * whose authority moved, and nothing else needs to pay a refetch for it.
1041
+ *
1042
+ * With no argument, everything goes: grants and the hierarchy tree. Not
1043
+ * needed in normal operation, where entries expire on their own; useful in
1044
+ * tests and after a change whose reach you cannot name (a role's
1045
+ * permissions edited, a node moved).
1046
+ *
1047
+ * Multi-instance honesty: an invalidation reaches THIS process only. A
1048
+ * webhook lands on one instance behind a load balancer; the others serve
1049
+ * their cached grants until their own TTL expires. Unless the app fans the
1050
+ * event out over its own pub/sub, the fleet-wide revocation guarantee is
1051
+ * the TTL, and webhook-driven invalidation is a latency optimization on
1052
+ * top of it — size the TTL to the revocation latency you can promise.
879
1053
  */
880
- invalidate() {
1054
+ invalidate(identityId) {
1055
+ if (identityId !== void 0) {
1056
+ this.grants.delete(identityId);
1057
+ return;
1058
+ }
881
1059
  this.grants.clear();
882
1060
  this.tree = void 0;
883
1061
  }
@@ -990,6 +1168,13 @@ var DEFAULT_JWKS_MIN_REFETCH_INTERVAL_MS = 30 * 1e3;
990
1168
  var DEFAULT_JWKS_TIMEOUT_MS = 5e3;
991
1169
  var PRINCIPAL_TYPES = /* @__PURE__ */ new Set(["user", "identity", "api_key", "platform"]);
992
1170
  var DEFAULT_CLOCK_TOLERANCE_SEC = 60;
1171
+ function orgContext(claims) {
1172
+ const { org_id, org_role } = claims;
1173
+ if (typeof org_id !== "string" || org_id === "" || typeof org_role !== "string" || org_role === "") {
1174
+ return null;
1175
+ }
1176
+ return { orgId: org_id, orgRole: org_role };
1177
+ }
993
1178
  function decodeBase64Url(value) {
994
1179
  const padded = value.replace(/-/g, "+").replace(/_/g, "/");
995
1180
  const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
@@ -1309,6 +1494,7 @@ exports.CanopyError = CanopyError;
1309
1494
  exports.CanopyTokenError = CanopyTokenError;
1310
1495
  exports.Identities = Identities;
1311
1496
  exports.LocalAuthorizer = LocalAuthorizer;
1497
+ exports.Organizations = Organizations;
1312
1498
  exports.Paginator = Paginator;
1313
1499
  exports.Permissions = Permissions;
1314
1500
  exports.Roles = Roles;
@@ -1318,6 +1504,7 @@ exports.isCanopyConnectionError = isCanopyConnectionError;
1318
1504
  exports.isCanopyError = isCanopyError;
1319
1505
  exports.isCanopyTokenError = isCanopyTokenError;
1320
1506
  exports.isCursorPagination = isCursorPagination;
1507
+ exports.orgContext = orgContext;
1321
1508
  exports.paginate = paginate;
1322
1509
  exports.withConcurrency = withConcurrency;
1323
1510
  //# sourceMappingURL=index.cjs.map