@cosmoops/connect-sdk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -245,6 +245,68 @@ it above every boundary gives up its static shell.
245
245
  You do **not** need `transpilePackages`. `dist/` is ES2022 with `"use client"`
246
246
  already hoisted, so Turbopack and webpack consume it as-is.
247
247
 
248
+ ## Route protection and `proxy.ts`
249
+
250
+ Nothing here is auto-wired, and nothing needs to be: this SDK ships no proxy and
251
+ never writes one. Next 16 renamed Middleware to **Proxy** — a single `proxy.ts`
252
+ at the project root (or in `src/`, beside `app/`).
253
+
254
+ You only need one if you want unauthenticated visitors bounced *before* a page
255
+ renders. If you are happy redirecting inside the page, `getConnectSession()`
256
+ above already does the job and there is nothing to add.
257
+
258
+ If you do add one, keep it to a cookie **presence** check:
259
+
260
+ ```ts
261
+ // proxy.ts
262
+ import { NextResponse } from "next/server";
263
+ import type { NextRequest } from "next/server";
264
+
265
+ const COOKIE = process.env.CONNECT_SESSION_COOKIE ?? "connect_session";
266
+
267
+ export function proxy(request: NextRequest) {
268
+ if (request.cookies.has(COOKIE)) return NextResponse.next();
269
+
270
+ const signin = new URL("/api/connect/signin", request.url);
271
+ signin.searchParams.set("next", request.nextUrl.pathname);
272
+ return NextResponse.redirect(signin);
273
+ }
274
+
275
+ // Must not match /api/connect/* — the sign-in route has no session cookie yet,
276
+ // so matching it would redirect the sign-in to itself.
277
+ export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };
278
+ ```
279
+
280
+ **A cookie is not a session, and this check is not authorization.** The cookie
281
+ holds an opaque Connect `sessionToken`; deciding whether it is still valid means
282
+ asking Connect, and proxy runs on every matched request — so a real check here
283
+ would put a network round trip in front of each one. Next's own guidance says
284
+ the same thing: proxy is for [optimistic
285
+ checks](https://nextjs.org/docs/app/guides/authentication#optimistic-checks-with-proxy-optional),
286
+ "not ... a full session management or authorization solution."
287
+
288
+ An expired or revoked token still arrives as a cookie and still passes the
289
+ snippet above. The authoritative check is `getConnectSession()` in the Server
290
+ Component, or `client.entitlements.*` on your server — treat the proxy purely as
291
+ a way to skip rendering a page the visitor cannot use.
292
+
293
+ ### The server/client boundary
294
+
295
+ `@cosmoops/connect-sdk/server` and `/next` build a client around
296
+ `CONNECT_API_KEY`; `/client` holds no credential and calls your own origin. That
297
+ split is enforced, not just documented — both credential-holding entries resolve
298
+ to a stub in a browser bundle, so importing one from a client component fails the
299
+ build:
300
+
301
+ ```
302
+ No matching export in ".../dist/esm/this-entry-is-server-only.js" for import "ConnectClient"
303
+ ```
304
+
305
+ Import from `/client` instead, or use `import type`, which erases before any
306
+ bundler sees it. Server Components, Route Handlers, Server Actions and non-Next
307
+ backends are unaffected — the stub is wired to the `browser` export condition,
308
+ which only a client bundle resolves.
309
+
248
310
  ## Other frameworks
249
311
 
250
312
  Mount the bridge once in your backend:
@@ -350,7 +412,13 @@ All three render `loading` — never the fallback — while the answer is unknow
350
412
  npx @cosmoops/connect-sdk init
351
413
  ```
352
414
 
353
- Writes `app/api/connect/[...connect]/route.ts`, `app/plans/page.tsx` and an `.env.local` template, then prints the two steps it will not do for you (wrapping your layout in `<ConnectProvider>`, and registering the redirect URI in Connect). It never overwrites, so re-running is safe.
415
+ Writes `app/api/connect/[...connect]/route.ts`, `app/plans/page.tsx` and an `.env.local` template, then prints the steps it will not do for you (wrapping your layout in `<ConnectProvider>`, importing the theme, and registering the redirect URI in Connect). It never overwrites, so re-running is safe.
416
+
417
+ ```bash
418
+ npx @cosmoops/connect-sdk doctor
419
+ ```
420
+
421
+ Checks a wired-up project and names what is missing: the bridge route (wherever you mounted it) exporting both verbs, exactly one `<ConnectProvider>`, the theme imported *after* `@heroui/styles`, and both credentials actually set rather than declared empty. Exits non-zero on a real problem, so it works as a CI step. It is entirely offline — no credentials, no network — which is also its one limit: Connect exposes no endpoint listing a client's registered redirect URIs, so that step is reported as something only you can confirm.
354
422
 
355
423
  Next has no plugin API that adds routes — `next.config` can rewrite and redirect, but both need a route to point at, and the App Router only discovers routes in your own `app/` directory. So this is a scaffolder rather than a plugin: `ConnectPlansPage` makes the file that must exist one line, and `init` writes that line.
356
424
 
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ /**
3
+ * `npx @cosmoops/connect-sdk doctor` - checks a wired-up project for the
4
+ * mistakes that `init` cannot prevent.
5
+ *
6
+ * `init` writes the two files it owns. Everything else in the setup is a step
7
+ * someone performs by hand in a file this package must not rewrite - the
8
+ * provider goes into a layout somebody else wrote, the theme import goes into
9
+ * their stylesheet in a specific position, and the redirect URI lives in
10
+ * Connect rather than in the repo. Those are where setup actually breaks, and
11
+ * each one fails in a way that does not name its cause: a missing provider
12
+ * throws from a hook, a mis-ordered theme import silently loses to HeroUI's
13
+ * defaults, and an unregistered redirect URI only shows up as an OAuth error
14
+ * after a round trip to Connect.
15
+ *
16
+ * Deliberately offline. Connect exposes no endpoint that lists a client's
17
+ * registered redirect URIs, so there is nothing to check remotely that would
18
+ * justify the cost - and an offline check needs no credentials, which is what
19
+ * lets this run in CI on a pull request.
20
+ *
21
+ * The exit code is the point: `fail` means the app is broken and returns 1,
22
+ * `warn` means this cannot tell from the outside and returns 0.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.runDoctor = runDoctor;
26
+ const node_fs_1 = require("node:fs");
27
+ const node_path_1 = require("node:path");
28
+ /** Directories that are never a project's own source, and are large enough to matter. */
29
+ const SKIP = new Set(["node_modules", ".next", ".git", "dist", "build", ".turbo", "coverage"]);
30
+ function walk(dir, extensions, found = [], depth = 0) {
31
+ if (depth > 8 || !(0, node_fs_1.existsSync)(dir))
32
+ return found;
33
+ for (const entry of (0, node_fs_1.readdirSync)(dir)) {
34
+ if (SKIP.has(entry))
35
+ continue;
36
+ const path = (0, node_path_1.join)(dir, entry);
37
+ if ((0, node_fs_1.statSync)(path).isDirectory())
38
+ walk(path, extensions, found, depth + 1);
39
+ else if (extensions.some((extension) => entry.endsWith(extension)))
40
+ found.push(path);
41
+ }
42
+ return found;
43
+ }
44
+ function read(path) {
45
+ try {
46
+ return (0, node_fs_1.readFileSync)(path, "utf8");
47
+ }
48
+ catch {
49
+ return "";
50
+ }
51
+ }
52
+ function findAppDir(root) {
53
+ for (const candidate of [(0, node_path_1.join)(root, "app"), (0, node_path_1.join)(root, "src", "app")]) {
54
+ if ((0, node_fs_1.existsSync)(candidate))
55
+ return candidate;
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * The bridge is found by what it exports, not by where it sits.
61
+ *
62
+ * `createConnectHandlers({ basePath })` exists precisely so the route can be
63
+ * mounted somewhere other than `/api/connect`, so checking for that one path
64
+ * would report a false failure on every app that took the option.
65
+ */
66
+ function checkBridge(appDir, root) {
67
+ const routes = walk(appDir, ["route.ts", "route.tsx", "route.js"]);
68
+ const bridge = routes.find((path) => /@cosmoops\/connect-sdk\/next/.test(read(path)));
69
+ if (!bridge) {
70
+ return {
71
+ status: "fail",
72
+ title: "Bridge route handler",
73
+ detail: 'No route re-exports the bridge. Create app/api/connect/[...connect]/route.ts containing:\n export { GET, POST } from "@cosmoops/connect-sdk/next";\n or run `npx @cosmoops/connect-sdk init`.',
74
+ };
75
+ }
76
+ const source = read(bridge);
77
+ const exportsBoth = /\bGET\b/.test(source) && /\bPOST\b/.test(source);
78
+ if (!exportsBoth) {
79
+ return {
80
+ status: "fail",
81
+ title: "Bridge route handler",
82
+ detail: `${(0, node_path_1.relative)(root, bridge)} exports only one verb. GET and POST are the same function - the bridge routes on method itself - and sign-out POSTs, so both must be exported.`,
83
+ };
84
+ }
85
+ return { status: "pass", title: `Bridge route handler (${(0, node_path_1.relative)(root, bridge)})` };
86
+ }
87
+ function checkProvider(appDir, root) {
88
+ const layouts = walk(appDir, ["layout.tsx", "layout.jsx"]);
89
+ const hosting = layouts.filter((path) => /<ConnectProvider/.test(read(path)));
90
+ if (hosting.length === 0) {
91
+ return {
92
+ status: "fail",
93
+ title: "ConnectProvider in a layout",
94
+ detail: 'Nothing renders <ConnectProvider>. Every client hook and component below it throws without one. In app/layout.tsx:\n import { ConnectProvider } from "@cosmoops/connect-sdk/client";\n <ConnectProvider>{children}</ConnectProvider>',
95
+ };
96
+ }
97
+ // A nested provider re-fetches the session and keeps a second cache that the
98
+ // first one's mutations never invalidate, so a plan change updates one half
99
+ // of the page. Worth naming even though both layouts look correct alone.
100
+ if (hosting.length > 1) {
101
+ return {
102
+ status: "warn",
103
+ title: "ConnectProvider in a layout",
104
+ detail: `Found in ${hosting.length} layouts (${hosting.map((path) => (0, node_path_1.relative)(root, path)).join(", ")}). Nested providers keep separate caches, so a mutation in one is invisible to the other. Keep the outermost one only.`,
105
+ };
106
+ }
107
+ return { status: "pass", title: `ConnectProvider in a layout (${(0, node_path_1.relative)(root, hosting[0])})` };
108
+ }
109
+ /**
110
+ * Order is the whole check.
111
+ *
112
+ * `theme.css` sets the HeroUI token values Connect's components read. Imported
113
+ * before `@heroui/styles`, HeroUI's own defaults are declared later, win on
114
+ * source order, and the theme silently does nothing - the app looks fine, just
115
+ * not themed. The import also has to live in the app's own stylesheet: from
116
+ * inside node_modules, Tailwind roots its content scan there and never scans
117
+ * the app.
118
+ */
119
+ function checkTheme(root) {
120
+ const stylesheets = walk(root, [".css"]).filter((path) => !path.includes("node_modules"));
121
+ const importing = stylesheets.filter((path) => read(path).includes("@cosmoops/connect-sdk/theme.css"));
122
+ if (importing.length === 0) {
123
+ return {
124
+ status: "fail",
125
+ title: "Theme stylesheet imported",
126
+ detail: 'No stylesheet imports the Connect theme, so the components render with HeroUI defaults. Add it last of the three, in your own global CSS:\n @import "tailwindcss";\n @import "@heroui/styles";\n @import "@cosmoops/connect-sdk/theme.css";',
127
+ };
128
+ }
129
+ for (const path of importing) {
130
+ const source = read(path);
131
+ const connect = source.indexOf("@cosmoops/connect-sdk/theme.css");
132
+ const heroui = source.indexOf("@heroui/styles");
133
+ if (heroui !== -1 && heroui > connect) {
134
+ return {
135
+ status: "fail",
136
+ title: "Theme stylesheet imported",
137
+ detail: `${(0, node_path_1.relative)(root, path)} imports @heroui/styles *after* the Connect theme, so HeroUI's defaults win on source order and the theme has no effect. Put "@cosmoops/connect-sdk/theme.css" last.`,
138
+ };
139
+ }
140
+ }
141
+ return { status: "pass", title: `Theme stylesheet imported (${(0, node_path_1.relative)(root, importing[0])})` };
142
+ }
143
+ /**
144
+ * `.env.local` is read directly rather than trusting `process.env`, because
145
+ * the interesting case is a developer running this in a shell that never
146
+ * loaded it - exactly the shell `next dev` will inherit.
147
+ */
148
+ function checkEnv(root) {
149
+ const files = [".env.local", ".env.development.local", ".env"].map((name) => (0, node_path_1.join)(root, name));
150
+ const combined = files.filter(node_fs_1.existsSync).map(read).join("\n");
151
+ return ["CONNECT_API_KEY", "CONNECT_CLIENT_ID"].map((name) => {
152
+ // Horizontal whitespace only. `\s*` also matches the newline, so a bare
153
+ // `CONNECT_API_KEY=` was satisfied by the *next* line's value and an empty
154
+ // credential reported as set - which is the exact failure this check exists
155
+ // to catch.
156
+ const assigned = new RegExp(`^[ \\t]*${name}[ \\t]*=[ \\t]*\\S`, "m").test(combined);
157
+ if (assigned || process.env[name]) {
158
+ return { status: "pass", title: `${name} set` };
159
+ }
160
+ const declaredEmpty = new RegExp(`^[ \\t]*${name}[ \\t]*=`, "m").test(combined);
161
+ return {
162
+ status: "fail",
163
+ title: `${name} set`,
164
+ detail: declaredEmpty
165
+ ? `Declared but empty in .env.local. The value is in your service's Setup tab in Connect.`
166
+ : `Not found in .env.local or the environment. Both values are in your service's Setup tab in Connect.`,
167
+ };
168
+ });
169
+ }
170
+ /**
171
+ * Nothing here can verify this - Connect has no endpoint listing a client's
172
+ * redirect URIs - but it is the single most common reason a correct setup
173
+ * still fails at sign-in, so it is worth a line that always prints.
174
+ */
175
+ function checkRedirectUri() {
176
+ return {
177
+ status: "warn",
178
+ title: "Redirect URI registered in Connect",
179
+ detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your service's Setup tab - including the deployed origin, not just http://localhost:3000.",
180
+ };
181
+ }
182
+ const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
183
+ function runDoctor(argv = []) {
184
+ const root = (0, node_path_1.resolve)(argv[0] ?? process.cwd());
185
+ const appDir = findAppDir(root);
186
+ if (!appDir) {
187
+ console.error(`[connect-sdk] No App Router directory found in ${root} - looked for "app/" and "src/app/".\n` +
188
+ "Run this from your Next.js project root, or pass the path: npx @cosmoops/connect-sdk doctor ./my-app");
189
+ return 1;
190
+ }
191
+ console.log(`[connect-sdk] Checking ${(0, node_path_1.relative)(process.cwd(), root) || root}\n`);
192
+ const checks = [
193
+ checkBridge(appDir, root),
194
+ checkProvider(appDir, root),
195
+ checkTheme(root),
196
+ ...checkEnv(root),
197
+ checkRedirectUri(),
198
+ ];
199
+ for (const check of checks) {
200
+ console.log(` ${MARKER[check.status]} ${check.title}`);
201
+ if (check.detail)
202
+ console.log(` ${check.detail.replace(/\n/g, "\n ")}\n`);
203
+ }
204
+ const failed = checks.filter((check) => check.status === "fail").length;
205
+ console.log(failed === 0
206
+ ? "\nNo problems found.\n"
207
+ : `\n${failed} problem${failed === 1 ? "" : "s"} to fix. Run \`npx @cosmoops/connect-sdk init\` to scaffold anything missing.\n`);
208
+ return failed === 0 ? 0 : 1;
209
+ }
@@ -27,6 +27,7 @@ exports.runInit = runInit;
27
27
  exports.main = main;
28
28
  const node_fs_1 = require("node:fs");
29
29
  const node_path_1 = require("node:path");
30
+ const doctor_js_1 = require("./doctor.js");
30
31
  const BRIDGE_ROUTE = `// The Connect bridge - the only place your API key is used.
31
32
  //
32
33
  // It answers your own origin, so the browser half of the SDK never holds a
@@ -145,15 +146,17 @@ as-is.
145
146
  Then <ConnectPlans />, <ConnectSubscriptionGate>, <ConnectFeatureGate> and the
146
147
  rest work anywhere below the provider, and getConnectSession() reads the same
147
148
  session from any Server Component.
149
+
150
+ Run \`npx @cosmoops/connect-sdk doctor\` when you have done those three - it
151
+ checks all of them and says which are still outstanding.
148
152
  `);
149
153
  return 0;
150
154
  }
151
155
  /**
152
- * Only `init` today. Parsed as a subcommand anyway so adding `doctor` or
153
- * `check` later does not change how the existing one is invoked.
156
+ * `init` writes the wiring; `doctor` checks the parts `init` cannot write.
154
157
  *
155
158
  * A bare path runs `init` against it, so both `connect-sdk init ./my-app` and
156
- * `connect-sdk ./my-app` work. A first argument that is neither the command nor
159
+ * `connect-sdk ./my-app` work. A first argument that is neither a command nor
157
160
  * an existing directory is a typo, and saying so beats reporting that
158
161
  * `./innit` contains no App Router - which is true, unhelpful, and points at
159
162
  * the wrong mistake.
@@ -161,13 +164,22 @@ session from any Server Component.
161
164
  function main(argv) {
162
165
  const [first, ...rest] = argv;
163
166
  if (first === "--help" || first === "-h") {
164
- console.log("Usage: connect-sdk init [project-dir]");
167
+ console.log([
168
+ "Usage: connect-sdk <command> [project-dir]",
169
+ "",
170
+ " init scaffold the bridge route, /plans page and .env.local",
171
+ " doctor check an existing setup and report what is missing",
172
+ "",
173
+ "With no command, runs init against the given directory (default: cwd).",
174
+ ].join("\n"));
165
175
  return 0;
166
176
  }
167
177
  if (first === "init")
168
178
  return runInit(rest);
179
+ if (first === "doctor" || first === "check")
180
+ return (0, doctor_js_1.runDoctor)(rest);
169
181
  if (!first || (0, node_fs_1.existsSync)(first))
170
182
  return runInit(argv);
171
- console.error(`[connect-sdk] Unknown command "${first}". Try: connect-sdk init`);
183
+ console.error(`[connect-sdk] Unknown command "${first}". Try: connect-sdk init, or connect-sdk doctor`);
172
184
  return 1;
173
185
  }
@@ -111,4 +111,4 @@ class ConnectClient {
111
111
  }
112
112
  exports.ConnectClient = ConnectClient;
113
113
  /** Kept in step with `package.json` by `sdk.test.ts`. */
114
- exports.SDK_VERSION = "0.1.0";
114
+ exports.SDK_VERSION = "0.3.0";
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * The module a browser bundle gets if it reaches for `/server` or `/next`.
5
+ *
6
+ * Neither entry may run in a browser: both build a `ConnectClient` around
7
+ * `CONNECT_API_KEY`. Nothing enforced that before - the split was documented
8
+ * and unguarded, so a client component importing `@cosmoops/connect-sdk/server`
9
+ * compiled cleanly and failed later, somewhere else, for reasons that did not
10
+ * mention the import.
11
+ *
12
+ * The key itself does not leak: `CONNECT_API_KEY` has no `NEXT_PUBLIC_` prefix,
13
+ * so Next replaces the read with `undefined` in a client bundle rather than
14
+ * inlining the secret. What ships is the credential-handling code path and a
15
+ * client that authenticates with `undefined` - which 401s against Connect and
16
+ * looks like a server bug. This turns that into a build-time failure naming the
17
+ * entry to import instead.
18
+ *
19
+ * Wired through the `browser` export condition, which Next's client compiler
20
+ * resolves and its server, RSC and SSR passes do not - so the same import stays
21
+ * legal in a Route Handler, a Server Component and a non-Next backend.
22
+ *
23
+ * The filename is the error message. A bundler reaches this module while
24
+ * resolving a *named* import, so it reports "no matching export" and never
25
+ * evaluates the throw below - the path is the only part of that error carrying
26
+ * any meaning, so the path has to say what went wrong. The throw still covers
27
+ * side-effect, default and namespace imports, which do evaluate it.
28
+ */
29
+ throw new Error('@cosmoops/connect-sdk: "@cosmoops/connect-sdk/server" and "@cosmoops/connect-sdk/next" hold your CONNECT_API_KEY and cannot be imported from browser code.\n' +
30
+ 'Import "@cosmoops/connect-sdk/client" instead - its components and hooks call your own origin, and the bridge route reaches Connect for them.\n' +
31
+ 'For types only, import from "@cosmoops/connect-sdk" (isomorphic) or use `import type`, which erases at compile time.');
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `npx @cosmoops/connect-sdk doctor` - checks a wired-up project for the
3
+ * mistakes that `init` cannot prevent.
4
+ *
5
+ * `init` writes the two files it owns. Everything else in the setup is a step
6
+ * someone performs by hand in a file this package must not rewrite - the
7
+ * provider goes into a layout somebody else wrote, the theme import goes into
8
+ * their stylesheet in a specific position, and the redirect URI lives in
9
+ * Connect rather than in the repo. Those are where setup actually breaks, and
10
+ * each one fails in a way that does not name its cause: a missing provider
11
+ * throws from a hook, a mis-ordered theme import silently loses to HeroUI's
12
+ * defaults, and an unregistered redirect URI only shows up as an OAuth error
13
+ * after a round trip to Connect.
14
+ *
15
+ * Deliberately offline. Connect exposes no endpoint that lists a client's
16
+ * registered redirect URIs, so there is nothing to check remotely that would
17
+ * justify the cost - and an offline check needs no credentials, which is what
18
+ * lets this run in CI on a pull request.
19
+ *
20
+ * The exit code is the point: `fail` means the app is broken and returns 1,
21
+ * `warn` means this cannot tell from the outside and returns 0.
22
+ */
23
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
24
+ import { join, relative, resolve } from "node:path";
25
+ /** Directories that are never a project's own source, and are large enough to matter. */
26
+ const SKIP = new Set(["node_modules", ".next", ".git", "dist", "build", ".turbo", "coverage"]);
27
+ function walk(dir, extensions, found = [], depth = 0) {
28
+ if (depth > 8 || !existsSync(dir))
29
+ return found;
30
+ for (const entry of readdirSync(dir)) {
31
+ if (SKIP.has(entry))
32
+ continue;
33
+ const path = join(dir, entry);
34
+ if (statSync(path).isDirectory())
35
+ walk(path, extensions, found, depth + 1);
36
+ else if (extensions.some((extension) => entry.endsWith(extension)))
37
+ found.push(path);
38
+ }
39
+ return found;
40
+ }
41
+ function read(path) {
42
+ try {
43
+ return readFileSync(path, "utf8");
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ }
49
+ function findAppDir(root) {
50
+ for (const candidate of [join(root, "app"), join(root, "src", "app")]) {
51
+ if (existsSync(candidate))
52
+ return candidate;
53
+ }
54
+ return null;
55
+ }
56
+ /**
57
+ * The bridge is found by what it exports, not by where it sits.
58
+ *
59
+ * `createConnectHandlers({ basePath })` exists precisely so the route can be
60
+ * mounted somewhere other than `/api/connect`, so checking for that one path
61
+ * would report a false failure on every app that took the option.
62
+ */
63
+ function checkBridge(appDir, root) {
64
+ const routes = walk(appDir, ["route.ts", "route.tsx", "route.js"]);
65
+ const bridge = routes.find((path) => /@cosmoops\/connect-sdk\/next/.test(read(path)));
66
+ if (!bridge) {
67
+ return {
68
+ status: "fail",
69
+ title: "Bridge route handler",
70
+ detail: 'No route re-exports the bridge. Create app/api/connect/[...connect]/route.ts containing:\n export { GET, POST } from "@cosmoops/connect-sdk/next";\n or run `npx @cosmoops/connect-sdk init`.',
71
+ };
72
+ }
73
+ const source = read(bridge);
74
+ const exportsBoth = /\bGET\b/.test(source) && /\bPOST\b/.test(source);
75
+ if (!exportsBoth) {
76
+ return {
77
+ status: "fail",
78
+ title: "Bridge route handler",
79
+ detail: `${relative(root, bridge)} exports only one verb. GET and POST are the same function - the bridge routes on method itself - and sign-out POSTs, so both must be exported.`,
80
+ };
81
+ }
82
+ return { status: "pass", title: `Bridge route handler (${relative(root, bridge)})` };
83
+ }
84
+ function checkProvider(appDir, root) {
85
+ const layouts = walk(appDir, ["layout.tsx", "layout.jsx"]);
86
+ const hosting = layouts.filter((path) => /<ConnectProvider/.test(read(path)));
87
+ if (hosting.length === 0) {
88
+ return {
89
+ status: "fail",
90
+ title: "ConnectProvider in a layout",
91
+ detail: 'Nothing renders <ConnectProvider>. Every client hook and component below it throws without one. In app/layout.tsx:\n import { ConnectProvider } from "@cosmoops/connect-sdk/client";\n <ConnectProvider>{children}</ConnectProvider>',
92
+ };
93
+ }
94
+ // A nested provider re-fetches the session and keeps a second cache that the
95
+ // first one's mutations never invalidate, so a plan change updates one half
96
+ // of the page. Worth naming even though both layouts look correct alone.
97
+ if (hosting.length > 1) {
98
+ return {
99
+ status: "warn",
100
+ title: "ConnectProvider in a layout",
101
+ detail: `Found in ${hosting.length} layouts (${hosting.map((path) => relative(root, path)).join(", ")}). Nested providers keep separate caches, so a mutation in one is invisible to the other. Keep the outermost one only.`,
102
+ };
103
+ }
104
+ return { status: "pass", title: `ConnectProvider in a layout (${relative(root, hosting[0])})` };
105
+ }
106
+ /**
107
+ * Order is the whole check.
108
+ *
109
+ * `theme.css` sets the HeroUI token values Connect's components read. Imported
110
+ * before `@heroui/styles`, HeroUI's own defaults are declared later, win on
111
+ * source order, and the theme silently does nothing - the app looks fine, just
112
+ * not themed. The import also has to live in the app's own stylesheet: from
113
+ * inside node_modules, Tailwind roots its content scan there and never scans
114
+ * the app.
115
+ */
116
+ function checkTheme(root) {
117
+ const stylesheets = walk(root, [".css"]).filter((path) => !path.includes("node_modules"));
118
+ const importing = stylesheets.filter((path) => read(path).includes("@cosmoops/connect-sdk/theme.css"));
119
+ if (importing.length === 0) {
120
+ return {
121
+ status: "fail",
122
+ title: "Theme stylesheet imported",
123
+ detail: 'No stylesheet imports the Connect theme, so the components render with HeroUI defaults. Add it last of the three, in your own global CSS:\n @import "tailwindcss";\n @import "@heroui/styles";\n @import "@cosmoops/connect-sdk/theme.css";',
124
+ };
125
+ }
126
+ for (const path of importing) {
127
+ const source = read(path);
128
+ const connect = source.indexOf("@cosmoops/connect-sdk/theme.css");
129
+ const heroui = source.indexOf("@heroui/styles");
130
+ if (heroui !== -1 && heroui > connect) {
131
+ return {
132
+ status: "fail",
133
+ title: "Theme stylesheet imported",
134
+ detail: `${relative(root, path)} imports @heroui/styles *after* the Connect theme, so HeroUI's defaults win on source order and the theme has no effect. Put "@cosmoops/connect-sdk/theme.css" last.`,
135
+ };
136
+ }
137
+ }
138
+ return { status: "pass", title: `Theme stylesheet imported (${relative(root, importing[0])})` };
139
+ }
140
+ /**
141
+ * `.env.local` is read directly rather than trusting `process.env`, because
142
+ * the interesting case is a developer running this in a shell that never
143
+ * loaded it - exactly the shell `next dev` will inherit.
144
+ */
145
+ function checkEnv(root) {
146
+ const files = [".env.local", ".env.development.local", ".env"].map((name) => join(root, name));
147
+ const combined = files.filter(existsSync).map(read).join("\n");
148
+ return ["CONNECT_API_KEY", "CONNECT_CLIENT_ID"].map((name) => {
149
+ // Horizontal whitespace only. `\s*` also matches the newline, so a bare
150
+ // `CONNECT_API_KEY=` was satisfied by the *next* line's value and an empty
151
+ // credential reported as set - which is the exact failure this check exists
152
+ // to catch.
153
+ const assigned = new RegExp(`^[ \\t]*${name}[ \\t]*=[ \\t]*\\S`, "m").test(combined);
154
+ if (assigned || process.env[name]) {
155
+ return { status: "pass", title: `${name} set` };
156
+ }
157
+ const declaredEmpty = new RegExp(`^[ \\t]*${name}[ \\t]*=`, "m").test(combined);
158
+ return {
159
+ status: "fail",
160
+ title: `${name} set`,
161
+ detail: declaredEmpty
162
+ ? `Declared but empty in .env.local. The value is in your service's Setup tab in Connect.`
163
+ : `Not found in .env.local or the environment. Both values are in your service's Setup tab in Connect.`,
164
+ };
165
+ });
166
+ }
167
+ /**
168
+ * Nothing here can verify this - Connect has no endpoint listing a client's
169
+ * redirect URIs - but it is the single most common reason a correct setup
170
+ * still fails at sign-in, so it is worth a line that always prints.
171
+ */
172
+ function checkRedirectUri() {
173
+ return {
174
+ status: "warn",
175
+ title: "Redirect URI registered in Connect",
176
+ detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your service's Setup tab - including the deployed origin, not just http://localhost:3000.",
177
+ };
178
+ }
179
+ const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
180
+ export function runDoctor(argv = []) {
181
+ const root = resolve(argv[0] ?? process.cwd());
182
+ const appDir = findAppDir(root);
183
+ if (!appDir) {
184
+ console.error(`[connect-sdk] No App Router directory found in ${root} - looked for "app/" and "src/app/".\n` +
185
+ "Run this from your Next.js project root, or pass the path: npx @cosmoops/connect-sdk doctor ./my-app");
186
+ return 1;
187
+ }
188
+ console.log(`[connect-sdk] Checking ${relative(process.cwd(), root) || root}\n`);
189
+ const checks = [
190
+ checkBridge(appDir, root),
191
+ checkProvider(appDir, root),
192
+ checkTheme(root),
193
+ ...checkEnv(root),
194
+ checkRedirectUri(),
195
+ ];
196
+ for (const check of checks) {
197
+ console.log(` ${MARKER[check.status]} ${check.title}`);
198
+ if (check.detail)
199
+ console.log(` ${check.detail.replace(/\n/g, "\n ")}\n`);
200
+ }
201
+ const failed = checks.filter((check) => check.status === "fail").length;
202
+ console.log(failed === 0
203
+ ? "\nNo problems found.\n"
204
+ : `\n${failed} problem${failed === 1 ? "" : "s"} to fix. Run \`npx @cosmoops/connect-sdk init\` to scaffold anything missing.\n`);
205
+ return failed === 0 ? 0 : 1;
206
+ }
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { dirname, join, relative, resolve } from "node:path";
25
+ import { runDoctor } from "./doctor.js";
25
26
  const BRIDGE_ROUTE = `// The Connect bridge - the only place your API key is used.
26
27
  //
27
28
  // It answers your own origin, so the browser half of the SDK never holds a
@@ -140,15 +141,17 @@ as-is.
140
141
  Then <ConnectPlans />, <ConnectSubscriptionGate>, <ConnectFeatureGate> and the
141
142
  rest work anywhere below the provider, and getConnectSession() reads the same
142
143
  session from any Server Component.
144
+
145
+ Run \`npx @cosmoops/connect-sdk doctor\` when you have done those three - it
146
+ checks all of them and says which are still outstanding.
143
147
  `);
144
148
  return 0;
145
149
  }
146
150
  /**
147
- * Only `init` today. Parsed as a subcommand anyway so adding `doctor` or
148
- * `check` later does not change how the existing one is invoked.
151
+ * `init` writes the wiring; `doctor` checks the parts `init` cannot write.
149
152
  *
150
153
  * A bare path runs `init` against it, so both `connect-sdk init ./my-app` and
151
- * `connect-sdk ./my-app` work. A first argument that is neither the command nor
154
+ * `connect-sdk ./my-app` work. A first argument that is neither a command nor
152
155
  * an existing directory is a typo, and saying so beats reporting that
153
156
  * `./innit` contains no App Router - which is true, unhelpful, and points at
154
157
  * the wrong mistake.
@@ -156,13 +159,22 @@ session from any Server Component.
156
159
  export function main(argv) {
157
160
  const [first, ...rest] = argv;
158
161
  if (first === "--help" || first === "-h") {
159
- console.log("Usage: connect-sdk init [project-dir]");
162
+ console.log([
163
+ "Usage: connect-sdk <command> [project-dir]",
164
+ "",
165
+ " init scaffold the bridge route, /plans page and .env.local",
166
+ " doctor check an existing setup and report what is missing",
167
+ "",
168
+ "With no command, runs init against the given directory (default: cwd).",
169
+ ].join("\n"));
160
170
  return 0;
161
171
  }
162
172
  if (first === "init")
163
173
  return runInit(rest);
174
+ if (first === "doctor" || first === "check")
175
+ return runDoctor(rest);
164
176
  if (!first || existsSync(first))
165
177
  return runInit(argv);
166
- console.error(`[connect-sdk] Unknown command "${first}". Try: connect-sdk init`);
178
+ console.error(`[connect-sdk] Unknown command "${first}". Try: connect-sdk init, or connect-sdk doctor`);
167
179
  return 1;
168
180
  }
@@ -107,4 +107,4 @@ export class ConnectClient {
107
107
  }
108
108
  }
109
109
  /** Kept in step with `package.json` by `sdk.test.ts`. */
110
- export const SDK_VERSION = "0.1.0";
110
+ export const SDK_VERSION = "0.3.0";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The module a browser bundle gets if it reaches for `/server` or `/next`.
3
+ *
4
+ * Neither entry may run in a browser: both build a `ConnectClient` around
5
+ * `CONNECT_API_KEY`. Nothing enforced that before - the split was documented
6
+ * and unguarded, so a client component importing `@cosmoops/connect-sdk/server`
7
+ * compiled cleanly and failed later, somewhere else, for reasons that did not
8
+ * mention the import.
9
+ *
10
+ * The key itself does not leak: `CONNECT_API_KEY` has no `NEXT_PUBLIC_` prefix,
11
+ * so Next replaces the read with `undefined` in a client bundle rather than
12
+ * inlining the secret. What ships is the credential-handling code path and a
13
+ * client that authenticates with `undefined` - which 401s against Connect and
14
+ * looks like a server bug. This turns that into a build-time failure naming the
15
+ * entry to import instead.
16
+ *
17
+ * Wired through the `browser` export condition, which Next's client compiler
18
+ * resolves and its server, RSC and SSR passes do not - so the same import stays
19
+ * legal in a Route Handler, a Server Component and a non-Next backend.
20
+ *
21
+ * The filename is the error message. A bundler reaches this module while
22
+ * resolving a *named* import, so it reports "no matching export" and never
23
+ * evaluates the throw below - the path is the only part of that error carrying
24
+ * any meaning, so the path has to say what went wrong. The throw still covers
25
+ * side-effect, default and namespace imports, which do evaluate it.
26
+ */
27
+ throw new Error('@cosmoops/connect-sdk: "@cosmoops/connect-sdk/server" and "@cosmoops/connect-sdk/next" hold your CONNECT_API_KEY and cannot be imported from browser code.\n' +
28
+ 'Import "@cosmoops/connect-sdk/client" instead - its components and hooks call your own origin, and the bridge route reaches Connect for them.\n' +
29
+ 'For types only, import from "@cosmoops/connect-sdk" (isomorphic) or use `import type`, which erases at compile time.');
30
+ export {};
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `npx @cosmoops/connect-sdk doctor` - checks a wired-up project for the
3
+ * mistakes that `init` cannot prevent.
4
+ *
5
+ * `init` writes the two files it owns. Everything else in the setup is a step
6
+ * someone performs by hand in a file this package must not rewrite - the
7
+ * provider goes into a layout somebody else wrote, the theme import goes into
8
+ * their stylesheet in a specific position, and the redirect URI lives in
9
+ * Connect rather than in the repo. Those are where setup actually breaks, and
10
+ * each one fails in a way that does not name its cause: a missing provider
11
+ * throws from a hook, a mis-ordered theme import silently loses to HeroUI's
12
+ * defaults, and an unregistered redirect URI only shows up as an OAuth error
13
+ * after a round trip to Connect.
14
+ *
15
+ * Deliberately offline. Connect exposes no endpoint that lists a client's
16
+ * registered redirect URIs, so there is nothing to check remotely that would
17
+ * justify the cost - and an offline check needs no credentials, which is what
18
+ * lets this run in CI on a pull request.
19
+ *
20
+ * The exit code is the point: `fail` means the app is broken and returns 1,
21
+ * `warn` means this cannot tell from the outside and returns 0.
22
+ */
23
+ export declare function runDoctor(argv?: string[]): number;
@@ -22,11 +22,10 @@
22
22
  */
23
23
  export declare function runInit(argv?: string[]): number;
24
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.
25
+ * `init` writes the wiring; `doctor` checks the parts `init` cannot write.
27
26
  *
28
27
  * 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
28
+ * `connect-sdk ./my-app` work. A first argument that is neither a command nor
30
29
  * an existing directory is a typo, and saying so beats reporting that
31
30
  * `./innit` contains no App Router - which is true, unhelpful, and points at
32
31
  * the wrong mistake.
@@ -80,4 +80,4 @@ export declare class ConnectClient {
80
80
  constructor(options: ConnectClientOptions);
81
81
  }
82
82
  /** Kept in step with `package.json` by `sdk.test.ts`. */
83
- export declare const SDK_VERSION = "0.1.0";
83
+ export declare const SDK_VERSION = "0.3.0";
@@ -10,6 +10,14 @@ export type { ConnectClientOptions } from "./client.js";
10
10
  export { createConnectHandler } from "./handler.js";
11
11
  export type { ConnectHandlerOptions, ConnectSessionStore } from "./handler.js";
12
12
  export { createState } from "./resources/identity.js";
13
- export type { AuthorizeUrlInput, ExchangeCodeInput } from "./resources/identity.js";
14
- export type { UploadInput } from "./resources/drive.js";
13
+ export type { AuthorizeUrlInput, ExchangeCodeInput, IdentityResource } from "./resources/identity.js";
14
+ export type { UploadInput, DriveResource } from "./resources/drive.js";
15
15
  export type { FetchLike } from "./http.js";
16
+ export type { AccessResource } from "./resources/access.js";
17
+ export type { AppsResource } from "./resources/apps.js";
18
+ export type { EntitlementsResource } from "./resources/entitlements.js";
19
+ export type { BillingResource } from "./resources/billing.js";
20
+ export type { AiResource } from "./resources/ai.js";
21
+ export type { OtpResource } from "./resources/otp.js";
22
+ export type { LogsResource } from "./resources/logs.js";
23
+ export type { SessionsResource } from "./resources/sessions.js";
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",
@@ -15,11 +15,19 @@
15
15
  },
16
16
  "./server": {
17
17
  "types": "./dist/types/server/index.d.ts",
18
+ "browser": {
19
+ "import": "./dist/esm/this-entry-is-server-only.js",
20
+ "require": "./dist/cjs/this-entry-is-server-only.js"
21
+ },
18
22
  "import": "./dist/esm/server/index.js",
19
23
  "require": "./dist/cjs/server/index.js"
20
24
  },
21
25
  "./next": {
22
26
  "types": "./dist/types/next/index.d.ts",
27
+ "browser": {
28
+ "import": "./dist/esm/this-entry-is-server-only.js",
29
+ "require": "./dist/cjs/this-entry-is-server-only.js"
30
+ },
23
31
  "import": "./dist/esm/next/index.js",
24
32
  "require": "./dist/cjs/next/index.js"
25
33
  },
@@ -58,6 +66,8 @@
58
66
  "verify:pack": "bun run scripts/verify-pack.ts",
59
67
  "prepublishOnly": "bun run build && bun run verify:pack",
60
68
  "test": "bun test",
69
+ "docs": "typedoc",
70
+ "docs:md": "typedoc --options typedoc.markdown.json",
61
71
  "storybook": "storybook dev -p 6106",
62
72
  "build:storybook": "storybook build -o storybook-static"
63
73
  },
@@ -76,6 +86,8 @@
76
86
  "react-dom": "19.2.8",
77
87
  "storybook": "^10.5.10",
78
88
  "tailwindcss": "^4.3.3",
89
+ "typedoc": "^0.28.20",
90
+ "typedoc-plugin-markdown": "^4.12.0",
79
91
  "typescript": "^5",
80
92
  "vite": "^8.2.2"
81
93
  },