@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,168 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useConnect } from "./provider.js";
4
+ /** How often `/entitlements` is re-read while a checkout is open. */
5
+ const POLL_INTERVAL_MS = 3_000;
6
+ /** Give up polling after this long. Long enough for a card 3-D Secure detour, short enough not to poll a forgotten tab all day. */
7
+ const POLL_DEADLINE_MS = 15 * 60 * 1000;
8
+ /**
9
+ * Grace period after the Connect window closes before calling it abandoned.
10
+ *
11
+ * The window can close a beat before Connect's own write lands - and on the
12
+ * paid path the subscription is created `"created"` and confirmed by a
13
+ * webhook, so "closed" and "settled" are genuinely different moments. A few
14
+ * extra polls here is the difference between a correct success and telling a
15
+ * customer who just paid that nothing happened.
16
+ */
17
+ const CLOSE_GRACE_MS = 12_000;
18
+ /**
19
+ * Drives a checkout that completes on Connect, not here.
20
+ *
21
+ * The partner's page cannot complete a subscription itself - see
22
+ * `ConnectPlans`' doc comment for why that is deliberate - so the flow is:
23
+ * open Connect, watch `/entitlements` until the entitlement changes, then
24
+ * refresh the session so every Connect component on the page repaints.
25
+ *
26
+ * **The result is read from Connect's own state, never from the window.**
27
+ * A closed window proves nothing: the customer may have paid and closed it,
28
+ * or opened it, thought better of it, and closed it. Polling is what tells
29
+ * the two apart, which is why the window closing only starts a short grace
30
+ * period rather than ending the run.
31
+ */
32
+ export function useConnectCheckout() {
33
+ const { fetchResource, invalidateResource, refresh } = useConnect();
34
+ const [state, setState] = useState({ status: "idle", url: null });
35
+ // Everything the poll loop needs lives in a ref: it runs from a timer, and
36
+ // re-creating the loop whenever a piece of it changed identity would
37
+ // restart the very interval that is meant to survive re-renders.
38
+ const run = useRef(null);
39
+ const generation = useRef(0);
40
+ const stop = useCallback(() => {
41
+ generation.current += 1;
42
+ run.current = null;
43
+ }, []);
44
+ const reset = useCallback(() => {
45
+ stop();
46
+ setState({ status: "idle", url: null });
47
+ }, [stop]);
48
+ const start = useCallback(({ url, isSettled }) => {
49
+ stop();
50
+ // Opened synchronously inside the press handler - a `window.open` after
51
+ // an await is what pop-up blockers exist to stop, and it would be
52
+ // blocked in every browser regardless of the user's settings.
53
+ //
54
+ // No `noopener`: with it set, `window.open` returns `null` by
55
+ // specification, and this hook would then read every successfully
56
+ // opened checkout as blocked. The handle is the whole point - it is
57
+ // how the grace period below knows the customer closed the window.
58
+ // The window it hands an `opener` to is Connect's own origin, and
59
+ // cross-origin access is limited to navigating this page, which
60
+ // Connect does not do.
61
+ const opened = window.open(url, "connect_checkout", "popup,width=520,height=780");
62
+ if (!opened) {
63
+ // Not a failure to report as an error: the customer can still get
64
+ // there, they just have to click the link themselves.
65
+ setState({ status: "blocked", url });
66
+ return;
67
+ }
68
+ run.current = {
69
+ url,
70
+ isSettled,
71
+ window: opened,
72
+ closedAt: null,
73
+ startedAt: Date.now(),
74
+ generation: generation.current,
75
+ };
76
+ setState({ status: "waiting", url });
77
+ }, [stop]);
78
+ useEffect(() => {
79
+ // One interval for the hook's whole life rather than one per run: it does
80
+ // nothing while `run.current` is null, and a run that starts from a press
81
+ // handler picks it up on the next tick without re-creating the timer.
82
+ let inFlight = false;
83
+ const poll = async () => {
84
+ const current = run.current;
85
+ if (!current || inFlight)
86
+ return;
87
+ const now = Date.now();
88
+ const expired = now - current.startedAt > POLL_DEADLINE_MS;
89
+ // `closed` is readable cross-origin; nothing else about the window is.
90
+ if (current.window?.closed && current.closedAt === null)
91
+ current.closedAt = now;
92
+ const graceElapsed = current.closedAt !== null && now - current.closedAt > CLOSE_GRACE_MS;
93
+ inFlight = true;
94
+ let settled = false;
95
+ try {
96
+ invalidateResource("/entitlements");
97
+ settled = current.isSettled(await fetchResource("/entitlements"));
98
+ }
99
+ catch {
100
+ // A bridge blip mid-checkout is not an answer either way - keep
101
+ // polling and let the deadline decide.
102
+ }
103
+ finally {
104
+ inFlight = false;
105
+ }
106
+ // The run may have been superseded or reset while that read was in
107
+ // flight; writing state from it now would resurrect a dead run.
108
+ if (run.current?.generation !== current.generation)
109
+ return;
110
+ if (settled) {
111
+ stop();
112
+ setState({ status: "completed", url: current.url });
113
+ // Session-wide, not just this path: `/session` carries its own
114
+ // `subscription` block, and every other Connect component on the
115
+ // page reads through the same cache.
116
+ void refresh();
117
+ return;
118
+ }
119
+ if (graceElapsed || expired) {
120
+ stop();
121
+ setState({ status: "abandoned", url: current.url });
122
+ }
123
+ };
124
+ const interval = setInterval(() => void poll(), POLL_INTERVAL_MS);
125
+ // Coming back to this tab is the strongest hint available that something
126
+ // happened in the other one - poll immediately rather than waiting out
127
+ // the interval the customer is now staring at.
128
+ const onVisible = () => {
129
+ if (document.visibilityState === "visible")
130
+ void poll();
131
+ };
132
+ document.addEventListener("visibilitychange", onVisible);
133
+ return () => {
134
+ clearInterval(interval);
135
+ document.removeEventListener("visibilitychange", onVisible);
136
+ };
137
+ }, [fetchResource, invalidateResource, refresh, stop]);
138
+ return { ...state, start, reset };
139
+ }
140
+ /**
141
+ * The Connect URL that cancels a subscription.
142
+ *
143
+ * Its own builder rather than a mode on `buildCheckoutUrl`, because it takes
144
+ * no plan: cancelling is about the subscription, not about what to move it to.
145
+ * Lands on the same kind of intercepted modal the other two do
146
+ * (`app/@modal/(.)billing/.../cancel`).
147
+ */
148
+ export function buildCancelUrl({ appUrl, subscriptionId }) {
149
+ return new URL(`/billing/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`, appUrl).toString();
150
+ }
151
+ /**
152
+ * The Connect URL that completes a subscribe or a plan change.
153
+ *
154
+ * Both land on a modal Connect renders over the relevant page (an intercepted
155
+ * parallel route - `app/@modal/(.)services/...` and
156
+ * `app/@modal/(.)billing/...`), so the customer arrives already looking at the
157
+ * confirmation for the plan they picked here, not at a catalogue they have to
158
+ * find it in again.
159
+ */
160
+ export function buildCheckoutUrl({ appUrl, serviceId, planId, subscriptionId, coupon, }) {
161
+ const path = subscriptionId
162
+ ? `/billing/subscriptions/${encodeURIComponent(subscriptionId)}/change-plan/${encodeURIComponent(planId)}`
163
+ : `/services/${encodeURIComponent(serviceId)}/subscribe/${encodeURIComponent(planId)}`;
164
+ const url = new URL(path, appUrl);
165
+ if (coupon?.trim())
166
+ url.searchParams.set("coupon", coupon.trim());
167
+ return url.toString();
168
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Every failure the Connect service API can report, as one class.
3
+ *
4
+ * The API speaks two error dialects and this collapses them: the
5
+ * `/api/v1/service/*` endpoints use `{ error: { code, message } }`, while the
6
+ * three `/sso/*` ones use OAuth2's `{ error, error_description }` (RFC 6749
7
+ * §5.2). Both land here with `code` populated, so integrating code branches on
8
+ * one field either way.
9
+ */
10
+ export class ConnectError extends Error {
11
+ name = "ConnectError";
12
+ /** HTTP status. `0` when the request never got a response (network failure, timeout). */
13
+ status;
14
+ /** `rate_limited`, `forbidden`, `invalid_token`, ... - branch on this, never on `message`. */
15
+ code;
16
+ /** From `Retry-After` on a 429, in milliseconds. `null` otherwise. */
17
+ retryAfterMs;
18
+ /** The endpoint that failed, e.g. `POST /sso/token` - present on every error. */
19
+ endpoint;
20
+ /** Parsed response body, when there was one. */
21
+ body;
22
+ constructor(init) {
23
+ super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
24
+ this.status = init.status;
25
+ this.code = init.code;
26
+ this.endpoint = init.endpoint;
27
+ this.retryAfterMs = init.retryAfterMs ?? null;
28
+ this.body = init.body;
29
+ }
30
+ /**
31
+ * Whether trying the exact same request again could plausibly succeed -
32
+ * throttling, a transient server fault, or no response at all. The SDK
33
+ * already retries these internally (see `ConnectClientOptions.maxRetries`);
34
+ * this is for callers deciding whether to queue their own retry after the
35
+ * SDK has given up.
36
+ */
37
+ get isRetryable() {
38
+ return this.status === 0 || this.status === 429 || this.status >= 500;
39
+ }
40
+ /**
41
+ * Whether the user's Connect sign-in is over and they need to go back
42
+ * through `identity.authorizeUrl()`. Collapses every session-side failure,
43
+ * matching how the API reports them - unknown, expired, revoked, or issued
44
+ * to a different service all arrive as one `invalid_token`.
45
+ */
46
+ get isSessionExpired() {
47
+ return this.code === "invalid_token";
48
+ }
49
+ /**
50
+ * Whether *your own API key* is dead - revoked or past its expiry - rather
51
+ * than the request being wrong.
52
+ *
53
+ * Connect answers `403` for every credential failure and keeps the code
54
+ * uniform (`forbidden`) whenever telling them apart would help someone
55
+ * guessing keys. These two are the exception: reaching them required
56
+ * presenting a key whose hash matched, so you already hold it, and what you
57
+ * need to know is that no retry and no scope change will fix this - the key
58
+ * has to be reissued from the service's Setup tab.
59
+ *
60
+ * Worth alerting on. It is the one failure that will not resolve itself and
61
+ * takes down every call your integration makes.
62
+ */
63
+ get isKeyRejected() {
64
+ return this.code === "key_revoked" || this.code === "key_expired";
65
+ }
66
+ }
67
+ export function isConnectError(error) {
68
+ return error instanceof ConnectError;
69
+ }
@@ -0,0 +1,15 @@
1
+ /** "3m ago" / "2h ago" / "5d ago", falling back to a plain date past 30 days. */
2
+ export function formatRelativeTime(date) {
3
+ const diffMinutes = Math.round((Date.now() - date.getTime()) / 60_000);
4
+ if (diffMinutes < 1)
5
+ return "just now";
6
+ if (diffMinutes < 60)
7
+ return `${diffMinutes}m ago`;
8
+ const diffHours = Math.round(diffMinutes / 60);
9
+ if (diffHours < 24)
10
+ return `${diffHours}h ago`;
11
+ const diffDays = Math.round(diffHours / 24);
12
+ if (diffDays < 30)
13
+ return `${diffDays}d ago`;
14
+ return date.toLocaleDateString();
15
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The blockers that actually deny access, in the order to show them.
3
+ *
4
+ * Advisory ones (no saved payment method mid-subscription, say) are worth
5
+ * surfacing but are not a reason to lock anyone out today - filtering them here
6
+ * keeps that judgement in one place rather than in every call site.
7
+ */
8
+ export function blockingBlockers(readiness) {
9
+ return readiness.readiness.blockers.filter((blocker) => blocker.severity === "blocking");
10
+ }
11
+ /**
12
+ * Coerces either wire shape into `PlanFeature[]`.
13
+ *
14
+ * A bare key becomes `{ key, name: key, description: "" }` - the same
15
+ * fallback the server applies for a plan older than its service's feature
16
+ * catalog, so an un-upgraded Connect renders exactly what it always did
17
+ * rather than a column of blanks.
18
+ *
19
+ * Applied inside `BillingResource.listPlans`, so anything reading a plan
20
+ * through the SDK or its bridge already has the normalised shape and no
21
+ * component needs to know this ambiguity exists. Exported for a partner who
22
+ * hand-rolled a bridge instead of using `createConnectHandler`.
23
+ */
24
+ export function normalizePlanFeatures(features) {
25
+ return (features ?? []).map((feature) => typeof feature === "string" ? { key: feature, name: feature, description: "" } : feature);
26
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@cosmoops/connect-sdk` - the official client for the CosmoOps Connect
3
+ * **service-partner API** (`/api/v1/service/*`).
4
+ *
5
+ * This root entry is the isomorphic surface: wire types, the error class, and
6
+ * pure helpers. It pulls in neither Node-only code nor React, so it is safe to
7
+ * import from anywhere.
8
+ *
9
+ * - `@cosmoops/connect-sdk/server` - `ConnectClient` and `createConnectHandler`.
10
+ * Holds the API key. Never import this from browser code.
11
+ * - `@cosmoops/connect-sdk/client` - React components and hooks. Talks to your
12
+ * own origin only.
13
+ */
14
+ export { ConnectError, isConnectError } from "./errors.js";
15
+ export { blockingBlockers, normalizePlanFeatures } from "./helpers.js";
@@ -0,0 +1,279 @@
1
+ import { ConnectClient } from "../server/client.js";
2
+ import { createConnectHandler } from "../server/handler.js";
3
+ /**
4
+ * Plug-and-play wiring for Next.js.
5
+ *
6
+ * Everything below is server-side. Nothing here is *statically* imported from
7
+ * `next`, so this module loads in App Router route handlers, the Node runtime
8
+ * and the edge runtime alike - and needs no peer dependency. The one place
9
+ * that does reach for `next/headers` does it behind a dynamic import, inside
10
+ * the function that needs it, and says so when it isn't there.
11
+ *
12
+ * The whole integration is two files:
13
+ *
14
+ * ```ts
15
+ * // app/api/connect/[...connect]/route.ts
16
+ * export { GET, POST } from "@cosmoops/connect-sdk/next";
17
+ * ```
18
+ *
19
+ * ```tsx
20
+ * // app/layout.tsx
21
+ * import { ConnectProvider } from "@cosmoops/connect-sdk/client";
22
+ * <ConnectProvider>{children}</ConnectProvider>
23
+ * ```
24
+ *
25
+ * Plus one line in `next.config.ts`, which is optional but catches the single
26
+ * misconfiguration that fails silently:
27
+ *
28
+ * ```ts
29
+ * import { withConnect } from "@cosmoops/connect-sdk/next";
30
+ * export default withConnect({});
31
+ * ```
32
+ *
33
+ * Configuration comes from the environment, so none of those files take
34
+ * arguments:
35
+ *
36
+ * | Variable | Required | Purpose |
37
+ * | --- | --- | --- |
38
+ * | `CONNECT_API_KEY` | yes | server-side credential |
39
+ * | `CONNECT_CLIENT_ID` | yes | OAuth client id |
40
+ * | `NEXT_PUBLIC_CONNECT_CLIENT_ID` | yes | same value, for `<ConnectProvider>` |
41
+ * | `CONNECT_APP_URL` | no | non-production Connect instance |
42
+ * | `CONNECT_SESSION_COOKIE` | no | session cookie name |
43
+ *
44
+ * Register `{your origin}/api/connect/callback` as a redirect URI in the
45
+ * service's Setup tab. That is the only step this cannot do for you.
46
+ */
47
+ const DEFAULT_COOKIE = "connect_session";
48
+ function requireEnv(name) {
49
+ const value = process.env[name];
50
+ if (!value) {
51
+ throw new Error(`@cosmoops/connect-sdk/next: ${name} is not set. Add it to your environment - see the module docs for the full list.`);
52
+ }
53
+ return value;
54
+ }
55
+ let cached = null;
56
+ /**
57
+ * The shared server client, built from the environment on first use.
58
+ *
59
+ * Lazy on purpose: reading `CONNECT_API_KEY` at module load would throw during
60
+ * `next build`, where route modules are imported but never invoked and secrets
61
+ * are frequently absent.
62
+ */
63
+ export function connectClient(overrides = {}) {
64
+ if (cached && Object.keys(overrides).length === 0)
65
+ return cached;
66
+ const client = new ConnectClient({
67
+ apiKey: requireEnv("CONNECT_API_KEY"),
68
+ clientId: requireEnv("CONNECT_CLIENT_ID"),
69
+ ...(process.env.CONNECT_APP_URL ? { baseUrl: process.env.CONNECT_APP_URL } : {}),
70
+ ...overrides,
71
+ });
72
+ if (Object.keys(overrides).length === 0)
73
+ cached = client;
74
+ return client;
75
+ }
76
+ /**
77
+ * An httpOnly cookie holding the opaque `sessionToken`.
78
+ *
79
+ * Not signed or encrypted, deliberately. The token is opaque and useless
80
+ * without the `client_secret` that never leaves the server, so a tampered
81
+ * value buys an attacker a 401 rather than a forged identity - and a signing
82
+ * secret would be one more thing to rotate for protection already provided by
83
+ * the token's own design. `SameSite=Lax` because `Strict` would withhold the
84
+ * cookie on the redirect back from Connect.
85
+ */
86
+ export function cookieSessionStore(cookieName = process.env.CONNECT_SESSION_COOKIE ?? DEFAULT_COOKIE) {
87
+ const serialise = (value, maxAge, secure) => [
88
+ `${cookieName}=${encodeURIComponent(value)}`,
89
+ "Path=/",
90
+ "HttpOnly",
91
+ "SameSite=Lax",
92
+ secure ? "Secure" : "",
93
+ `Max-Age=${maxAge}`,
94
+ ]
95
+ .filter(Boolean)
96
+ .join("; ");
97
+ const isSecure = (request) => new URL(request.url).protocol === "https:";
98
+ return {
99
+ read(request) {
100
+ const header = request.headers.get("cookie");
101
+ if (!header)
102
+ return null;
103
+ for (const part of header.split(";")) {
104
+ const [key, ...rest] = part.trim().split("=");
105
+ if (key === cookieName)
106
+ return decodeURIComponent(rest.join("=")) || null;
107
+ }
108
+ return null;
109
+ },
110
+ write(request, response, sessionToken) {
111
+ // ~15 days, matching the token's own lifetime - a cookie that outlives
112
+ // the token it carries only produces confident 401s.
113
+ response.headers.append("set-cookie", serialise(sessionToken, 15 * 24 * 60 * 60, isSecure(request)));
114
+ },
115
+ clear(request, response) {
116
+ response.headers.append("set-cookie", serialise("", 0, isSecure(request)));
117
+ },
118
+ };
119
+ }
120
+ /**
121
+ * The catch-all route handlers, ready to re-export.
122
+ *
123
+ * Only needed when you are changing something - a different mount point, a
124
+ * session store that isn't a cookie, a post-login destination that isn't `/`.
125
+ * The zero-configuration case is the `GET` / `POST` re-export below, which is
126
+ * this function with no arguments.
127
+ *
128
+ * ```ts
129
+ * // app/api/connect/[...connect]/route.ts
130
+ * import { createConnectHandlers } from "@cosmoops/connect-sdk/next";
131
+ * export const { GET, POST } = createConnectHandlers({ basePath: "/api/auth" });
132
+ * ```
133
+ *
134
+ * `GET` and `POST` are the same function - the bridge routes on method and
135
+ * path itself, so Next only needs to know both verbs reach it.
136
+ */
137
+ export function createConnectHandlers(options = {}) {
138
+ const handler = createConnectHandler({
139
+ client: options.client ?? connectClient(),
140
+ session: options.session ?? cookieSessionStore(),
141
+ ...(options.basePath ? { basePath: options.basePath } : {}),
142
+ ...(options.defaultRedirect ? { defaultRedirect: options.defaultRedirect } : {}),
143
+ });
144
+ return { GET: handler, POST: handler };
145
+ }
146
+ let defaultHandler = null;
147
+ /**
148
+ * The bridge itself, for the default mount at `/api/connect`.
149
+ *
150
+ * Built on the first request rather than at module load, for the same reason
151
+ * `connectClient` is lazy: `next build` imports every route module, and an
152
+ * environment-reading constructor at the top level turns a missing secret in
153
+ * CI into a failed build of routes that were never going to run there.
154
+ */
155
+ async function handle(request) {
156
+ defaultHandler ??= createConnectHandler({
157
+ client: connectClient(),
158
+ session: cookieSessionStore(),
159
+ });
160
+ return defaultHandler(request);
161
+ }
162
+ /**
163
+ * The one-line backend.
164
+ *
165
+ * ```ts
166
+ * // app/api/connect/[...connect]/route.ts
167
+ * export { GET, POST } from "@cosmoops/connect-sdk/next";
168
+ * ```
169
+ *
170
+ * That gives you `/signin`, `/callback`, the session cookie, and the
171
+ * `/session` `/entitlements` `/plans` `/apps` `/addons` `/sessions` `/logout`
172
+ * reads the browser half calls. Reach for `createConnectHandlers` instead only
173
+ * when you need to configure one of them.
174
+ */
175
+ export { handle as GET, handle as POST };
176
+ /**
177
+ * Rebuilds a `Request` from the headers of the Server Component, Route Handler
178
+ * or Server Action currently rendering.
179
+ *
180
+ * `next/headers` is imported dynamically and only from here, so this module
181
+ * still has no static dependency on `next` - which is what lets it load on the
182
+ * edge runtime and in a non-Next backend that only wants `createConnectHandler`.
183
+ */
184
+ async function requestFromHeaders() {
185
+ let read;
186
+ try {
187
+ ({ headers: read } = await import("next/headers"));
188
+ }
189
+ catch (cause) {
190
+ throw new Error("@cosmoops/connect-sdk/next: getConnectSession() with no arguments needs `next/headers`, which only exists inside a Server Component, Route Handler or Server Action. Pass the `Request` explicitly anywhere else.", { cause });
191
+ }
192
+ // Deliberately outside the try. `headers()` *throws* to tell Next this route
193
+ // is dynamic - `DYNAMIC_SERVER_USAGE` is control flow, not a fault - and a
194
+ // catch around it converts a route that should have opted out of prerendering
195
+ // into a failed build. Only the import is guarded, because only the import
196
+ // can be legitimately absent.
197
+ const incoming = (await read());
198
+ // Only the cookie header is actually read, but the URL has to be well-formed
199
+ // and its protocol is what a custom session store would use to decide
200
+ // `Secure` - so reconstruct the real one rather than hardcoding localhost.
201
+ const host = incoming.get("x-forwarded-host") ?? incoming.get("host") ?? "localhost";
202
+ const proto = incoming.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
203
+ return new Request(`${proto}://${host}/`, { headers: incoming });
204
+ }
205
+ /**
206
+ * The signed-in user for a Server Component, Route Handler or Server Action,
207
+ * or `null`.
208
+ *
209
+ * ```tsx
210
+ * // app/dashboard/page.tsx
211
+ * const session = await getConnectSession();
212
+ * if (!session) redirect("/api/connect/signin?next=/dashboard");
213
+ * ```
214
+ *
215
+ * Pass a `Request` to use it somewhere `next/headers` does not exist - a
216
+ * framework-agnostic handler, a test, a custom server. Both forms take the
217
+ * same path afterwards; the argument is only how the cookie is found.
218
+ *
219
+ * Reads `cookies()` transitively, so under `cacheComponents` this belongs
220
+ * inside a `<Suspense>` boundary (or a `'use cache: private'` function) like
221
+ * any other request-scoped read - a page that awaits it above every boundary
222
+ * gives up its static shell.
223
+ *
224
+ * Returns `null` for every failure - an expired session and a missing one both
225
+ * mean "not signed in", and a page guard should not need a try/catch.
226
+ */
227
+ export async function getConnectSession(request, options = {}) {
228
+ const store = options.session ?? cookieSessionStore();
229
+ const token = await store.read(request ?? (await requestFromHeaders()));
230
+ if (!token)
231
+ return null;
232
+ try {
233
+ return await (options.client ?? connectClient()).identity.getUser(token);
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
239
+ /**
240
+ * `next.config` wrapper.
241
+ *
242
+ * Asserts, at config load, that the public client id is present - the one
243
+ * variable whose absence fails silently in the browser instead of loudly on
244
+ * the server, because `NEXT_PUBLIC_*` is inlined at build time and an unset
245
+ * one simply becomes `undefined`. That is its whole job.
246
+ *
247
+ * In particular it does **not** replace the bridge route, and cannot: Next has
248
+ * no plugin API that contributes a route from a `node_modules` package - the
249
+ * App Router discovers routes by walking your own `app/` directory and nothing
250
+ * else, and a `next.config` rewrite still needs a real route to point at. So
251
+ * `app/api/connect/[...connect]/route.ts` has to exist whether or not you use
252
+ * this wrapper. It is one line, and `npx @cosmoops/connect-sdk init` writes it:
253
+ *
254
+ * ```ts
255
+ * export { GET, POST } from "@cosmoops/connect-sdk/next";
256
+ * ```
257
+ *
258
+ * The two are independent - the route works with a plain config, and this
259
+ * wrapper is useful with no route at all. Using both is just the default setup.
260
+ *
261
+ * It does **not** add this package to `transpilePackages`, and you should not
262
+ * either. `dist/` is already ES2022 with `"use client"` hoisted to the top of
263
+ * every browser file, so Turbopack and webpack both consume it as-is;
264
+ * transpiling it again only costs build time. An earlier version of this
265
+ * wrapper added the entry, which is why it may still be in your config.
266
+ *
267
+ * ```ts
268
+ * import { withConnect } from "@cosmoops/connect-sdk/next";
269
+ * export default withConnect({ });
270
+ * ```
271
+ */
272
+ export function withConnect(config = {}) {
273
+ if (!process.env.NEXT_PUBLIC_CONNECT_CLIENT_ID && process.env.NODE_ENV !== "test") {
274
+ console.warn("[@cosmoops/connect-sdk] NEXT_PUBLIC_CONNECT_CLIENT_ID is not set - <ConnectProvider> will not be able to start a sign-in.");
275
+ }
276
+ return config;
277
+ }
278
+ export { createConnectHandler } from "../server/handler.js";
279
+ export { ConnectClient } from "../server/client.js";
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }