@commish/next 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oftring Ventures LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # @commish/next
2
+
3
+ MIT-licensed Next.js App Router integration for permanent Commish TEST and LIVE modes.
4
+ This source checkpoint is verified with Node 24.15.0, Next 16.3.4 and React 19.2.8.
5
+ It does not establish npm availability or acceptance against a hosted Commish API.
6
+ Use the exact paired `0.1.0-beta.10` SDK/Next packages and keep the consumer lockfile.
7
+ Accepted candidate tarballs remain available for prepublication qualification.
8
+
9
+ ## Capture route
10
+
11
+ Set `COMMISH_API_URL` to the selected mode's API base, including `/api/v1`, and
12
+ `COMMISH_SECRET_KEY` to its application-scoped secret. Keep both on the server.
13
+ Only the publishable key and application ID belong in the public variables below.
14
+ Create `app/api/commish/attribution/route.ts` (or under `src/app`):
15
+
16
+ ```ts
17
+ import { createAttributionHandler } from "@commish/next";
18
+
19
+ export const runtime = "nodejs";
20
+ export const POST = createAttributionHandler({
21
+ apiUrl: process.env.COMMISH_API_URL,
22
+ secretKey: process.env.COMMISH_SECRET_KEY,
23
+ });
24
+ ```
25
+
26
+ The route exchanges the referral with Commish and stores successful attribution
27
+ in an HttpOnly cookie. Configure the API base explicitly for the selected deployment;
28
+ the helper otherwise uses its default Commish API origin.
29
+
30
+ ## Application layout
31
+
32
+ Wrap the existing content in `app/layout.tsx` (or `src/app/layout.tsx`):
33
+
34
+ ```tsx
35
+ import { CommishProvider } from "@commish/next/react";
36
+ import type { ReactNode } from "react";
37
+
38
+ export default function Layout({ children }: { children: ReactNode }) {
39
+ return (
40
+ <html lang="en">
41
+ <body>
42
+ <CommishProvider
43
+ publishableKey={process.env.NEXT_PUBLIC_COMMISH_PUBLISHABLE_KEY!}
44
+ applicationId={process.env.NEXT_PUBLIC_COMMISH_APPLICATION_ID!}
45
+ >
46
+ {children}
47
+ </CommishProvider>
48
+ </body>
49
+ </html>
50
+ );
51
+ }
52
+ ```
53
+
54
+ The provider captures on client navigation through the same-origin route above.
55
+ Set `capturePath` on the provider if that route uses another same-origin path.
56
+ Successful capture removes `commish_ref` while preserving navigation state;
57
+ failure keeps the referral for retry. Wait for capture before starting Checkout.
58
+
59
+ ## Agent-driven installation and Checkout
60
+
61
+ In your existing authenticated server Checkout route, await
62
+ `withCommishStripeMetadata(params)` from `@commish/next` before passing the result
63
+ to your existing Stripe Checkout integration. The helper preserves other
64
+ parameters and applies the request's attribution to payment or subscription
65
+ metadata. `getCommishAttribution()` reads that cookie directly; both helpers
66
+ require a Next request context. The root export is blocked in browser builds.
67
+ The provider belongs at `@commish/next/react`; `@commish/next/browser` also exports
68
+ `captureReferral` for direct browser integration.
69
+
70
+ After installing an accepted pair, run from the consumer project root:
71
+
72
+ ```sh
73
+ pnpm exec commish-next --json # inspect the plan; no writes
74
+ pnpm exec commish-next --write --json # install the integration files
75
+ ```
76
+
77
+ The CLI creates the capture route above and `app/commish-provider.tsx` (under
78
+ `src/app` when there is no root `app`). JavaScript apps receive `.js`/`.jsx`
79
+ files; the installer does not enable TypeScript. Repeated runs preserve identical files;
80
+ custom files, conflicting extensions and symbolic links stop installation before
81
+ writing. It never reads or writes credentials. Supply matching application keys
82
+ for the selected mode through your environment configuration.
83
+
84
+ The coding agent must finish these existing application edits:
85
+
86
+ 1. Import `CommishRootProvider` from `./commish-provider` in the root layout and
87
+ wrap its existing children. Preserve other providers, metadata and layout content.
88
+ 2. Add the awaited metadata helper to the authenticated Checkout handler as above.
89
+ 3. Build, then exercise a TEST referral through capture and Checkout. Successful
90
+ capture must set the HttpOnly cookie; Checkout must carry its attribution.
91
+
92
+ JSON output lists file outcomes, required environment variable names and next
93
+ steps. `files_installed` means those files exist; `integrationVerified: false`
94
+ explicitly leaves wiring, configuration and end-to-end verification outstanding.
95
+ If a custom route/provider already exists, integrate using the examples above;
96
+ the installer will not overwrite it or guess at customer code.
97
+
98
+ Package installation does not connect a Stripe account, configure a program,
99
+ activate a creator, or prove a conversion. Complete the hosted TEST setup and
100
+ record its acceptance separately from package build and installation evidence.
101
+
102
+ ## Provision TEST or LIVE credentials from the CLI
103
+
104
+ An owner or admin can authorize an agent-driven setup without copying a secret
105
+ from the dashboard:
106
+
107
+ ```sh
108
+ pnpm exec commish-next setup \
109
+ --workspace wrk_your_workspace_id \
110
+ --application-name "Your product" \
111
+ --key-label "Local agent setup" \
112
+ --output .env.commish \
113
+ --json
114
+ ```
115
+
116
+ The command defaults to TEST; add `--mode live` for LIVE credentials. LIVE requires
117
+ current workspace eligibility and provider readiness at approval and exchange;
118
+ it never falls back to TEST. The command generates the selected mode's keys on the developer's
119
+ machine, opens a ten-minute approval page on `https://app.commish.sh`, and polls
120
+ for the approved exchange. The browser requires an authenticated workspace owner
121
+ or admin with a recent authenticator check. Only hashes and the publishable key
122
+ cross the browser boundary; Commish never receives or stores the raw secret.
123
+
124
+ After approval, the CLI atomically creates the explicitly named output with mode
125
+ `0600`. It never overwrites an existing path. The file contains
126
+ `COMMISH_API_URL`, `COMMISH_SECRET_KEY`,
127
+ `NEXT_PUBLIC_COMMISH_PUBLISHABLE_KEY`, and
128
+ `NEXT_PUBLIC_COMMISH_APPLICATION_ID`. Add that path to the product's local secret
129
+ handling policy; do not commit it. An interrupted exchange can safely retry the
130
+ same verifier and receives the same application and key.
131
+
132
+ Use `--no-open` when the controlling agent will present the printed approval URL
133
+ itself. For a trusted local Commish runtime, `--app-url` accepts an explicit
134
+ loopback HTTP origin; other custom origins require HTTPS. Setup creates application
135
+ credentials in the approved mode. Program configuration and the complete transaction
136
+ journey remain separate verification steps.
137
+
138
+ ## Verify configuration from the CLI
139
+
140
+ With the installed packages and configured environment, set `COMMISH_PROGRAM_ID`
141
+ to your existing program ID and run `pnpm exec commish-next verify --json`.
142
+ The command reads `COMMISH_SECRET_KEY`, `NEXT_PUBLIC_COMMISH_PUBLISHABLE_KEY`,
143
+ `NEXT_PUBLIC_COMMISH_APPLICATION_ID` and `COMMISH_PROGRAM_ID` from the process
144
+ environment. Use Node's `--env-file` support or your existing environment runner
145
+ if needed; the CLI does not search for or change dotenv files.
146
+
147
+ It performs one authenticated, read-only program lookup in the key's TEST or LIVE
148
+ mode. `configuration_verified` means the key can read that program and its
149
+ application/mode match the configured values. The receipt includes program status;
150
+ a paused or draft program is not declared ready for transactions. Publishable-key
151
+ format/mode is checked locally; its server-side application binding is **not** proved.
152
+ `integrationVerified: false` and `unverified` identify the remaining real journey.
153
+
154
+ The API defaults to `https://app.commish.sh/api/v1`. Set `COMMISH_API_URL` only to a
155
+ Commish deployment you trust: this destination receives the secret key. Custom
156
+ bases require HTTPS, except explicit loopback development addresses. Redirects are
157
+ rejected; responses and request duration are bounded. The CLI does not provision resources or issue mutation requests. Authentication
158
+ may update key-usage metadata. It never prints credentials/provider bodies or retries a request. Invalid configuration,
159
+ denied/revoked keys, inaccessible programs, mismatches and unavailable responses exit
160
+ with code 1 and a stable JSON error code. On older deployments that reject LIVE reads,
161
+ verification fails with `access_denied`; it never falls back to TEST.
package/bin/init.mjs ADDED
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import {
5
+ linkSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { basename, dirname, join, resolve } from "node:path";
13
+ import { setTimeout as delay } from "node:timers/promises";
14
+
15
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
16
+ const token = () => randomBytes(24).toString("base64url");
17
+ const safeUrl = (raw) => {
18
+ let value;
19
+ try { value = new URL(raw); } catch { throw new Error("invalid_app_url"); }
20
+ if (value.username || value.password || value.search || value.hash || value.pathname !== "/" ||
21
+ !(value.protocol === "https:" || value.protocol === "http:" &&
22
+ ["127.0.0.1", "[::1]", "localhost"].includes(value.hostname)))
23
+ throw new Error("invalid_app_url");
24
+ return value;
25
+ };
26
+ const responseJson = async (response) => {
27
+ if (Number(response.headers.get("content-length")) > 65_536 || !response.body)
28
+ throw new Error("invalid_response");
29
+ const chunks = []; let size = 0;
30
+ for await (const chunk of response.body) {
31
+ size += chunk.byteLength;
32
+ if (size > 65_536) throw new Error("invalid_response");
33
+ chunks.push(chunk);
34
+ }
35
+ try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
36
+ catch { throw new Error("invalid_response"); }
37
+ };
38
+ const setupOptions = (values) => {
39
+ const result = { appUrl: "https://app.commish.sh", keyLabel: "Agent setup", mode: "test", open: true };
40
+ const keys = new Set();
41
+ for (let index = 0; index < values.length; index++) {
42
+ const name = values[index];
43
+ if (name === "--json" || name === "--no-open") {
44
+ if (keys.has(name)) throw new Error("invalid_arguments");
45
+ keys.add(name);
46
+ if (name === "--json") result.json = true;
47
+ else result.open = false;
48
+ continue;
49
+ }
50
+ const fields = {
51
+ "--workspace": "workspaceId", "--output": "output",
52
+ "--application-name": "applicationName", "--key-label": "keyLabel",
53
+ "--app-url": "appUrl", "--mode": "mode",
54
+ };
55
+ const field = fields[name], value = values[++index];
56
+ if (!field || keys.has(name) || !value || value.startsWith("--"))
57
+ throw new Error("invalid_arguments");
58
+ keys.add(name); result[field] = value;
59
+ }
60
+ if (!/^wrk_[A-Za-z0-9_-]{12,}$/.test(result.workspaceId ?? "") ||
61
+ !result.output || !result.applicationName?.trim() || result.applicationName.length > 100 ||
62
+ !result.keyLabel.trim() || result.keyLabel.length > 100 ||
63
+ !["test", "live"].includes(result.mode))
64
+ throw new Error("invalid_arguments");
65
+ result.applicationName = result.applicationName.trim();
66
+ result.keyLabel = result.keyLabel.trim();
67
+ result.appUrl = safeUrl(result.appUrl);
68
+ return result;
69
+ };
70
+ const openBrowser = (url) => {
71
+ const command = process.platform === "darwin" ? ["open", [url]]
72
+ : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
73
+ : ["xdg-open", [url]];
74
+ const child = spawn(command[0], command[1], { detached: true, stdio: "ignore" });
75
+ child.on("error", () => {}); child.unref();
76
+ };
77
+ const credentialTarget = (output) => {
78
+ const target = resolve(output), parent = dirname(target);
79
+ const parentEntry = lstatSync(parent);
80
+ if (!parentEntry.isDirectory() || parentEntry.isSymbolicLink())
81
+ throw new Error("unsafe_output_path");
82
+ try {
83
+ lstatSync(target);
84
+ const error = new Error("output_exists"); error.code = "EEXIST"; throw error;
85
+ } catch (error) {
86
+ if (error.code !== "ENOENT") throw error;
87
+ }
88
+ return target;
89
+ };
90
+ const saveCredentials = (output, values) => {
91
+ const target = credentialTarget(output), parent = dirname(target);
92
+ const temporary = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
93
+ const body = Object.entries(values).map(([name, value]) => `${name}=${value}\n`).join("");
94
+ try {
95
+ writeFileSync(temporary, body, { flag: "wx", mode: 0o600 });
96
+ linkSync(temporary, target);
97
+ } finally {
98
+ try { unlinkSync(temporary); } catch {}
99
+ }
100
+ return target;
101
+ };
102
+ const setupReceipt = (body, expected) => {
103
+ const value = body?.data, app = value?.application, key = value?.apiKey;
104
+ if (value?.protocol !== "commish-cli-setup-v1" || value.status !== "complete" ||
105
+ value.workspaceId !== expected.workspaceId || typeof value.expiresAt !== "string" ||
106
+ typeof value.replayed !== "boolean" || !/^app_[A-Za-z0-9_-]{12,}$/.test(app?.id ?? "") ||
107
+ app?.name !== expected.applicationName || !Array.isArray(app?.verifiedOrigins) ||
108
+ typeof app?.createdAt !== "string" || !/^key_[A-Za-z0-9_-]{12,}$/.test(key?.id ?? "") ||
109
+ key?.applicationId !== app.id || key?.mode !== expected.mode ||
110
+ (value.mode ?? "test") !== expected.mode ||
111
+ key?.publishableKey !== expected.publishableKey || key?.label !== expected.keyLabel ||
112
+ typeof key?.createdAt !== "string" || key?.lastUsedAt !== null || key?.revokedAt !== null)
113
+ throw new Error("invalid_response");
114
+ return value;
115
+ };
116
+ const setup = async (values) => {
117
+ const options = setupOptions(values), output = credentialTarget(options.output),
118
+ verifier = randomBytes(32).toString("base64url"),
119
+ publishableKey = `cm_${options.mode}_pk_${token()}`, secretKey = `cm_${options.mode}_sk_${token()}`,
120
+ expiresAt = new Date(Date.now() + 570_000).toISOString(),
121
+ request = {
122
+ workspaceId: options.workspaceId, mode: options.mode, challengeHash: hash(verifier),
123
+ applicationName: options.applicationName, keyLabel: options.keyLabel,
124
+ publishableKey, secretHash: hash(secretKey),
125
+ idempotencyKey: `cli-setup:${randomUUID()}`, expiresAt,
126
+ }, approval = new URL("/cli/setup", options.appUrl);
127
+ approval.search = new URLSearchParams(request).toString();
128
+ process.stderr.write(`Authorize ${options.mode.toUpperCase()} setup in your browser:\n${approval.href}\n`);
129
+ if (options.open) openBrowser(approval.href);
130
+ const exchange = new URL("/api/cli/setup-grants/exchange", options.appUrl);
131
+ let receipt;
132
+ while (Date.now() < Date.parse(expiresAt)) {
133
+ let response;
134
+ try {
135
+ response = await fetch(exchange, {
136
+ method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
137
+ body: JSON.stringify({ verifier }), redirect: "error", cache: "no-store",
138
+ signal: AbortSignal.timeout(10_000),
139
+ });
140
+ const body = await responseJson(response);
141
+ if (response.ok) { receipt = setupReceipt(body, { ...options, publishableKey }); break; }
142
+ if (response.status !== 408 && response.status !== 429 && response.status !== 428 &&
143
+ response.status < 500)
144
+ throw new Error(body?.error?.code === "setup_grant_expired" ? "setup_expired" : "setup_denied");
145
+ } catch (error) {
146
+ if (["setup_expired", "setup_denied", "invalid_response"].includes(error.message)) throw error;
147
+ }
148
+ await delay(2_000);
149
+ }
150
+ if (!receipt) throw new Error("setup_expired");
151
+ const file = saveCredentials(output, {
152
+ COMMISH_API_URL: new URL("/api/v1", options.appUrl).href,
153
+ COMMISH_SECRET_KEY: secretKey,
154
+ NEXT_PUBLIC_COMMISH_PUBLISHABLE_KEY: publishableKey,
155
+ NEXT_PUBLIC_COMMISH_APPLICATION_ID: receipt.application.id,
156
+ });
157
+ return {
158
+ status: `${options.mode}_credentials_configured`, mode: options.mode, workspaceId: options.workspaceId,
159
+ applicationId: receipt.application.id, apiKeyId: receipt.apiKey.id, output: file,
160
+ replayed: receipt.replayed, integrationVerified: false,
161
+ next: `Configure a ${options.mode.toUpperCase()} program, then run commish-next verify --json with COMMISH_PROGRAM_ID.`,
162
+ };
163
+ };
164
+
165
+ const args = process.argv.slice(2);
166
+ const json = args.includes("--json");
167
+ if (args[0] === "setup") {
168
+ try {
169
+ const result = await setup(args.slice(1));
170
+ console.log(json ? JSON.stringify(result) :
171
+ `Commish ${result.mode.toUpperCase()} credentials saved to ${result.output}.\n${result.next}`);
172
+ } catch (error) {
173
+ const code = ["invalid_arguments", "invalid_app_url", "unsafe_output_path", "invalid_response",
174
+ "setup_expired", "setup_denied"].includes(error?.message) ? error.message :
175
+ error?.code === "EEXIST" ? "output_exists" : "setup_unavailable";
176
+ console.error(json ? JSON.stringify({ status: "error", code }) : `Setup failed: ${code}.`);
177
+ process.exitCode = 1;
178
+ }
179
+ } else if (args[0] === "verify") {
180
+ const fail = (code) => { throw new Error(code); };
181
+ try {
182
+ if (args.slice(1).some((arg) => arg !== "--json") || new Set(args).size !== args.length)
183
+ fail("invalid_arguments");
184
+ const env = process.env;
185
+ const secret = env.COMMISH_SECRET_KEY ?? "";
186
+ const key = secret.match(/^cm_(test|live)_sk_[A-Za-z0-9_-]{12,}$/);
187
+ const publishable = (env.NEXT_PUBLIC_COMMISH_PUBLISHABLE_KEY ?? "").match(/^cm_(test|live)_pk_[A-Za-z0-9_-]{12,}$/);
188
+ const applicationId = env.NEXT_PUBLIC_COMMISH_APPLICATION_ID;
189
+ const programId = env.COMMISH_PROGRAM_ID;
190
+ if (!key || !publishable || !/^app_[A-Za-z0-9_-]{12,}$/.test(applicationId ?? "") ||
191
+ !/^prg_[A-Za-z0-9_-]{12,}$/.test(programId ?? "")) fail("invalid_configuration");
192
+ const mode = key[1] === "live" ? "live" : "test";
193
+ if (mode !== publishable[1]) fail("key_mode_mismatch");
194
+ let api;
195
+ try { api = new URL(env.COMMISH_API_URL ?? "https://app.commish.sh/api/v1"); }
196
+ catch { fail("invalid_api_url"); }
197
+ if (api.username || api.password || api.search || api.hash ||
198
+ !/^\/api\/v1\/?$/.test(api.pathname) ||
199
+ !(api.protocol === "https:" || api.protocol === "http:" &&
200
+ ["127.0.0.1", "[::1]", "localhost"].includes(api.hostname))) fail("invalid_api_url");
201
+ const response = await fetch(`${api.href.replace(/\/$/, "")}/programs/${programId}`, {
202
+ method: "GET", headers: { authorization: `Bearer ${secret}`, accept: "application/json" },
203
+ redirect: "error", cache: "no-store", signal: AbortSignal.timeout(10_000),
204
+ });
205
+ if (!response.ok) {
206
+ await response.body?.cancel();
207
+ fail(({ 401: "invalid_api_key", 403: "access_denied", 404: "program_not_found" })[response.status] ?? "verification_unavailable");
208
+ }
209
+ const chunks = []; let size = 0;
210
+ if (!response.body) fail("invalid_response");
211
+ for await (const chunk of response.body) {
212
+ size += chunk.byteLength;
213
+ if (size > 65_536) fail("invalid_response");
214
+ chunks.push(chunk);
215
+ }
216
+ let program;
217
+ try { program = JSON.parse(Buffer.concat(chunks).toString("utf8"))?.data; }
218
+ catch { fail("invalid_response"); }
219
+ if (!program || program.id !== programId ||
220
+ !["test", "live"].includes(program.mode) ||
221
+ !["draft", "active", "paused", "suspended", "archived"].includes(program.status)) fail("invalid_response");
222
+ if (program.applicationId !== applicationId) fail("application_mismatch");
223
+ if (program.mode !== mode) fail("program_mode_mismatch");
224
+ const result = {
225
+ status: "configuration_verified", mode, applicationId, programId,
226
+ programStatus: program.status,
227
+ checks: ["secret_key_authenticated", "program_accessible", "application_matches", "mode_matches", "publishable_key_mode_matches"],
228
+ unverified: ["publishable_key_binding", "consumer_wiring", "attribution", "checkout", "webhooks", "refunds", "renewals", "payouts"],
229
+ integrationVerified: false,
230
+ };
231
+ console.log(json ? JSON.stringify(result) :
232
+ `Configuration verified (${result.mode}, program ${programId}, status ${program.status}).\n` +
233
+ "Publishable-key binding and end-to-end integration remain unverified. Use --json for individual checks.");
234
+ } catch (error) {
235
+ const codes = ["invalid_arguments", "invalid_configuration", "key_mode_mismatch", "invalid_api_url",
236
+ "invalid_api_key", "access_denied", "program_not_found", "verification_unavailable",
237
+ "invalid_response", "application_mismatch", "program_mode_mismatch"];
238
+ const code = codes.includes(error?.message) ? error.message : "verification_unavailable";
239
+ console.error(json ? JSON.stringify({ status: "error", code, integrationVerified: false }) : `Verification failed: ${code}.`);
240
+ process.exitCode = 1;
241
+ }
242
+ } else try {
243
+ if (args.some((arg) => !["init", "--write", "--json"].includes(arg)) ||
244
+ new Set(args).size !== args.length) throw new Error("Usage: commish-next [init] [--write] [--json]");
245
+ const stat = (path) => {
246
+ try { return lstatSync(path); } catch (error) {
247
+ if (error.code === "ENOENT") return null;
248
+ throw error;
249
+ }
250
+ };
251
+ // Next ignores src/app when a root app directory exists.
252
+ const app = stat("app") ? "app" : stat("src/app") ? "src/app" : null;
253
+ if (!app) throw new Error("No Next.js App Router directory found.");
254
+ const safePath = (path) => {
255
+ let current = ".";
256
+ for (const part of path.split("/")) {
257
+ current = join(current, part);
258
+ const entry = stat(current);
259
+ if (entry && (entry.isSymbolicLink() || (current !== path && !entry.isDirectory())))
260
+ throw new Error(`Refusing unsafe path: ${current}`);
261
+ }
262
+ };
263
+ safePath(`${app}/`);
264
+ const typed = Boolean(stat("tsconfig.json") || stat(`${app}/layout.tsx`));
265
+ const files = {
266
+ [`${app}/api/commish/attribution/route.${typed ? "ts" : "js"}`]: `import { createAttributionHandler } from "@commish/next";
267
+
268
+ export const runtime = "nodejs";
269
+ export const POST = createAttributionHandler({
270
+ apiUrl: process.env.COMMISH_API_URL,
271
+ secretKey: process.env.COMMISH_SECRET_KEY,
272
+ });
273
+ `,
274
+ [`${app}/commish-provider.${typed ? "tsx" : "jsx"}`]: `import { CommishProvider } from "@commish/next/react";
275
+ ${typed ? 'import type { ReactNode } from "react";\n' : ""}
276
+ export default function CommishRootProvider({ children }${typed ? ": { children: ReactNode }" : ""}) {
277
+ return (
278
+ <CommishProvider
279
+ publishableKey={process.env.NEXT_PUBLIC_COMMISH_PUBLISHABLE_KEY${typed ? "!" : ""}}
280
+ applicationId={process.env.NEXT_PUBLIC_COMMISH_APPLICATION_ID${typed ? "!" : ""}}
281
+ >
282
+ {children}
283
+ </CommishProvider>
284
+ );
285
+ }
286
+ `,
287
+ };
288
+ for (const extension of ["js", "jsx", "ts", "tsx"])
289
+ if (stat(`${app}/api/commish/attribution/page.${extension}`))
290
+ throw new Error("The attribution URL already has a page. Choose a different capturePath and integrate using the Next setup guide.");
291
+ const plan = Object.entries(files).map(([path, content]) => {
292
+ safePath(path);
293
+ for (const extension of ["js", "jsx", "ts", "tsx"])
294
+ if (path.replace(/\.[^.]+$/, `.${extension}`) !== path &&
295
+ stat(path.replace(/\.[^.]+$/, `.${extension}`)))
296
+ throw new Error(`Conflicting file extension for ${path}; integrate using the Next setup guide.`);
297
+ const entry = stat(path);
298
+ return { path, status: !entry ? "create" :
299
+ entry.isFile() && readFileSync(path, "utf8") === content ? "unchanged" : "conflict" };
300
+ });
301
+ const conflicts = plan.filter(({ status }) => status === "conflict");
302
+ if (conflicts.length) throw new Error(`Existing custom files: ${conflicts.map(({ path }) => path).join(", ")}. No files changed; integrate using the Next setup guide.`);
303
+ const write = args.includes("--write");
304
+ if (write) for (const { path, status } of plan) if (status === "create") {
305
+ mkdirSync(dirname(path), { recursive: true });
306
+ writeFileSync(path, files[path], { flag: "wx" });
307
+ }
308
+ const result = {
309
+ status: write ? "files_installed" : "plan", app, files: plan,
310
+ requiredEnvironment: ["COMMISH_API_URL", "COMMISH_SECRET_KEY",
311
+ "NEXT_PUBLIC_COMMISH_PUBLISHABLE_KEY", "NEXT_PUBLIC_COMMISH_APPLICATION_ID"],
312
+ next: [
313
+ `Import CommishRootProvider from './commish-provider' in the existing ${app}/layout file and wrap its existing children. Preserve the layout's other content.`,
314
+ "Set matching application credentials for the selected TEST or LIVE mode. Keep COMMISH_SECRET_KEY server-only.",
315
+ "Await referral capture before Checkout; await withCommishStripeMetadata(params) in your authenticated server Checkout handler.",
316
+ "Build the application, then verify a referral capture and attributed Checkout in TEST before LIVE activation.",
317
+ ],
318
+ integrationVerified: false,
319
+ };
320
+ console.log(json ? JSON.stringify(result) : [
321
+ write ? "Commish integration files installed." : "Commish setup plan (use --write to install).",
322
+ ...plan.map(({ path, status }) => `${status}: ${path}`),
323
+ `Required environment: ${result.requiredEnvironment.join(", ")}`,
324
+ ...result.next,
325
+ ].join("\n"));
326
+ } catch (error) {
327
+ console.error(json ? JSON.stringify({ status: "error", message: error.message }) : error.message);
328
+ process.exitCode = 1;
329
+ }
@@ -0,0 +1,2 @@
1
+ export { captureReferral } from "@commish/sdk/browser";
2
+ export type { CommishBrowserOptions } from "@commish/sdk/browser";
@@ -0,0 +1 @@
1
+ export { captureReferral } from "@commish/sdk/browser";
@@ -0,0 +1,5 @@
1
+ export declare const COMMISH_COOKIE = "commish_attribution";
2
+ export declare function createAttributionHandler(options: {
3
+ apiUrl?: string;
4
+ secretKey?: string;
5
+ }): (request: Request) => Promise<Response>;
@@ -0,0 +1,123 @@
1
+ import { cookies } from "next/headers.js";
2
+ export const COMMISH_COOKIE = "commish_attribution";
3
+ export function createAttributionHandler(options) {
4
+ return async function POST(request) {
5
+ // Loaded per request so the root module stays importable wherever the
6
+ // cookie helpers are: only the handler needs the server response builder
7
+ // and the Node hash implementation.
8
+ const { NextResponse } = await import("next/server.js");
9
+ const body = await request.text();
10
+ const captureId = request.headers.get("x-commish-capture-id");
11
+ if (!captureId || !/^[a-f\d]{32}$/.test(captureId))
12
+ return NextResponse.json({
13
+ error: {
14
+ code: "invalid_capture_id",
15
+ message: "A valid browser capture ID is required.",
16
+ },
17
+ }, { status: 400 });
18
+ const configuredUrl = options.apiUrl ?? "https://app.commish.sh/api/v1";
19
+ let urlEnd = configuredUrl.length;
20
+ while (urlEnd > 0 && configuredUrl[urlEnd - 1] === "/")
21
+ urlEnd--;
22
+ const apiUrl = configuredUrl.slice(0, urlEnd);
23
+ const clientIp = request.headers.get("x-vercel-forwarded-for");
24
+ const userAgent = request.headers.get("user-agent");
25
+ const country = request.headers.get("x-vercel-ip-country");
26
+ const previousAttributionId = (await cookies()).get(COMMISH_COOKIE)?.value;
27
+ let idempotencyBody = body;
28
+ let forwardedBody = body;
29
+ try {
30
+ const parsed = JSON.parse(body);
31
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
32
+ const { previousAttributionId: _untrusted, ...capture } = parsed;
33
+ void _untrusted;
34
+ idempotencyBody = JSON.stringify(capture);
35
+ forwardedBody = JSON.stringify({
36
+ ...capture,
37
+ ...(previousAttributionId &&
38
+ /^atr_[A-Za-z0-9_-]{12,}$/.test(previousAttributionId)
39
+ ? { previousAttributionId }
40
+ : {}),
41
+ });
42
+ }
43
+ }
44
+ catch {
45
+ // Preserve malformed input for the API contract to reject consistently.
46
+ }
47
+ const { createHash } = await import("node:crypto");
48
+ const idempotencyKey = `attribution:${createHash("sha256")
49
+ .update(`${captureId}.${idempotencyBody}`)
50
+ .digest("hex")}`;
51
+ let response;
52
+ let responseText;
53
+ try {
54
+ response = await fetch(`${apiUrl}/attributions/capture`, {
55
+ method: "POST",
56
+ headers: {
57
+ "content-type": "application/json",
58
+ "idempotency-key": idempotencyKey,
59
+ "x-commish-publishable-key": request.headers.get("x-commish-publishable-key") ?? "",
60
+ ...(options.secretKey
61
+ ? { authorization: `Bearer ${options.secretKey}` }
62
+ : {}),
63
+ ...(clientIp ? { "x-commish-client-ip": clientIp } : {}),
64
+ ...(userAgent ? { "user-agent": userAgent } : {}),
65
+ ...(country ? { "x-vercel-ip-country": country } : {}),
66
+ },
67
+ body: forwardedBody,
68
+ });
69
+ responseText = await response.text();
70
+ }
71
+ catch {
72
+ // An unreachable or truncated upstream is a transport failure, not an
73
+ // attribution outcome: answer the caller instead of rejecting the route.
74
+ return NextResponse.json({
75
+ error: {
76
+ code: "attribution_capture_unavailable",
77
+ message: "Attribution capture is temporarily unavailable.",
78
+ },
79
+ }, { status: 502 });
80
+ }
81
+ let payload;
82
+ try {
83
+ payload = responseText ? JSON.parse(responseText) : undefined;
84
+ }
85
+ catch {
86
+ payload = undefined;
87
+ }
88
+ if (!response.ok)
89
+ return NextResponse.json(payload && typeof payload === "object"
90
+ ? payload
91
+ : {
92
+ error: {
93
+ code: "attribution_capture_failed",
94
+ message: "Attribution capture failed.",
95
+ },
96
+ }, { status: response.status });
97
+ const capturePayload = payload && typeof payload === "object"
98
+ ? payload
99
+ : undefined;
100
+ const token = capturePayload?.data?.token;
101
+ const expiresAt = capturePayload?.data?.expiresAt;
102
+ const expiresAtMs = typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
103
+ const maxAge = Math.floor((expiresAtMs - Date.now()) / 1000);
104
+ if (typeof token !== "string" || !token || typeof expiresAt !== "string" ||
105
+ !Number.isFinite(expiresAtMs) || maxAge <= 0)
106
+ return NextResponse.json({
107
+ error: {
108
+ code: "invalid_capture_response",
109
+ message: "Commish returned an invalid attribution response.",
110
+ },
111
+ }, { status: 502 });
112
+ const outgoing = NextResponse.json({ data: { captured: true, expiresAt } }, { status: response.status });
113
+ outgoing.cookies.set(COMMISH_COOKIE, token, {
114
+ httpOnly: true,
115
+ sameSite: "lax",
116
+ secure: process.env.NODE_ENV === "production",
117
+ path: "/",
118
+ expires: new Date(expiresAtMs),
119
+ maxAge,
120
+ });
121
+ return outgoing;
122
+ };
123
+ }
@@ -0,0 +1,5 @@
1
+ export { COMMISH_COOKIE, createAttributionHandler } from "./capture.js";
2
+ import { applyCommishStripeMetadata, type StripeCheckoutParams } from "./metadata.js";
3
+ export { applyCommishStripeMetadata };
4
+ export declare function getCommishAttribution(): Promise<string | null>;
5
+ export declare function withCommishStripeMetadata<T extends StripeCheckoutParams>(params: T): Promise<T>;
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ import { cookies } from "next/headers.js";
2
+ import { COMMISH_COOKIE } from "./capture.js";
3
+ export { COMMISH_COOKIE, createAttributionHandler } from "./capture.js";
4
+ import { applyCommishStripeMetadata } from "./metadata.js";
5
+ export { applyCommishStripeMetadata };
6
+ export async function getCommishAttribution() {
7
+ return (await cookies()).get(COMMISH_COOKIE)?.value ?? null;
8
+ }
9
+ export async function withCommishStripeMetadata(params) {
10
+ return applyCommishStripeMetadata(params, await getCommishAttribution());
11
+ }
@@ -0,0 +1,12 @@
1
+ type StripeMetadata = Record<string, string | number | null>;
2
+ export type StripeCheckoutParams = {
3
+ mode?: string;
4
+ client_reference_id?: string;
5
+ metadata?: StripeMetadata;
6
+ subscription_data?: {
7
+ metadata?: StripeMetadata;
8
+ [key: string]: unknown;
9
+ };
10
+ };
11
+ export declare function applyCommishStripeMetadata<T extends StripeCheckoutParams>(params: T, attribution: string | null): T;
12
+ export {};
@@ -0,0 +1,22 @@
1
+ export function applyCommishStripeMetadata(params, attribution) {
2
+ if (!attribution)
3
+ return params;
4
+ return {
5
+ ...params,
6
+ metadata: { ...params.metadata, commish_attribution: attribution },
7
+ ...(params.mode === "subscription"
8
+ ? {
9
+ subscription_data: {
10
+ ...params.subscription_data,
11
+ metadata: {
12
+ ...params.subscription_data?.metadata,
13
+ commish_attribution: attribution,
14
+ ...(params.client_reference_id
15
+ ? { commish_customer_id: params.client_reference_id }
16
+ : {}),
17
+ },
18
+ },
19
+ }
20
+ : {}),
21
+ };
22
+ }
@@ -0,0 +1,10 @@
1
+ import { type ReactNode } from "react";
2
+ type CommishCaptureProps = {
3
+ publishableKey: string;
4
+ applicationId: string;
5
+ capturePath?: string;
6
+ };
7
+ export declare function CommishProvider({ children, ...captureProps }: CommishCaptureProps & {
8
+ children: ReactNode;
9
+ }): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Suspense, useEffect } from "react";
4
+ import { captureReferral } from "@commish/sdk/browser";
5
+ import { usePathname, useSearchParams } from "next/navigation.js";
6
+ function CommishCapture({ publishableKey, applicationId, capturePath, }) {
7
+ const pathname = usePathname();
8
+ const searchParams = useSearchParams();
9
+ useEffect(() => void captureReferral({ publishableKey, applicationId, capturePath }), [publishableKey, applicationId, capturePath, pathname, searchParams]);
10
+ return null;
11
+ }
12
+ export function CommishProvider({ children, ...captureProps }) {
13
+ return (_jsxs(_Fragment, { children: [_jsx(Suspense, { fallback: null, children: _jsx(CommishCapture, { ...captureProps }) }), children] }));
14
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@commish/next",
3
+ "version": "0.1.0-beta.10",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/oftring-ventures/commish-sdk.git",
8
+ "directory": "packages/next"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=24 <25"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "tag": "beta"
17
+ },
18
+ "description": "Next.js referral capture and Stripe metadata helpers for Commish",
19
+ "exports": {
20
+ ".": {
21
+ "browser": null,
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./browser": {
26
+ "types": "./dist/browser.d.ts",
27
+ "default": "./dist/browser.js"
28
+ },
29
+ "./react": {
30
+ "types": "./dist/provider.d.ts",
31
+ "default": "./dist/provider.js"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@commish/sdk": "0.1.0-beta.10",
36
+ "next": ">=16.2.12 <17",
37
+ "react": ">=19.2.8 <20"
38
+ },
39
+ "bin": {
40
+ "commish-next": "./bin/init.mjs"
41
+ }
42
+ }