@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.
Files changed (126) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +446 -0
  3. package/dist/cjs/cli/bin.js +16 -0
  4. package/dist/cjs/cli/init.js +179 -0
  5. package/dist/cjs/client/components/account-sessions.js +112 -0
  6. package/dist/cjs/client/components/apps-grid.js +46 -0
  7. package/dist/cjs/client/components/auth-buttons.js +42 -0
  8. package/dist/cjs/client/components/feature-gate.js +61 -0
  9. package/dist/cjs/client/components/link-button.js +24 -0
  10. package/dist/cjs/client/components/logo.js +27 -0
  11. package/dist/cjs/client/components/plan-pricing.js +166 -0
  12. package/dist/cjs/client/components/plans-page.js +39 -0
  13. package/dist/cjs/client/components/plans.js +212 -0
  14. package/dist/cjs/client/components/readiness.js +117 -0
  15. package/dist/cjs/client/components/subscribe-dialog.js +60 -0
  16. package/dist/cjs/client/components/subscription.js +189 -0
  17. package/dist/cjs/client/components/usage-meter.js +68 -0
  18. package/dist/cjs/client/components/user-menu.js +172 -0
  19. package/dist/cjs/client/index.js +99 -0
  20. package/dist/cjs/client/mark.js +16 -0
  21. package/dist/cjs/client/provider.js +259 -0
  22. package/dist/cjs/client/use-checkout.js +173 -0
  23. package/dist/cjs/errors.js +74 -0
  24. package/dist/cjs/format-relative-time.js +18 -0
  25. package/dist/cjs/helpers.js +30 -0
  26. package/dist/cjs/index.js +22 -0
  27. package/dist/cjs/next/index.js +311 -0
  28. package/dist/cjs/package.json +3 -0
  29. package/dist/cjs/server/client.js +114 -0
  30. package/dist/cjs/server/handler.js +333 -0
  31. package/dist/cjs/server/http.js +160 -0
  32. package/dist/cjs/server/index.js +18 -0
  33. package/dist/cjs/server/resources/access.js +28 -0
  34. package/dist/cjs/server/resources/ai.js +36 -0
  35. package/dist/cjs/server/resources/apps.js +30 -0
  36. package/dist/cjs/server/resources/billing.js +71 -0
  37. package/dist/cjs/server/resources/drive.js +39 -0
  38. package/dist/cjs/server/resources/entitlements.js +49 -0
  39. package/dist/cjs/server/resources/identity.js +194 -0
  40. package/dist/cjs/server/resources/logs.js +41 -0
  41. package/dist/cjs/server/resources/otp.js +29 -0
  42. package/dist/cjs/server/resources/sessions.js +82 -0
  43. package/dist/cjs/types.js +15 -0
  44. package/dist/esm/cli/bin.js +13 -0
  45. package/dist/esm/cli/init.js +174 -0
  46. package/dist/esm/client/components/account-sessions.js +109 -0
  47. package/dist/esm/client/components/apps-grid.js +43 -0
  48. package/dist/esm/client/components/auth-buttons.js +38 -0
  49. package/dist/esm/client/components/feature-gate.js +57 -0
  50. package/dist/esm/client/components/link-button.js +21 -0
  51. package/dist/esm/client/components/logo.js +24 -0
  52. package/dist/esm/client/components/plan-pricing.js +156 -0
  53. package/dist/esm/client/components/plans-page.js +36 -0
  54. package/dist/esm/client/components/plans.js +209 -0
  55. package/dist/esm/client/components/readiness.js +113 -0
  56. package/dist/esm/client/components/subscribe-dialog.js +57 -0
  57. package/dist/esm/client/components/subscription.js +181 -0
  58. package/dist/esm/client/components/usage-meter.js +65 -0
  59. package/dist/esm/client/components/user-menu.js +164 -0
  60. package/dist/esm/client/index.js +47 -0
  61. package/dist/esm/client/mark.js +13 -0
  62. package/dist/esm/client/provider.js +247 -0
  63. package/dist/esm/client/use-checkout.js +168 -0
  64. package/dist/esm/errors.js +69 -0
  65. package/dist/esm/format-relative-time.js +15 -0
  66. package/dist/esm/helpers.js +26 -0
  67. package/dist/esm/index.js +15 -0
  68. package/dist/esm/next/index.js +279 -0
  69. package/dist/esm/package.json +3 -0
  70. package/dist/esm/server/client.js +110 -0
  71. package/dist/esm/server/handler.js +330 -0
  72. package/dist/esm/server/http.js +156 -0
  73. package/dist/esm/server/index.js +10 -0
  74. package/dist/esm/server/resources/access.js +24 -0
  75. package/dist/esm/server/resources/ai.js +32 -0
  76. package/dist/esm/server/resources/apps.js +26 -0
  77. package/dist/esm/server/resources/billing.js +67 -0
  78. package/dist/esm/server/resources/drive.js +35 -0
  79. package/dist/esm/server/resources/entitlements.js +45 -0
  80. package/dist/esm/server/resources/identity.js +189 -0
  81. package/dist/esm/server/resources/logs.js +37 -0
  82. package/dist/esm/server/resources/otp.js +25 -0
  83. package/dist/esm/server/resources/sessions.js +78 -0
  84. package/dist/esm/types.js +14 -0
  85. package/dist/theme.css +84 -0
  86. package/dist/types/cli/bin.d.ts +1 -0
  87. package/dist/types/cli/init.d.ts +34 -0
  88. package/dist/types/client/components/account-sessions.d.ts +25 -0
  89. package/dist/types/client/components/apps-grid.d.ts +20 -0
  90. package/dist/types/client/components/auth-buttons.d.ts +31 -0
  91. package/dist/types/client/components/feature-gate.d.ts +57 -0
  92. package/dist/types/client/components/link-button.d.ts +20 -0
  93. package/dist/types/client/components/logo.d.ts +35 -0
  94. package/dist/types/client/components/plan-pricing.d.ts +96 -0
  95. package/dist/types/client/components/plans-page.d.ts +36 -0
  96. package/dist/types/client/components/plans.d.ts +34 -0
  97. package/dist/types/client/components/readiness.d.ts +52 -0
  98. package/dist/types/client/components/subscribe-dialog.d.ts +34 -0
  99. package/dist/types/client/components/subscription.d.ts +123 -0
  100. package/dist/types/client/components/usage-meter.d.ts +24 -0
  101. package/dist/types/client/components/user-menu.d.ts +86 -0
  102. package/dist/types/client/index.d.ts +47 -0
  103. package/dist/types/client/mark.d.ts +11 -0
  104. package/dist/types/client/provider.d.ts +209 -0
  105. package/dist/types/client/use-checkout.d.ts +93 -0
  106. package/dist/types/errors.d.ts +62 -0
  107. package/dist/types/format-relative-time.d.ts +2 -0
  108. package/dist/types/helpers.d.ts +33 -0
  109. package/dist/types/index.d.ts +17 -0
  110. package/dist/types/next/index.d.ts +136 -0
  111. package/dist/types/server/client.d.ts +83 -0
  112. package/dist/types/server/handler.d.ts +67 -0
  113. package/dist/types/server/http.d.ts +52 -0
  114. package/dist/types/server/index.d.ts +15 -0
  115. package/dist/types/server/resources/access.d.ts +17 -0
  116. package/dist/types/server/resources/ai.d.ts +24 -0
  117. package/dist/types/server/resources/apps.d.ts +12 -0
  118. package/dist/types/server/resources/billing.d.ts +41 -0
  119. package/dist/types/server/resources/drive.d.ts +26 -0
  120. package/dist/types/server/resources/entitlements.d.ts +31 -0
  121. package/dist/types/server/resources/identity.d.ts +76 -0
  122. package/dist/types/server/resources/logs.d.ts +20 -0
  123. package/dist/types/server/resources/otp.d.ts +17 -0
  124. package/dist/types/server/resources/sessions.d.ts +37 -0
  125. package/dist/types/types.d.ts +434 -0
  126. 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;