@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 +38 -1
- package/dist/index.cjs +190 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2738 -291
- package/dist/index.d.ts +2738 -291
- package/dist/index.js +189 -4
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
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 `
|
|
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
|
|
878
|
-
*
|
|
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
|