@cosmoops/connect-sdk 0.1.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/LICENSE +34 -0
- package/README.md +446 -0
- package/dist/cjs/cli/bin.js +16 -0
- package/dist/cjs/cli/init.js +179 -0
- package/dist/cjs/client/components/account-sessions.js +112 -0
- package/dist/cjs/client/components/apps-grid.js +46 -0
- package/dist/cjs/client/components/auth-buttons.js +42 -0
- package/dist/cjs/client/components/feature-gate.js +61 -0
- package/dist/cjs/client/components/link-button.js +24 -0
- package/dist/cjs/client/components/logo.js +27 -0
- package/dist/cjs/client/components/plan-pricing.js +166 -0
- package/dist/cjs/client/components/plans-page.js +39 -0
- package/dist/cjs/client/components/plans.js +212 -0
- package/dist/cjs/client/components/readiness.js +117 -0
- package/dist/cjs/client/components/subscribe-dialog.js +60 -0
- package/dist/cjs/client/components/subscription.js +189 -0
- package/dist/cjs/client/components/usage-meter.js +68 -0
- package/dist/cjs/client/components/user-menu.js +172 -0
- package/dist/cjs/client/index.js +99 -0
- package/dist/cjs/client/mark.js +16 -0
- package/dist/cjs/client/provider.js +259 -0
- package/dist/cjs/client/use-checkout.js +173 -0
- package/dist/cjs/errors.js +74 -0
- package/dist/cjs/format-relative-time.js +18 -0
- package/dist/cjs/helpers.js +30 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/next/index.js +311 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/server/client.js +114 -0
- package/dist/cjs/server/handler.js +333 -0
- package/dist/cjs/server/http.js +160 -0
- package/dist/cjs/server/index.js +18 -0
- package/dist/cjs/server/resources/access.js +28 -0
- package/dist/cjs/server/resources/ai.js +36 -0
- package/dist/cjs/server/resources/apps.js +30 -0
- package/dist/cjs/server/resources/billing.js +71 -0
- package/dist/cjs/server/resources/drive.js +39 -0
- package/dist/cjs/server/resources/entitlements.js +49 -0
- package/dist/cjs/server/resources/identity.js +194 -0
- package/dist/cjs/server/resources/logs.js +41 -0
- package/dist/cjs/server/resources/otp.js +29 -0
- package/dist/cjs/server/resources/sessions.js +82 -0
- package/dist/cjs/types.js +15 -0
- package/dist/esm/cli/bin.js +13 -0
- package/dist/esm/cli/init.js +174 -0
- package/dist/esm/client/components/account-sessions.js +109 -0
- package/dist/esm/client/components/apps-grid.js +43 -0
- package/dist/esm/client/components/auth-buttons.js +38 -0
- package/dist/esm/client/components/feature-gate.js +57 -0
- package/dist/esm/client/components/link-button.js +21 -0
- package/dist/esm/client/components/logo.js +24 -0
- package/dist/esm/client/components/plan-pricing.js +156 -0
- package/dist/esm/client/components/plans-page.js +36 -0
- package/dist/esm/client/components/plans.js +209 -0
- package/dist/esm/client/components/readiness.js +113 -0
- package/dist/esm/client/components/subscribe-dialog.js +57 -0
- package/dist/esm/client/components/subscription.js +181 -0
- package/dist/esm/client/components/usage-meter.js +65 -0
- package/dist/esm/client/components/user-menu.js +164 -0
- package/dist/esm/client/index.js +47 -0
- package/dist/esm/client/mark.js +13 -0
- package/dist/esm/client/provider.js +247 -0
- package/dist/esm/client/use-checkout.js +168 -0
- package/dist/esm/errors.js +69 -0
- package/dist/esm/format-relative-time.js +15 -0
- package/dist/esm/helpers.js +26 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/next/index.js +279 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/server/client.js +110 -0
- package/dist/esm/server/handler.js +330 -0
- package/dist/esm/server/http.js +156 -0
- package/dist/esm/server/index.js +10 -0
- package/dist/esm/server/resources/access.js +24 -0
- package/dist/esm/server/resources/ai.js +32 -0
- package/dist/esm/server/resources/apps.js +26 -0
- package/dist/esm/server/resources/billing.js +67 -0
- package/dist/esm/server/resources/drive.js +35 -0
- package/dist/esm/server/resources/entitlements.js +45 -0
- package/dist/esm/server/resources/identity.js +189 -0
- package/dist/esm/server/resources/logs.js +37 -0
- package/dist/esm/server/resources/otp.js +25 -0
- package/dist/esm/server/resources/sessions.js +78 -0
- package/dist/esm/types.js +14 -0
- package/dist/theme.css +84 -0
- package/dist/types/cli/bin.d.ts +1 -0
- package/dist/types/cli/init.d.ts +34 -0
- package/dist/types/client/components/account-sessions.d.ts +25 -0
- package/dist/types/client/components/apps-grid.d.ts +20 -0
- package/dist/types/client/components/auth-buttons.d.ts +31 -0
- package/dist/types/client/components/feature-gate.d.ts +57 -0
- package/dist/types/client/components/link-button.d.ts +20 -0
- package/dist/types/client/components/logo.d.ts +35 -0
- package/dist/types/client/components/plan-pricing.d.ts +96 -0
- package/dist/types/client/components/plans-page.d.ts +36 -0
- package/dist/types/client/components/plans.d.ts +34 -0
- package/dist/types/client/components/readiness.d.ts +52 -0
- package/dist/types/client/components/subscribe-dialog.d.ts +34 -0
- package/dist/types/client/components/subscription.d.ts +123 -0
- package/dist/types/client/components/usage-meter.d.ts +24 -0
- package/dist/types/client/components/user-menu.d.ts +86 -0
- package/dist/types/client/index.d.ts +47 -0
- package/dist/types/client/mark.d.ts +11 -0
- package/dist/types/client/provider.d.ts +209 -0
- package/dist/types/client/use-checkout.d.ts +93 -0
- package/dist/types/errors.d.ts +62 -0
- package/dist/types/format-relative-time.d.ts +2 -0
- package/dist/types/helpers.d.ts +33 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/next/index.d.ts +136 -0
- package/dist/types/server/client.d.ts +83 -0
- package/dist/types/server/handler.d.ts +67 -0
- package/dist/types/server/http.d.ts +52 -0
- package/dist/types/server/index.d.ts +15 -0
- package/dist/types/server/resources/access.d.ts +17 -0
- package/dist/types/server/resources/ai.d.ts +24 -0
- package/dist/types/server/resources/apps.d.ts +12 -0
- package/dist/types/server/resources/billing.d.ts +41 -0
- package/dist/types/server/resources/drive.d.ts +26 -0
- package/dist/types/server/resources/entitlements.d.ts +31 -0
- package/dist/types/server/resources/identity.d.ts +76 -0
- package/dist/types/server/resources/logs.d.ts +20 -0
- package/dist/types/server/resources/otp.d.ts +17 -0
- package/dist/types/server/resources/sessions.d.ts +37 -0
- package/dist/types/types.d.ts +434 -0
- package/package.json +102 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/** The services an organization can open - the data behind a launcher grid. */
|
|
3
|
+
export class AppsResource {
|
|
4
|
+
transport;
|
|
5
|
+
constructor(transport) {
|
|
6
|
+
this.transport = transport;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Launchable apps only, so a grid tile is never a dead end. The calling
|
|
10
|
+
* service is included and flagged `isCurrent` rather than filtered out.
|
|
11
|
+
*/
|
|
12
|
+
async list(organizationId) {
|
|
13
|
+
const result = await request(this.transport, {
|
|
14
|
+
method: "GET",
|
|
15
|
+
path: "/apps",
|
|
16
|
+
query: { organizationId },
|
|
17
|
+
});
|
|
18
|
+
// `?? []` for the same reason `billing.listPlans` has it: this is a
|
|
19
|
+
// collection every caller immediately maps or measures, and a body without
|
|
20
|
+
// the key would hand them `undefined`. Worse, the bridge re-wraps this as
|
|
21
|
+
// `{ apps: <value> }` and `JSON.stringify` drops an `undefined` member
|
|
22
|
+
// entirely - so one missing key upstream reaches the browser as `{}` and
|
|
23
|
+
// crashes on `.length`, past every `response.ok` check in between.
|
|
24
|
+
return result.apps ?? [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { pathSegment, request } from "../http.js";
|
|
2
|
+
import { normalizePlanFeatures } from "../../helpers.js";
|
|
3
|
+
/** The plan catalog you sell, and metering usage back against a subscriber's add-on balance. */
|
|
4
|
+
export class BillingResource {
|
|
5
|
+
transport;
|
|
6
|
+
constructor(transport) {
|
|
7
|
+
this.transport = transport;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Your service's active plans - render live pricing instead of hardcoding it.
|
|
11
|
+
*
|
|
12
|
+
* `features` is normalised on the way out: Connect used to send bare keys
|
|
13
|
+
* and now sends `{ key, name, description }`, and this SDK is versioned
|
|
14
|
+
* independently of the instance it talks to. Without the coercion a partner
|
|
15
|
+
* pointed at a Connect that has not upgraded yet renders a feature list of
|
|
16
|
+
* blanks - which is exactly what happened. See `normalizePlanFeatures`.
|
|
17
|
+
*/
|
|
18
|
+
async listPlans() {
|
|
19
|
+
const response = await request(this.transport, { method: "GET", path: "/plans" });
|
|
20
|
+
return {
|
|
21
|
+
plans: (response.plans ?? []).map((plan) => ({
|
|
22
|
+
...plan,
|
|
23
|
+
features: normalizePlanFeatures(plan.features),
|
|
24
|
+
})),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* What is left of every consumable add-on on an organization's
|
|
29
|
+
* subscription - the read half of `consumeAddon`.
|
|
30
|
+
*
|
|
31
|
+
* Needs a key scoped to `addons:read`, which is separate from
|
|
32
|
+
* `addons:consume` on purpose: a key that only renders a usage meter should
|
|
33
|
+
* not also be able to spend the balance down.
|
|
34
|
+
*
|
|
35
|
+
* Returns an empty `addons` array rather than throwing when there is
|
|
36
|
+
* nothing to meter, so a caller can render it unconditionally.
|
|
37
|
+
*/
|
|
38
|
+
async getAddonUsage(organizationId) {
|
|
39
|
+
const response = await request(this.transport, {
|
|
40
|
+
method: "GET",
|
|
41
|
+
path: `/addons/organizations/${pathSegment(organizationId, "organizationId")}`,
|
|
42
|
+
});
|
|
43
|
+
// Normalised, not passed through: the promise above resolves for any 2xx,
|
|
44
|
+
// and `ConnectUsageMeter` reads `addons` straight off it. A body without
|
|
45
|
+
// the key is not an error the meter can see - `data` is truthy, so its
|
|
46
|
+
// `!data` guard passes - and it crashes on `.length` instead of doing what
|
|
47
|
+
// it promises for an empty list, which is to render nothing.
|
|
48
|
+
return { ...response, addons: response.addons ?? [], resetsAt: response.resetsAt ?? null };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Debit a consumable add-on's per-cycle balance on behalf of an
|
|
52
|
+
* organization's users. Call it at whatever cadence your usage happens.
|
|
53
|
+
*
|
|
54
|
+
* An `Idempotency-Key` is always sent, generated per call when you do not
|
|
55
|
+
* supply one: a balance cannot be un-spent, so a retried consume without a
|
|
56
|
+
* key double-debits. Pass your own when the natural unit of work is
|
|
57
|
+
* something you can name (a job id, a message id) and might submit twice.
|
|
58
|
+
*/
|
|
59
|
+
consumeAddon(input, options = {}) {
|
|
60
|
+
return request(this.transport, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
path: "/addons/consume",
|
|
63
|
+
json: input,
|
|
64
|
+
headers: { "idempotency-key": options.idempotencyKey ?? crypto.randomUUID() },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { pathSegment, request } from "../http.js";
|
|
2
|
+
/** Files in an organization's Drive - upload, and signed URLs to read back. */
|
|
3
|
+
export class DriveResource {
|
|
4
|
+
transport;
|
|
5
|
+
constructor(transport) {
|
|
6
|
+
this.transport = transport;
|
|
7
|
+
}
|
|
8
|
+
upload(input) {
|
|
9
|
+
const form = new FormData();
|
|
10
|
+
const fileName = input.fileName ?? input.file.name ?? "upload";
|
|
11
|
+
form.set("file", input.file, fileName);
|
|
12
|
+
form.set("organizationId", input.organizationId);
|
|
13
|
+
if (input.parentId)
|
|
14
|
+
form.set("parentId", input.parentId);
|
|
15
|
+
return request(this.transport, { method: "POST", path: "/drive/files", multipart: form });
|
|
16
|
+
}
|
|
17
|
+
/** A signed URL for the original bytes. Short-lived - fetch it when you need it, do not store it. */
|
|
18
|
+
async getFileUrl(fileId, input) {
|
|
19
|
+
const result = await request(this.transport, {
|
|
20
|
+
method: "GET",
|
|
21
|
+
path: `/drive/files/${pathSegment(fileId, "fileId")}/url`,
|
|
22
|
+
query: { organizationId: input.organizationId },
|
|
23
|
+
});
|
|
24
|
+
return result.url;
|
|
25
|
+
}
|
|
26
|
+
/** A signed URL for the thumbnail. Same lifetime caveat as `getFileUrl`. */
|
|
27
|
+
async getPreviewUrl(fileId, input) {
|
|
28
|
+
const result = await request(this.transport, {
|
|
29
|
+
method: "GET",
|
|
30
|
+
path: `/drive/files/${pathSegment(fileId, "fileId")}/preview-url`,
|
|
31
|
+
query: { organizationId: input.organizationId, size: input.size },
|
|
32
|
+
});
|
|
33
|
+
return result.url;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { pathSegment, request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* The two checks worth making on every request: may this workspace be here at
|
|
4
|
+
* all, and what is it paying for.
|
|
5
|
+
*/
|
|
6
|
+
export class EntitlementsResource {
|
|
7
|
+
transport;
|
|
8
|
+
constructor(transport) {
|
|
9
|
+
this.transport = transport;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Every gate Connect owns between an organization and your service -
|
|
13
|
+
* subscription, KYC/KYB, a chargeable payment method, seat headroom -
|
|
14
|
+
* resolved in one call.
|
|
15
|
+
*
|
|
16
|
+
* Blocked users are the point: each blocker carries a `resolveUrl` into the
|
|
17
|
+
* Connect screen that fixes it, so you can route them somewhere useful
|
|
18
|
+
* instead of refusing them with nowhere to go.
|
|
19
|
+
*/
|
|
20
|
+
getReadiness(organizationId) {
|
|
21
|
+
return request(this.transport, {
|
|
22
|
+
method: "GET",
|
|
23
|
+
path: `/onboarding/organizations/${pathSegment(organizationId, "organizationId")}`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** The live plan and subscription, including `plan.features`. */
|
|
27
|
+
getSubscription(organizationId) {
|
|
28
|
+
return request(this.transport, {
|
|
29
|
+
method: "GET",
|
|
30
|
+
path: `/subscriptions/organizations/${pathSegment(organizationId, "organizationId")}`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether an active subscription unlocks a plan feature - the check that
|
|
35
|
+
* gates a plan-restricted module.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately `false` rather than a throw when there is no subscription at
|
|
38
|
+
* all: "not entitled" is the same answer either way, and a feature gate
|
|
39
|
+
* should not need a try/catch around it.
|
|
40
|
+
*/
|
|
41
|
+
async hasFeature(organizationId, feature) {
|
|
42
|
+
const result = await this.getSubscription(organizationId);
|
|
43
|
+
return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ConnectError } from "../../errors.js";
|
|
2
|
+
import { request } from "../http.js";
|
|
3
|
+
/** A URL-safe random string, suitable for `state` or a PKCE verifier. */
|
|
4
|
+
export function createState(byteLength = 32) {
|
|
5
|
+
const bytes = new Uint8Array(byteLength);
|
|
6
|
+
crypto.getRandomValues(bytes);
|
|
7
|
+
return base64UrlEncode(bytes);
|
|
8
|
+
}
|
|
9
|
+
function base64UrlEncode(bytes) {
|
|
10
|
+
let binary = "";
|
|
11
|
+
for (const byte of bytes)
|
|
12
|
+
binary += String.fromCharCode(byte);
|
|
13
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
14
|
+
}
|
|
15
|
+
function base64UrlDecode(value) {
|
|
16
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
17
|
+
const binary = atob(padded);
|
|
18
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* "Login with Connect" - the OAuth2 handshake, and the session that outlives
|
|
22
|
+
* it.
|
|
23
|
+
*
|
|
24
|
+
* The whole flow is five calls: `authorizeUrl` to send them out,
|
|
25
|
+
* `exchangeCode` when they come back, `verifyIdToken` once, then `getUser` on
|
|
26
|
+
* each later request and `logout` at the end.
|
|
27
|
+
*/
|
|
28
|
+
export class IdentityResource {
|
|
29
|
+
transport;
|
|
30
|
+
clientId;
|
|
31
|
+
appBaseUrl;
|
|
32
|
+
constructor(transport, clientId, appBaseUrl) {
|
|
33
|
+
this.transport = transport;
|
|
34
|
+
this.clientId = clientId;
|
|
35
|
+
this.appBaseUrl = appBaseUrl;
|
|
36
|
+
}
|
|
37
|
+
/** Step 1 - the URL to send the user's browser to. */
|
|
38
|
+
authorizeUrl(input) {
|
|
39
|
+
const url = new URL("/sso/authorize", this.appBaseUrl);
|
|
40
|
+
url.searchParams.set("client_id", this.requireClientId());
|
|
41
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
42
|
+
url.searchParams.set("response_type", "code");
|
|
43
|
+
url.searchParams.set("scope", "openid");
|
|
44
|
+
url.searchParams.set("state", input.state);
|
|
45
|
+
if (input.prompt)
|
|
46
|
+
url.searchParams.set("prompt", input.prompt);
|
|
47
|
+
if (input.codeChallenge) {
|
|
48
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
49
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
50
|
+
}
|
|
51
|
+
return url.toString();
|
|
52
|
+
}
|
|
53
|
+
/** Step 2 - trade the one-time `code` for a session. Server-side only. */
|
|
54
|
+
async exchangeCode(input) {
|
|
55
|
+
const wire = await this.post("/sso/token", {
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
code: input.code,
|
|
58
|
+
redirect_uri: input.redirectUri,
|
|
59
|
+
code_verifier: input.codeVerifier,
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
idToken: wire.id_token,
|
|
63
|
+
tokenType: wire.token_type,
|
|
64
|
+
expiresIn: wire.expires_in,
|
|
65
|
+
sessionToken: wire.session_token,
|
|
66
|
+
sessionTokenExpiresIn: wire.session_token_expires_in,
|
|
67
|
+
user: wire.user,
|
|
68
|
+
subscription: wire.subscription,
|
|
69
|
+
serviceRole: wire.serviceRole,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Step 3 - verify the `id_token` once, then discard it.
|
|
74
|
+
*
|
|
75
|
+
* HS256 over `header.payload`, keyed with your API key, plus the `exp` and
|
|
76
|
+
* `aud` checks. There is no JWKS to fetch: Connect stores only your key's
|
|
77
|
+
* sha256 hash, so the token is signed with the shared secret rather than an
|
|
78
|
+
* asymmetric key. Signature comparison goes through WebCrypto's own
|
|
79
|
+
* `verify`, which is why this is async - hand-rolling the compare is the
|
|
80
|
+
* step integrations most often get wrong.
|
|
81
|
+
*/
|
|
82
|
+
async verifyIdToken(idToken) {
|
|
83
|
+
// Guarded before `.split`, because the failure this catches is a caller
|
|
84
|
+
// passing something that is not a token at all. Left ungurded it threw a
|
|
85
|
+
// raw TypeError, which is not a `ConnectError`, so every layer above
|
|
86
|
+
// reported it as an unexplained internal error.
|
|
87
|
+
if (typeof idToken !== "string" || idToken.length === 0) {
|
|
88
|
+
throw this.tokenError("id_token is missing");
|
|
89
|
+
}
|
|
90
|
+
const parts = idToken.split(".");
|
|
91
|
+
const [header, payload, signature] = parts;
|
|
92
|
+
if (parts.length !== 3 || !header || !payload || !signature) {
|
|
93
|
+
throw this.tokenError("id_token is not a well-formed JWT");
|
|
94
|
+
}
|
|
95
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(this.transport.apiKey), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
96
|
+
// Decoded before `verify`, and guarded for the same reason the emptiness
|
|
97
|
+
// check above is: `atob` throws a raw `InvalidCharacterError` on a
|
|
98
|
+
// signature that is not base64url, which is not a `ConnectError`, so a
|
|
99
|
+
// caller verifying a token it did not mint gets an unexplained 500 where
|
|
100
|
+
// it should get a clean 401.
|
|
101
|
+
let signatureBytes;
|
|
102
|
+
try {
|
|
103
|
+
signatureBytes = base64UrlDecode(signature);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw this.tokenError("id_token signature is not valid base64url");
|
|
107
|
+
}
|
|
108
|
+
const valid = await crypto.subtle.verify("HMAC", key, signatureBytes, new TextEncoder().encode(`${header}.${payload}`));
|
|
109
|
+
if (!valid)
|
|
110
|
+
throw this.tokenError("id_token signature does not match this API key");
|
|
111
|
+
let claims;
|
|
112
|
+
try {
|
|
113
|
+
claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload)));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw this.tokenError("id_token payload is not valid JSON");
|
|
117
|
+
}
|
|
118
|
+
if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now()) {
|
|
119
|
+
throw this.tokenError("id_token has expired");
|
|
120
|
+
}
|
|
121
|
+
if (claims.aud !== this.requireClientId()) {
|
|
122
|
+
throw this.tokenError("id_token was issued for a different client_id");
|
|
123
|
+
}
|
|
124
|
+
return claims;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Step 4 - who is signed in, right now. Same blocks as `exchangeCode`,
|
|
128
|
+
* re-resolved live, so a role change or a lapsed subscription shows up here
|
|
129
|
+
* without another sign-in.
|
|
130
|
+
*
|
|
131
|
+
* Any `ConnectError` with `isSessionExpired` means send them back through
|
|
132
|
+
* `authorizeUrl()`.
|
|
133
|
+
*/
|
|
134
|
+
async getUser(sessionToken) {
|
|
135
|
+
const wire = await this.post("/sso/user", { session_token: sessionToken });
|
|
136
|
+
return {
|
|
137
|
+
user: wire.user,
|
|
138
|
+
subscription: wire.subscription,
|
|
139
|
+
serviceRole: wire.serviceRole,
|
|
140
|
+
sessionExpiresAt: wire.session_expires_at,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Step 5 - end the sign-in immediately rather than leaving it valid for the
|
|
145
|
+
* rest of its ~15-day life. Discard your own copy of `sessionToken` too;
|
|
146
|
+
* this does not do that for you.
|
|
147
|
+
*
|
|
148
|
+
* `scope` defaults to `"device"`: this sign-in, every other service signed
|
|
149
|
+
* into from the same browser, and Connect's own session on that one device.
|
|
150
|
+
* `"all"` ends every session on every device - an explicit "sign out
|
|
151
|
+
* everywhere" action only.
|
|
152
|
+
*
|
|
153
|
+
* Always succeeds. An unknown or already-revoked token is an
|
|
154
|
+
* indistinguishable no-op, so there is nothing to branch on.
|
|
155
|
+
*/
|
|
156
|
+
async logout(sessionToken, options = {}) {
|
|
157
|
+
await this.post("/sso/logout", {
|
|
158
|
+
session_token: sessionToken,
|
|
159
|
+
scope: options.scope,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
post(path, form) {
|
|
163
|
+
const spec = {
|
|
164
|
+
method: "POST",
|
|
165
|
+
path,
|
|
166
|
+
auth: "none",
|
|
167
|
+
form: {
|
|
168
|
+
...form,
|
|
169
|
+
client_id: this.requireClientId(),
|
|
170
|
+
client_secret: this.transport.apiKey,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return request(this.transport, spec);
|
|
174
|
+
}
|
|
175
|
+
requireClientId() {
|
|
176
|
+
if (!this.clientId) {
|
|
177
|
+
throw new ConnectError({
|
|
178
|
+
status: 0,
|
|
179
|
+
code: "missing_client_id",
|
|
180
|
+
message: "identity.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Setup tab once SSO is enabled.",
|
|
181
|
+
endpoint: "identity",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return this.clientId;
|
|
185
|
+
}
|
|
186
|
+
tokenError(message) {
|
|
187
|
+
return new ConnectError({ status: 401, code: "invalid_token", message, endpoint: "verifyIdToken" });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/** Push feature events into the Console's audit log, filterable to your service. */
|
|
3
|
+
export class LogsResource {
|
|
4
|
+
transport;
|
|
5
|
+
constructor(transport) {
|
|
6
|
+
this.transport = transport;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Never throws. A logging call that takes down the code path it was
|
|
10
|
+
* observing is worse than a lost line, so a failure here is swallowed and
|
|
11
|
+
* reported through the return value.
|
|
12
|
+
*
|
|
13
|
+
* Pass `sessionToken` to attribute the event to the end user it belongs to.
|
|
14
|
+
* Both scopes on the stored entry come from something verified - the service
|
|
15
|
+
* from your API key, the user from that token, which Connect checks was
|
|
16
|
+
* minted for your service before attributing anything. `attributed` in the
|
|
17
|
+
* result says whether it resolved; a `false` there still means the line was
|
|
18
|
+
* stored, just without a user on it.
|
|
19
|
+
*/
|
|
20
|
+
async write(input) {
|
|
21
|
+
const timestamp = input.timestamp instanceof Date ? input.timestamp.toISOString() : input.timestamp;
|
|
22
|
+
try {
|
|
23
|
+
const result = await request(this.transport, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
path: "/logs",
|
|
26
|
+
json: { ...input, timestamp },
|
|
27
|
+
});
|
|
28
|
+
// `?? false` covers a Connect old enough to predate the field - an
|
|
29
|
+
// absent `attributed` means the server had no attribution to report,
|
|
30
|
+
// which is exactly what `false` says.
|
|
31
|
+
return { delivered: true, attributed: result?.attributed ?? false };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { delivered: false, attributed: false };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/** WhatsApp/SMS phone verification. */
|
|
3
|
+
export class OtpResource {
|
|
4
|
+
transport;
|
|
5
|
+
constructor(transport) {
|
|
6
|
+
this.transport = transport;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Send a code. As with `billing.consumeAddon`, an `Idempotency-Key` always
|
|
10
|
+
* goes along - a retried send would otherwise deliver a second message and
|
|
11
|
+
* invalidate the first code the user is looking at.
|
|
12
|
+
*/
|
|
13
|
+
send(input, options = {}) {
|
|
14
|
+
return request(this.transport, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
path: "/otp/send",
|
|
17
|
+
json: input,
|
|
18
|
+
headers: { "idempotency-key": options.idempotencyKey ?? crypto.randomUUID() },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** Check a 6-digit code against a `verificationId`. */
|
|
22
|
+
verify(input) {
|
|
23
|
+
return request(this.transport, { method: "POST", path: "/otp/verify", json: input });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ConnectError } from "../../errors.js";
|
|
2
|
+
import { request } from "../http.js";
|
|
3
|
+
/**
|
|
4
|
+
* This Connect account's own login sessions - device, approximate location,
|
|
5
|
+
* timestamps - the same list Connect's own account settings page shows the
|
|
6
|
+
* user themselves.
|
|
7
|
+
*
|
|
8
|
+
* **Account-wide, not scoped to this service.** A Connect session is one
|
|
9
|
+
* browser's login to Connect, and it fans out to every partner service's own
|
|
10
|
+
* `sessionToken` - Connect has no per-service session concept to filter by.
|
|
11
|
+
* `list()` returns every session on the account, including ones that
|
|
12
|
+
* originated from a completely different, unrelated service. Requires the
|
|
13
|
+
* `sessions:manage` scope on your API key - unlike `identity`'s base `sso`
|
|
14
|
+
* capability, this is never granted to a zero-scope legacy key by default.
|
|
15
|
+
*
|
|
16
|
+
* Authenticated like `identity.*` - `client_id`+`client_secret`, never a
|
|
17
|
+
* Bearer key - because every call here is really "resolve identity from a
|
|
18
|
+
* `sessionToken`", the same shape `identity.getUser`/`identity.logout` are.
|
|
19
|
+
*/
|
|
20
|
+
export class SessionsResource {
|
|
21
|
+
transport;
|
|
22
|
+
clientId;
|
|
23
|
+
constructor(transport, clientId) {
|
|
24
|
+
this.transport = transport;
|
|
25
|
+
this.clientId = clientId;
|
|
26
|
+
}
|
|
27
|
+
/** Every active session on the account behind `sessionToken`. `isCurrent` marks the one it was minted from. */
|
|
28
|
+
async list(sessionToken) {
|
|
29
|
+
const result = await this.post("/sso/sessions", {
|
|
30
|
+
session_token: sessionToken,
|
|
31
|
+
});
|
|
32
|
+
// See `AppsResource.list` - a collection unwrap has to survive a body
|
|
33
|
+
// that omits the key, or it becomes a browser-side crash two hops away.
|
|
34
|
+
return result.items ?? [];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Ends one specific session by id - not necessarily the one `sessionToken`
|
|
38
|
+
* itself was minted from. Cascades to every partner-service sign-in that
|
|
39
|
+
* originated from that same browser, same as Connect's own "sign out this
|
|
40
|
+
* device".
|
|
41
|
+
*/
|
|
42
|
+
async revoke(sessionToken, sessionId) {
|
|
43
|
+
await this.post("/sso/sessions/revoke", {
|
|
44
|
+
session_token: sessionToken,
|
|
45
|
+
sessionId,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** "Sign out everywhere else" - keeps the session `sessionToken` was minted from, ends every other one. */
|
|
49
|
+
async revokeOthers(sessionToken) {
|
|
50
|
+
return this.post("/sso/sessions/revoke-others", {
|
|
51
|
+
session_token: sessionToken,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
post(path, form) {
|
|
55
|
+
const spec = {
|
|
56
|
+
method: "POST",
|
|
57
|
+
path,
|
|
58
|
+
auth: "none",
|
|
59
|
+
form: {
|
|
60
|
+
...form,
|
|
61
|
+
client_id: this.requireClientId(),
|
|
62
|
+
client_secret: this.transport.apiKey,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return request(this.transport, spec);
|
|
66
|
+
}
|
|
67
|
+
requireClientId() {
|
|
68
|
+
if (!this.clientId) {
|
|
69
|
+
throw new ConnectError({
|
|
70
|
+
status: 0,
|
|
71
|
+
code: "missing_client_id",
|
|
72
|
+
message: "sessions.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Setup tab once SSO is enabled.",
|
|
73
|
+
endpoint: "sessions",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return this.clientId;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the `/api/v1/service/*` surface - the service-partner API,
|
|
3
|
+
* and the only thing this SDK covers.
|
|
4
|
+
*
|
|
5
|
+
* Hand-written rather than generated so the published package carries no
|
|
6
|
+
* dependency on Connect's internals. `src/conformance.test.ts` type-checks
|
|
7
|
+
* these against the server's own DTOs, so drift fails the build here instead
|
|
8
|
+
* of surfacing as a runtime surprise in a partner's integration.
|
|
9
|
+
*
|
|
10
|
+
* Dates cross the wire as ISO 8601 strings and are left that way - parsing
|
|
11
|
+
* them into `Date` would be a lossy guess about what the caller wants, and
|
|
12
|
+
* `new Date(value)` is right there.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/theme.css
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "@heroui/styles";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Connect's own components ship as compiled JS inside `node_modules`, which
|
|
6
|
+
* Tailwind's automatic content detection deliberately skips - so without this
|
|
7
|
+
* every class they paint with (`bg-accent`, `rounded-[0.28em]`, `size-12`, the
|
|
8
|
+
* whole lockup) is simply never generated, and the components render unstyled
|
|
9
|
+
* in a partner app while looking perfect in this repo's own Storybook (which
|
|
10
|
+
* scans `src/` directly). An explicit `@source` is the documented opt-in.
|
|
11
|
+
*
|
|
12
|
+
* Relative to this file, so it resolves correctly from both places it lives:
|
|
13
|
+
* `src/` in this repo, and `dist/` once `finalize-dist.ts` copies it into the
|
|
14
|
+
* published package.
|
|
15
|
+
*/
|
|
16
|
+
@source "./";
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* The shared CosmoOps HeroUI theme.
|
|
20
|
+
*
|
|
21
|
+
* Import this once per service platform and every Connect component - and
|
|
22
|
+
* everything the platform builds beside them - lands on the same palette:
|
|
23
|
+
*
|
|
24
|
+
* @import "tailwindcss";
|
|
25
|
+
* @import "@heroui/styles";
|
|
26
|
+
* @import "@cosmoops/connect-sdk/theme.css";
|
|
27
|
+
*
|
|
28
|
+
* Order matters. Tailwind first, HeroUI's own token layer next, this last, so
|
|
29
|
+
* these values win.
|
|
30
|
+
*
|
|
31
|
+
* Only HeroUI's semantic tokens are defined here - there is no numeric scale
|
|
32
|
+
* and no `primary` in v3, and a component reaching for `bg-primary` or
|
|
33
|
+
* `text-default-500` renders unstyled. Every Connect component paints with
|
|
34
|
+
* these names alone, which is what lets one edit here restyle all of them at
|
|
35
|
+
* once across every platform.
|
|
36
|
+
*
|
|
37
|
+
* To diverge on brand, redefine individual tokens *after* this import rather
|
|
38
|
+
* than forking the file - that keeps you on the shared baseline for everything
|
|
39
|
+
* you did not deliberately change.
|
|
40
|
+
*/
|
|
41
|
+
:root,
|
|
42
|
+
[data-theme="light"] {
|
|
43
|
+
--accent: oklch(62.23% 0.192 145.67);
|
|
44
|
+
--accent-foreground: oklch(99.11% 0 0);
|
|
45
|
+
--accent-soft: oklch(94% 0.03 145.67);
|
|
46
|
+
--background: oklch(97.02% 0.0067 145.67);
|
|
47
|
+
--foreground: oklch(21.03% 0.0067 145.67);
|
|
48
|
+
--muted: oklch(55.17% 0.0134 145.67);
|
|
49
|
+
--border: oklch(90% 0.0067 145.67);
|
|
50
|
+
--separator: oklch(92% 0.0067 145.67);
|
|
51
|
+
--surface: oklch(100% 0.0034 145.67);
|
|
52
|
+
--surface-secondary: oklch(94% 0.0067 145.67);
|
|
53
|
+
--danger: oklch(65.32% 0.2359 12.76);
|
|
54
|
+
--danger-foreground: oklch(99.11% 0 0);
|
|
55
|
+
--warning: oklch(79% 0.16 76);
|
|
56
|
+
--warning-foreground: oklch(21.03% 0.0067 145.67);
|
|
57
|
+
--focus: oklch(62.23% 0.192 145.67);
|
|
58
|
+
--overlay: oklch(100% 0.002 145.67);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.dark,
|
|
62
|
+
[data-theme="dark"] {
|
|
63
|
+
--accent: oklch(62.23% 0.192 145.67);
|
|
64
|
+
--accent-foreground: oklch(99.11% 0 0);
|
|
65
|
+
--accent-soft: oklch(30% 0.05 145.67);
|
|
66
|
+
--background: oklch(17% 0.0067 145.67);
|
|
67
|
+
--foreground: oklch(96% 0.0067 145.67);
|
|
68
|
+
--muted: oklch(68% 0.0134 145.67);
|
|
69
|
+
--border: oklch(28% 0.0067 145.67);
|
|
70
|
+
--separator: oklch(26% 0.0067 145.67);
|
|
71
|
+
--surface: oklch(21% 0.0067 145.67);
|
|
72
|
+
--surface-secondary: oklch(25% 0.0067 145.67);
|
|
73
|
+
--danger: oklch(65.32% 0.2359 12.76);
|
|
74
|
+
--danger-foreground: oklch(99.11% 0 0);
|
|
75
|
+
--warning: oklch(79% 0.16 76);
|
|
76
|
+
--warning-foreground: oklch(17% 0.0067 145.67);
|
|
77
|
+
--focus: oklch(62.23% 0.192 145.67);
|
|
78
|
+
--overlay: oklch(21% 0.002 145.67);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
body {
|
|
82
|
+
background: var(--background);
|
|
83
|
+
color: var(--foreground);
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx @cosmoops/connect-sdk init` - writes the Connect wiring into a Next.js
|
|
3
|
+
* App Router project.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is a CLI and not a plugin
|
|
6
|
+
*
|
|
7
|
+
* Next has no plugin API that injects routes. `next.config` can rewrite and
|
|
8
|
+
* redirect, but both need a real route to point at, and there is no hook that
|
|
9
|
+
* contributes a `page.tsx` from a node_module - the App Router discovers routes
|
|
10
|
+
* by walking the project's own `app/` directory and nothing else. So a package
|
|
11
|
+
* cannot add `/plans` to an app by being installed, however much one would like
|
|
12
|
+
* it to.
|
|
13
|
+
*
|
|
14
|
+
* What it *can* do is make the file that must exist a single line, and then
|
|
15
|
+
* write that file for you. `ConnectPlansPage` is the first half; this is the
|
|
16
|
+
* second. The result is the same as a plugin from the developer's side - one
|
|
17
|
+
* command and the page is there - without pretending an extension point exists.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here overwrites. Every file that already exists is reported as
|
|
20
|
+
* skipped, so re-running after a partial setup is safe and running it on an
|
|
21
|
+
* app that already has Connect wired up does nothing at all.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runInit(argv?: string[]): number;
|
|
24
|
+
/**
|
|
25
|
+
* Only `init` today. Parsed as a subcommand anyway so adding `doctor` or
|
|
26
|
+
* `check` later does not change how the existing one is invoked.
|
|
27
|
+
*
|
|
28
|
+
* A bare path runs `init` against it, so both `connect-sdk init ./my-app` and
|
|
29
|
+
* `connect-sdk ./my-app` work. A first argument that is neither the command nor
|
|
30
|
+
* an existing directory is a typo, and saying so beats reporting that
|
|
31
|
+
* `./innit` contains no App Router - which is true, unhelpful, and points at
|
|
32
|
+
* the wrong mistake.
|
|
33
|
+
*/
|
|
34
|
+
export declare function main(argv: string[]): number;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ConnectAccountSessionsProps {
|
|
2
|
+
className?: string;
|
|
3
|
+
/**
|
|
4
|
+
* Rows shown before the "Show all" toggle collapses the rest. An account
|
|
5
|
+
* with a dozen devices would otherwise push everything below it - the
|
|
6
|
+
* sign-out action included - off the bottom of a modal. Pass `0` to
|
|
7
|
+
* always render every row.
|
|
8
|
+
*/
|
|
9
|
+
maxVisible?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Every device signed into this Connect account - not scoped to this
|
|
13
|
+
* service, see `ConnectUserSession`'s doc comment. Owns the one
|
|
14
|
+
* `useConnectSessions()` call for this modal; every revoke action here
|
|
15
|
+
* mutates through it and re-reads the list itself, since nothing else
|
|
16
|
+
* invalidates it automatically - see `mutateResource`'s doc comment.
|
|
17
|
+
*
|
|
18
|
+
* That re-read is `invalidateResource("/sessions")` **then** `refetch()`,
|
|
19
|
+
* and both halves are load-bearing. `mutateResource` evicts only the path it
|
|
20
|
+
* posted to - `/sessions/revoke` - so `/sessions` keeps its cached promise,
|
|
21
|
+
* and `refetch()` alone just re-runs the effect and is handed that same
|
|
22
|
+
* resolved promise back. The row stayed on screen and the revoke looked like
|
|
23
|
+
* it had silently failed, when in fact the server had done exactly as asked.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ConnectAccountSessions({ className, maxVisible }: ConnectAccountSessionsProps): import("react").JSX.Element | null;
|