diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 8808784..666096d 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -68,17 +68,17 @@ Every command at a glance — what it does, whether it reads your source, what i | Command | What it does | Reads your source? | Writes to your project | Sends over the network | |---|---|---|---|---| -| `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No source analysis — lockfile only; `node_modules/` is enumerated when no lockfile can be read (e.g. `bun.lockb`) or when the lockfiles present disagree. It also reads the `` of the root `index.html` and the `name` in `package.json`, to report what the site is called. During `prebuild` only, it reads the scaffolded guard and its co-located rules JSON to remove a previous map stamp. | `.patchstackrc.json` (public: site UUID + settings); `.patchstackrc.local.json` (the API key, created owner-only) and a `.gitignore` entry for it — the CLI says so if it could not add one; during `prebuild`, removal of a previous `_patchstack.build_id` from the guard's own rules file so a later build cannot carry stale coordinates; the widget `<script>` tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID | Package names + versions; this site's public address and name, where the project or build environment states them | +| `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No source analysis — lockfile only; `node_modules/` is enumerated when no lockfile can be read (e.g. `bun.lockb`) or when the lockfiles present disagree. It also reads the `<title>` of the root `index.html` and the `name` in `package.json`, to report what the site is called. During `prebuild` only, it reads the scaffolded guard and its co-located rules JSON to remove a previous map stamp. | `.patchstackrc.json` (public: site UUID + settings, the link to connect the site, and whether it is connected yet); `.patchstackrc.local.json` (the API key, created owner-only) and a `.gitignore` entry for it — the CLI says so if it could not add one; during `prebuild`, removal of a previous `_patchstack.build_id` from the guard's own rules file so a later build cannot carry stale coordinates; the widget `<script>` tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID | Package names + versions; this site's public address and name, where the project or build environment states them; the site UUID, to check whether the site is connected to an account yet (skipped once `.patchstackrc.json` records that it is) | | `setup` | One bounded command: `scan` → manage the widget → install + verify `protect` → wire the install/build scans. Never runs the project build. | Local integration reads via `scan` and `protect` | Config, widget tag, production marker, guard/framework/route files, `package.json` scripts | Package names + versions and the site's public address and name (via `scan`); a claim token as a request header, only when you pass one | | `map` | Local attack-surface analysis (entry points → inputs → sinks → evidence-backed flows). Never run by another command. | **Yes** — via the app's own TypeScript; a pre-bundle `--upload` also locates the co-located rules file imported by the scaffolded guard | Only the file named by `--out`; during a pre-bundle `--upload`, `_patchstack.build_id` in that existing rules file | Nothing — **unless `--upload`**: structure only (routes, parameter names, the package behind each sink, file:line) plus a SHA-256 identity derived from its policy content. Never source code or env values | | `protect` | Install the always-on runtime guard; auto-wire known stacks, or scaffold a generic guard + print a wiring plan. `--check` verifies the guard is wired (exit 1 if not); `--demo` seeds a broad sample rule set. Runs automatically **only** via `setup` — never by `scan`, `guide`, `status`, or `mark-build`. | Reads local wiring/route source for integration; does not produce an attack-surface map | Guard/framework files (e.g. `middleware.ts`, `src/patchstack/`, supported Next App Router handlers) | Nothing | | `demo node-serialize` | Production-backed walkthrough: confirm the vulnerable package is present, scan, wait for live rule `18843`, install + verify the guard, print test requests. Does not install the package or start/restart the app. | Local integration reads via `scan` and `protect` | Same files as `scan` + `protect` | `scan` payload; polls the public Pulse rules endpoint (never the printed test requests) | | `demo-guide node-serialize` | Read-only companion: explains the prepare/run/prove/cleanup sequence and prints the next command. | Reads local protection wiring for verification | Nothing | Nothing | | `guide` | Print this project's live setup status (done/missing, with tailored commands), then the full guide. `--full` prints it even when setup is complete. | Reads local protection wiring for verification | Nothing | Nothing | -| `status` | Re-print the site UUID + dashboard URL and check whether the site still exists (active / removed / could not verify). | No | Nothing | Site-existence check | +| `status` | Re-print the site UUID + dashboard URL and check whether the site still exists (active / removed / could not verify) and whether it is connected to an account. | No | The connected note in `.patchstackrc.json`, when it changed | Site-existence and connection checks | | `init <site-uuid>` | Optional: pre-seed `.patchstackrc.json` with an existing UUID. | No | `.patchstackrc.json` only | Nothing | | `mark-build` | Ensure the widget tag in built pages, and — **on a production build only** — stamp the live-site flag + build fingerprint. A local or preview build is stamped with neither, and has a stale marker removed; `--production` forces it for a build published by hand. Run as a `postbuild` step. | No | Build output only (`dist/ build/ out/ .output/public/ _site/`) — never source | The same manifest `scan` sent, plus one word for what it did with the marker — and only for a site already registered | -| `claim` | Attach the site to a Patchstack account from the terminal: print a link the user opens to sign in (or sign up) and poll (10 min). Whoever approves becomes the owner. Does **not** rotate the credential. Same result as opening the dashboard link `scan` prints. Not usable in CI. | No | Nothing, unless the server issues a credential for a checkout that had none — then `.patchstackrc.local.json` | Device-code request + approval poll | +| `claim` | Attach the site to a Patchstack account from the terminal: print a link the user opens to sign in (or sign up) and poll (10 min). Whoever approves becomes the owner. Does **not** rotate the credential. Same result as opening the dashboard link `scan` prints. Not usable in CI. | No | The connected note in `.patchstackrc.json`; and, if the server issues a credential for a checkout that had none, `.patchstackrc.local.json` | Device-code request + approval poll | | `login` | Recover a lost credential for an existing site: print an owner-approval link and poll (10 min). Approving **rotates** the credential. Not usable in CI. | No | New credential into `.patchstackrc.local.json` on approval | Device-code request + approval poll | | `uninstall` | Signal Patchstack that the package is being removed: an unclaimed record is deleted, a claimed one is flagged. Does **not** touch local files. | No | Nothing local | Removal signal | @@ -97,7 +97,7 @@ Only `map` produces an attack-surface analysis, and only `map --upload` sends th - **Only `map` produces an attack-surface analysis.** It parses your server source to report your app's attack surface. It runs only when you invoke it and prints to stdout. It transmits nothing unless you explicitly pass `--upload`, which sends that description of your app's structure to your own site's Patchstack endpoint — never source code, and never without that flag. `protect` separately parses supported wiring and route files for local integration, without producing or uploading a map. A `prebuild` scan reads the scaffolded guard and rules JSON only to identify and clear the reserved map stamp; it does not analyse them or transmit their contents. - **`scan` makes up to three source edits:** the Patchstack Connector's `<script>` tag, the production marker, and — during `prebuild` only — removal of a previous `_patchstack.build_id` from the existing guard rules file. None runs on `--dry-run`; all are idempotent. `"widget": false` disables the first two, while stale-stamp removal is independent because it prevents old coordinates being attributed to a new build. - The **widget tag** goes in the root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists — and only after a successful post, because it carries the site UUID. - - The **production marker** goes in a root shell that is JSX rather than HTML (e.g. `src/routes/__root.tsx`, `app/layout.tsx`), inside a `{/* #region patchstack */}` block placed above the widget tag. It is written *before* the post: it carries no site UUID and needs no network, and build scripts commonly chain `patchstack-connect scan || true`, where waiting on the server would mean an offline build silently ships without the flag. The marker is guarded by the framework's own production expression (`import.meta.env.PROD`, or `process.env.NODE_ENV === 'production'`), so it is inert in dev and preview builds. Without it a server-rendered site has no built HTML for `mark-build` to stamp, and the widget treats the published site as build mode. `mark-build` writes to build output only (`dist/`, `build/`, `out/`, `.output/public`), never to source. `guide`, `status`, and `init` write nothing except `init`'s own `.patchstackrc.json`. + - The **production marker** goes in a root shell that is JSX rather than HTML (e.g. `src/routes/__root.tsx`, `app/layout.tsx`), inside a `{/* #region patchstack */}` block placed above the widget tag. It is written *before* the post: it carries no site UUID and needs no network, and build scripts commonly chain `patchstack-connect scan || true`, where waiting on the server would mean an offline build silently ships without the flag. The marker is guarded by the framework's own production expression (`import.meta.env.PROD`, or `process.env.NODE_ENV === 'production'`), so it is inert in dev and preview builds. Without it a server-rendered site has no built HTML for `mark-build` to stamp, and the widget treats the published site as build mode. `mark-build` writes to build output only (`dist/`, `build/`, `out/`, `.output/public`), never to source. `guide` writes nothing, `init` writes only its own `.patchstackrc.json`, and `status` writes only the connected note in `.patchstackrc.json`. `guide` sends nothing either: it reports a site as connected from that note, which `scan`, `status` and `claim` keep current. - **`setup` runs `scan`, then `protect`, then edits `package.json` scripts:** provisioning happens first so the runtime guard can bake the real site UUID. It verifies the resulting framework seam, preserves existing commands, adds `scan` after dependency installs and before builds, adds `mark-build` after builds, and uses a direct build chain for Bun. It never runs the project build. If the widget or runtime guard needs a framework-specific manual merge, it prints the exact remaining step instead of overwriting user code. - The package also exposes **`protect`** directly (runtime exploit guard; its templates live under `dist/protect/`). `setup` invokes it automatically; `scan`, `guide`, `status`, and `mark-build` do not. It writes only local files and auto-wires known stacks — **TanStack Start + Supabase** (patches the Supabase client + `src/start.ts`), **Next.js** (scaffolds or composes middleware and adds request/response checks to supported App Router handlers), **SvelteKit** (`src/hooks.server.ts`), **Astro** (`src/middleware.ts`), **Nuxt** (`server/middleware/`), **NestJS** (`app.use(patchstackMiddleware)` in the bootstrap), **Fastify** (`app.register(patchstackFastify)`), and **Express** (`app.use(patchstackMiddleware)`). On **any other stack** it scaffolds a framework-agnostic guard under `src/patchstack/` and prints a wiring plan — then you finish the install by importing that guard into your server entry (`protectFetch(handler)` for a Web-Fetch server, or `app.use(patchstackMiddleware)` for Node/Express) and running `patchstack-connect protect --check` to confirm it is wired (exit 1 until it is). Passing `--demo` seeds a broad sample rule set (for demonstrations, not production). - **`demo node-serialize` is an explicit production-backed walkthrough.** It requires `node-serialize@0.0.4` to already be present in the lockfile; it does not install the vulnerable dependency. It runs the same production `scan`, polls the configured site's public Pulse rules endpoint until rule `18843` is served, runs `protect`, verifies the generated guard, and prints exploit/benign test requests. It writes the same manifest/widget and guard files as those underlying commands. It does not start/restart the app and does not send the printed requests. @@ -776,11 +776,15 @@ When more than one guard in a process has `egress: true`, an outbound call is ch refused if any one refuses it. A host listed in one guard's `allowHosts` is still refused when another guard refuses it. -Two more endpoints the package can call, for completeness: +Three more endpoints the package can call, for completeness: - `GET monitor/widget/settings/<your site uuid>` — how `status` tells "this site was deleted on Patchstack" apart from "still active". It sends no credential and nothing about your project; the site UUID in the path is the whole request. +- `GET monitor/claim/preview?site=<your site uuid>` — how `scan` and `status` tell whether the site is + connected to an account yet, the same lookup the claim link's page makes. It sends no credential and + nothing about your project; the site UUID is the whole request. `scan` stops asking once + `.patchstackrc.json` records `"claimed": true`. - `GET api/get-rules/3` — the older rules path, used only when the guard is configured with a `token` instead of a site UUID. The zero-configuration flow provisions a site UUID and uses `monitor/pulse/rules/<uuid>` instead, so this is unreachable unless you pass `token` yourself. diff --git a/README.md b/README.md index 1d32055..bf0b9ae 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,8 @@ Two files, because one value is public and the other is not. `"widget"` is optional and defaults to `true`; set it to `false` to stop Connect from managing the Patchstack Connector tag (see *The Patchstack Connector*). +Connect also keeps two fields there itself. `"claimUrl"` is the link that connects the site to your Patchstack account, saved when the site is created so it is not lost with the terminal output. Once `scan`, `status` or `claim` sees the site connected, the link is removed and `"claimed": true` takes its place, which is how `guide` knows without asking Patchstack. + **You do not write `apiKey` yourself.** The first `scan` provisions the site and Connect saves it, so setup needs no manual step. The site UUID identifies the site and is **not** a secret — the Patchstack Connector ships the same UUID in client-side HTML. diff --git a/src/cli.ts b/src/cli.ts index 578b442..6833df9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { claimOutcome, DEFAULT_ENDPOINT, buildClaimUrl, + fetchClaimState, fetchSiteStatus, postManifest, postManifestWithEnvironmentFallback, @@ -36,6 +37,7 @@ import { credentialInCommittedConfig, persistApiKey, secretFileIgnored, + persistClaimState, persistSiteUuid, resolveConfig, type ResolveConfigOptions, @@ -385,6 +387,7 @@ async function runClaim(args: ParsedArgs): Promise<number> { } const settled = async (result: ClaimResult): Promise<number> => { + await recordClaimState(config, siteUuid, true); console.log( `\n ✓ Site claimed${result.account !== undefined ? ` by ${result.account}` : ''}.`, ); @@ -448,6 +451,7 @@ async function runClaim(args: ParsedArgs): Promise<number> { // An already-claimed site is the goal state, not a failure: an assistant re-running this after // the user claimed in the browser must not report the setup as broken. if (started.status === 'already-claimed') { + await recordClaimState(config, siteUuid, true); console.log(`\n ${started.message ?? 'This site is already claimed.'}\n`); return 0; } @@ -468,6 +472,7 @@ async function runClaim(args: ParsedArgs): Promise<number> { if (result.status === 'claimed') return await settled(result); if (result.status === 'already-claimed') { + await recordClaimState(config, siteUuid, true); console.log(`\n ${result.message ?? 'This site is already claimed.'}\n`); return 0; } @@ -796,9 +801,10 @@ async function runScan( // The server always returns the UUID. If we didn't have one, persist it so // every subsequent scan targets the same site. if (provisioning && response.uuid !== undefined && response.uuid.length > 0) { - const target = await persistSiteUuid(process.cwd(), response.uuid); + const claimUrl = config.endpointTrusted !== false ? buildClaimUrl(config.endpoint, response.uuid) : null; + const target = await persistSiteUuid(process.cwd(), response.uuid, claimUrl); report.done.push('Added this project to Patchstack'); - say(`Created site ${response.uuid}. Saved to ${target}.`); + say(`Created site ${response.uuid}. Saved it${claimUrl !== null ? ' and the link to connect it' : ''} to ${target}.`); } if (typeof response.api_key === 'string' && response.api_key.length > 0) { // One credential for both paths: Pulse resolution falls back to apiKey, so @@ -838,7 +844,7 @@ async function runScan( } else if (claim !== null) { report.missing.push(notConnectedItem([`${claim.summary}.`, ...claim.hint])); } - const connected = response.claim?.state === 'claimed' || response.claim?.state === 'owned-by-you'; + const tokenConnected = response.claim?.state === 'claimed' || response.claim?.state === 'owned-by-you'; // With a UUID in hand (existing or freshly provisioned), ensure the Patchstack widget's managed tag in // the source HTML shell so the next preview reload shows it. Best-effort; a failed post never reaches @@ -848,6 +854,8 @@ async function runScan( reportSourceWidget(effectiveUuid, shellFramework, report); } + const connected = await resolveConnected(config, effectiveUuid, tokenConnected); + const synced = effectiveUuid !== null && effectiveUuid.length > 0 && @@ -871,6 +879,32 @@ async function runScan( return 0; } +/** + * Whether the site has an owner, and keep `.patchstackrc.json`'s note of it current. + * + * A claim token that connected the site answers outright. Otherwise a site the file already records as + * claimed is taken at its word, so a build does not ask again on every run; any other site is looked up. + * Best-effort: a failed lookup or write leaves the site reported as not connected, never fails the scan. + */ +async function resolveConnected(config: Config, siteUuid: string | null, tokenConnected: boolean): Promise<boolean> { + if (siteUuid === null || siteUuid.length === 0) return false; + if (!tokenConnected && config.claimed === true) return true; + + const state = tokenConnected ? 'claimed' : await fetchClaimState({ ...config, siteUuid }); + if (state !== 'unknown') await recordClaimState(config, siteUuid, state === 'claimed'); + return state === 'claimed'; +} + +/** Write what Patchstack said about ownership to `.patchstackrc.json`. Never throws. */ +async function recordClaimState(config: Config, siteUuid: string, claimed: boolean): Promise<void> { + const claimUrl = config.endpointTrusted !== false ? buildClaimUrl(config.endpoint, siteUuid) : null; + try { + await persistClaimState(process.cwd(), claimed, claimUrl); + } catch { + // The note is a convenience for `guide`; the answer itself was already given. + } +} + /** An item the run reported replaces the working-tree item with the same key, which says less. */ function mergeMissing(...lists: StatusReport['missing'][]): StatusReport['missing'] { const merged: StatusReport['missing'] = []; @@ -1332,7 +1366,16 @@ async function runStatus(args: ParsedArgs): Promise<number> { console.log(`Environment: ${config.environment}`); if (config.siteUuid !== null) { console.log(`Dashboard URL: ${buildClaimUrl(config.endpoint, config.siteUuid)}`); - console.log(' Not connected yet? Open the link above or run `npx @patchstack/connect claim`.'); + + const claimState = await fetchClaimState(config); + if (claimState !== 'unknown') await recordClaimState(config, config.siteUuid, claimState === 'claimed'); + if (claimState === 'claimed') { + console.log('Connected: yes, to a Patchstack account'); + } else if (claimState === 'unclaimed') { + console.log('Connected: not yet. Open the link above or run `npx @patchstack/connect claim`.'); + } else { + console.log('Connected: could not be verified. Not connected yet? Open the link above.'); + } switch (await fetchSiteStatus(config)) { case 'active': diff --git a/src/client.ts b/src/client.ts index 7d9a514..90c3f7b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -352,6 +352,44 @@ export async function fetchSiteStatus(config: Config): Promise<SiteStatus> { } } +/** Whether the site has an owner on Patchstack, or `unknown` when that could not be learned. */ +export type ClaimState = 'claimed' | 'unclaimed' | 'unknown'; + +/** + * Ask the public claim page whether this site has an owner yet. + * + * The same lookup the claim link's page makes before it renders: `claimable` means nobody owns the + * site, and either owned state means somebody does. It sends no credential and nothing about the + * project — the site UUID in the query is the whole request. Any other answer, a non-2xx response or + * a network failure is `unknown`; never throws. + */ +export async function fetchClaimState(config: Config): Promise<ClaimState> { + if (config.siteUuid === null) return 'unknown'; + + const url = new URL('/monitor/claim/preview', new URL(config.endpoint).origin); + url.searchParams.set('site', config.siteUuid); + + try { + assertConnectableEndpoint(config, url.toString()); + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + 'Cache-Control': 'no-cache', + 'User-Agent': '@patchstack/connect', + }, + signal: AbortSignal.timeout(config.timeoutMs), + }); + if (!response.ok) return 'unknown'; + const body = (await readBoundedJson(response)) as { state?: unknown } | null; + if (body?.state === 'claimable') return 'unclaimed'; + if (body?.state === 'owned-by-other' || body?.state === 'owned-by-you') return 'claimed'; + return 'unknown'; + } catch { + return 'unknown'; + } +} + /** * The whole body of a manifest push. * diff --git a/src/config.ts b/src/config.ts index 0f13ce3..d218d40 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,6 +50,16 @@ interface ConfigFile { * `package.json` still named after its template, an HTML shell whose title is filled in by script. */ name?: string; + /** + * The link that connects this site to a Patchstack account, written when the site is created so it + * survives a terminal nobody read. Dropped once the site is claimed, when the link has done its job. + */ + claimUrl?: string; + /** + * True once `scan`, `status` or `claim` has seen the site attached to an account. Lets `guide`, which + * sends nothing over the network, stop asking for a claim that already happened. + */ + claimed?: boolean; } export interface ResolveConfigOptions { @@ -244,6 +254,7 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise<Conf environmentSource, ignoredFileEnvironment, widget: fromFile.widget !== false, + claimed: fromFile.claimed === true, claimToken, }; } @@ -432,9 +443,42 @@ function ignoresEntry(contents: string, entry: string): boolean { * Merge a new siteUuid into the existing `.patchstackrc.json` (or create it). * Preserves any `endpoint` / `timeoutMs` / `apiKey` the user already wrote. */ -export async function persistSiteUuid(cwd: string, siteUuid: string): Promise<string> { +export async function persistSiteUuid(cwd: string, siteUuid: string, claimUrl?: string | null): Promise<string> { const existing = await readConfigFile(cwd); - return writeConfigFile(cwd, { ...existing, siteUuid }); + return writeConfigFile(cwd, { + ...existing, + siteUuid, + ...(typeof claimUrl === 'string' && claimUrl !== '' ? { claimUrl } : {}), + }); +} + +/** + * Record whether the site has an owner, as last seen from Patchstack. + * + * A claimed site loses its `claimUrl`; an unclaimed one gets it back when the caller supplies it. The + * file is left untouched when it already says the same thing, so a re-run does not rewrite a committed + * file for nothing. Returns whether it wrote. + */ +export async function persistClaimState( + cwd: string, + claimed: boolean, + claimUrl?: string | null, +): Promise<boolean> { + const existing = await readConfigFile(cwd); + if (typeof existing.siteUuid !== 'string' || existing.siteUuid === '') return false; + + const next: ConfigFile = { ...existing }; + if (claimed) { + next.claimed = true; + delete next.claimUrl; + } else { + delete next.claimed; + if (typeof claimUrl === 'string' && claimUrl !== '') next.claimUrl = claimUrl; + } + + if (JSON.stringify(next) === JSON.stringify(existing)) return false; + await writeConfigFile(cwd, next); + return true; } /** diff --git a/src/guide.ts b/src/guide.ts index f8cc450..46cfdeb 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -42,6 +42,8 @@ export interface GuideState { installed: { version: string; section: 'devDependencies' | 'dependencies' } | null; siteUuid: string | null; claimUrl: string | null; + /** True when `.patchstackrc.json` records the site as claimed (see `persistClaimState`). */ + claimed: boolean; /** Non-default API endpoint in effect (rc file, env, or flag), else null. */ endpointOverride: string | null; /** Where a scan from here reports from, and what decided it. Null when the config is unreadable. */ @@ -356,6 +358,7 @@ export async function collectGuideState(cwd: string): Promise<GuideState> { let siteUuid: string | null = null; let claimUrl: string | null = null; + let claimed = false; let endpointOverride: string | null = null; let widgetOptOut = false; let environment: Environment | null = null; @@ -365,6 +368,7 @@ export async function collectGuideState(cwd: string): Promise<GuideState> { environment = config.environment; environmentSource = config.environmentSource ?? null; siteUuid = config.siteUuid; + claimed = siteUuid !== null && config.claimed === true; if (siteUuid !== null && config.endpointTrusted !== false) { claimUrl = buildClaimUrl(config.endpoint, siteUuid); } @@ -395,6 +399,7 @@ export async function collectGuideState(cwd: string): Promise<GuideState> { installed, siteUuid, claimUrl, + claimed, endpointOverride, environment, environmentSource, @@ -473,9 +478,9 @@ export function countRemainingSteps(state: GuideState): number { export function guideProgress(state: GuideState, known: Partial<Progress> = {}): Progress { return { installed: state.installed !== null, - // Claim state lives on the server and nothing on disk records it, so only a caller that has just - // heard from the server (a scan's claim outcome) can mark it done. - connected: false, + // Claim state lives on the server. The working tree has only the note a scan, status or claim left + // in `.patchstackrc.json`; a caller that has just heard from the server overrides it. + connected: state.claimed, // `.patchstackrc.json` only gains a site UUID from a manifest the server stored. synced: state.siteUuid !== null, // Only the dashboard can see the live site, so nothing the CLI runs marks this done. @@ -525,8 +530,8 @@ function buildScriptLines(state: GuideState): string[] { * What the working tree is still missing, each with the one thing to do about it. Until the site exists * only a dev-only install is listed: before that, `setup` is the next step and applies the rest. * - * `connected` is whether the caller heard from Patchstack that the project has an owner. Nothing on disk - * records it, so without that answer the project is treated as not connected. + * `connected` is whether the caller heard from Patchstack that the project has an owner. Without that + * answer, the note in `.patchstackrc.json` decides. */ export function guideMissing(state: GuideState, known: Partial<Progress> = {}): MissingItem[] { const missing: MissingItem[] = []; @@ -603,7 +608,7 @@ export function guideMissing(state: GuideState, known: Partial<Progress> = {}): ); } - if (known.connected !== true) missing.push(notConnectedItem()); + if ((known.connected ?? state.claimed) !== true) missing.push(notConnectedItem()); return missing; } diff --git a/src/types.ts b/src/types.ts index d207ed2..6998824 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,6 +114,12 @@ export interface Config { * `"widget": false` in .patchstackrc.json for dependency-scanning only. */ widget: boolean; + /** + * Whether `.patchstackrc.json` records the site as claimed — what `scan`, `status` or `claim` last + * saw. A note on disk, not Patchstack's answer: only those commands ask. Optional on the same terms + * as `siteUrl`. + */ + claimed?: boolean; /** * The claim token the Patchstack dashboard puts in its install prompt, so the site the first scan * provisions is born in that account instead of waiting for a dashboard link. Read from diff --git a/tests/bin-invocation.test.ts b/tests/bin-invocation.test.ts index dc8923e..83a1ff8 100644 --- a/tests/bin-invocation.test.ts +++ b/tests/bin-invocation.test.ts @@ -367,6 +367,12 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { let raw = ''; req.on('data', (chunk) => { raw += chunk.toString(); }); req.on('end', () => { + // The ownership lookup a scan makes after a stored manifest; it carries no body. + if (req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ state: 'claimable' })); + return; + } bodies.push(JSON.parse(raw) as Record<string, unknown>); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ uuid: SITE, stored: true, checksum: 'abc' })); @@ -424,6 +430,43 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { } }); + it('marks the site connected once Patchstack says it has an owner, and stops asking after that', async () => { + const lookups: string[] = []; + const server = createServer((req, res) => { + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + if (req.method === 'GET') { + lookups.push(req.url ?? ''); + res.end(JSON.stringify({ state: 'owned-by-other' })); + return; + } + res.end(JSON.stringify({ uuid: SITE, stored: true, manifest_id: 7, checksum: 'abc' })); + }); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + const endpoint = `http://127.0.0.1:${port}/monitor/pulse/manifest`; + const dir = freshProject(); + try { + const first = await scan(dir, endpoint); + + expect(first).toContain(' ✔ Connect project to Patchstack account'); + expect(first).not.toContain('/monitor/claim?site='); + expect(lookups).toEqual([`/monitor/claim/preview?site=${SITE}`]); + const saved = JSON.parse(readFileSync(path.join(dir, '.patchstackrc.json'), 'utf8')) as Record<string, unknown>; + expect(saved).toMatchObject({ siteUuid: SITE, claimed: true }); + expect(saved).not.toHaveProperty('claimUrl'); + + const second = await scan(dir, endpoint); + expect(second).toContain(' ✔ Connect project to Patchstack account'); + expect(lookups).toHaveLength(1); + } finally { + await new Promise<void>((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } + }); + /** * The default output is read by people who do not write code. The technical words are still there for * whoever needs them, behind --verbose. diff --git a/tests/client.test.ts b/tests/client.test.ts index 763ac17..f4c745f 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -5,6 +5,7 @@ import { buildPackageRemovedUrl, buildRulesUrl, buildSettingsUrl, + fetchClaimState, fetchSiteStatus, postManifest, postPackageRemoved, @@ -170,6 +171,70 @@ describe('fetchSiteStatus', () => { }); }); +describe('fetchClaimState', () => { + const config = { + siteUuid: 'uuid', + endpoint: 'https://example.com/monitor/pulse/manifest', + timeoutMs: 30_000, + widget: true, + environment: 'production', + } as const; + + const answering = (body: unknown, status = 200) => + vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reads an ownerless site as unclaimed', async () => { + vi.stubGlobal('fetch', answering({ state: 'claimable' })); + await expect(fetchClaimState(config)).resolves.toBe('unclaimed'); + }); + + it('reads a site with an owner as claimed, whoever owns it', async () => { + vi.stubGlobal('fetch', answering({ state: 'owned-by-other' })); + await expect(fetchClaimState(config)).resolves.toBe('claimed'); + + vi.stubGlobal('fetch', answering({ state: 'owned-by-you' })); + await expect(fetchClaimState(config)).resolves.toBe('claimed'); + }); + + it('returns unknown for any other answer, a failed response or no network', async () => { + vi.stubGlobal('fetch', answering({ state: 'not-found' })); + await expect(fetchClaimState(config)).resolves.toBe('unknown'); + + vi.stubGlobal('fetch', answering({ state: 'claimable' }, 500)); + await expect(fetchClaimState(config)).resolves.toBe('unknown'); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not json', { status: 200 }))); + await expect(fetchClaimState(config)).resolves.toBe('unknown'); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom'))); + await expect(fetchClaimState(config)).resolves.toBe('unknown'); + }); + + it('returns unknown without a request when no siteUuid is configured', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchClaimState({ ...config, siteUuid: null })).resolves.toBe('unknown'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('asks the claim page on the endpoint origin, with the site UUID and no credential', async () => { + const fetchMock = answering({ state: 'claimable' }); + vi.stubGlobal('fetch', fetchMock); + + await fetchClaimState({ ...config, apiKey: 'secret-key' } as typeof config); + + const [calledUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(calledUrl).toBe('https://example.com/monitor/claim/preview?site=uuid'); + expect(init.method).toBe('GET'); + expect(JSON.stringify(init.headers)).not.toContain('secret-key'); + }); +}); + describe('buildPackageRemovedUrl', () => { it('maps the production manifest endpoint to the per-site package-removed endpoint', () => { expect( diff --git a/tests/config.test.ts b/tests/config.test.ts index a4bd5f1..5fa7091 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from '../src/config.js'; +import { persistApiKey, persistClaimState, persistSiteUuid, resolveConfig, writeConfigFile } from '../src/config.js'; import { inferEnvironment } from '../src/environment.js'; import { readFile } from 'node:fs/promises'; import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from '../src/client.js'; @@ -106,6 +106,37 @@ describe('resolveConfig', () => { expect(parsed.endpoint).toBe('https://custom.example.com/monitor/pulse/manifest'); }); + it('persistSiteUuid saves the claim link with the UUID when given one', async () => { + const claimUrl = `https://app.example.com/monitor/claim?site=${VALID_UUID}`; + await persistSiteUuid(cwd, VALID_UUID, claimUrl); + const parsed = JSON.parse(await readFile(path.join(cwd, '.patchstackrc.json'), 'utf8')) as Record<string, unknown>; + expect(parsed).toEqual({ siteUuid: VALID_UUID, claimUrl }); + }); + + it('persistClaimState swaps the claim link for the claimed note, and back', async () => { + const claimUrl = `https://app.example.com/monitor/claim?site=${VALID_UUID}`; + await writeConfigFile(cwd, { siteUuid: VALID_UUID, claimUrl, widget: true }); + const read = async () => JSON.parse(await readFile(path.join(cwd, '.patchstackrc.json'), 'utf8')) as Record<string, unknown>; + + await expect(persistClaimState(cwd, true, claimUrl)).resolves.toBe(true); + expect(await read()).toEqual({ siteUuid: VALID_UUID, widget: true, claimed: true }); + await expect(resolveConfig({ cwd })).resolves.toMatchObject({ claimed: true }); + + await expect(persistClaimState(cwd, false, claimUrl)).resolves.toBe(true); + expect(await read()).toEqual({ siteUuid: VALID_UUID, widget: true, claimUrl }); + await expect(resolveConfig({ cwd })).resolves.toMatchObject({ claimed: false }); + }); + + it('persistClaimState leaves the file alone when nothing changed or no site exists', async () => { + await writeConfigFile(cwd, { siteUuid: VALID_UUID, claimed: true }); + await expect(persistClaimState(cwd, true)).resolves.toBe(false); + + await writeConfigFile(cwd, { widget: false }); + await expect(persistClaimState(cwd, true)).resolves.toBe(false); + const parsed = JSON.parse(await readFile(path.join(cwd, '.patchstackrc.json'), 'utf8')) as Record<string, unknown>; + expect(parsed).toEqual({ widget: false }); + }); + it('throws on invalid UUID', async () => { await expect(resolveConfig({ cwd, cliSiteUuid: 'not-a-uuid' })).rejects.toBeInstanceOf( PatchstackError, diff --git a/tests/endpoint-disclosure.test.ts b/tests/endpoint-disclosure.test.ts index 427952f..3d35bdd 100644 --- a/tests/endpoint-disclosure.test.ts +++ b/tests/endpoint-disclosure.test.ts @@ -47,6 +47,7 @@ const DISCLOSED_AS: Record<string, { doc: RegExp; files?: string[] }> = { 'monitor/pulse/build': { doc: /monitor\/pulse\/build/i, files: ['src/client.ts'] }, 'monitor/widget/settings': { doc: /monitor\/widget\/settings/i }, 'monitor/claim': { doc: /claim/i }, + 'monitor/claim/preview': { doc: /monitor\/claim\/preview/i }, 'oauth/token': { doc: /oauth\/token/i }, 'api/logs/log': { doc: /logs\/log/i }, 'api/get-rules/3': { doc: /get-rules/i }, diff --git a/tests/guide.test.ts b/tests/guide.test.ts index f7a572d..6075fdc 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -347,7 +347,7 @@ describe('guide', () => { expect(output).toContain(`Open http`); expect(output).toContain(`/monitor/claim?site=${VALID_UUID}`); expect(output).toContain('anyone who opens your app can connect it to their own account'); - // Nothing on disk says the site has an owner, so the checklist never marks it connected. + // Nothing on disk says the site has an owner, so the checklist does not mark it connected. expect(output).not.toContain('✔ Connect'); expect(output).not.toMatch(/^ {5}✘/m); }); @@ -365,6 +365,28 @@ describe('guide', () => { expect(output).not.toContain('/monitor/claim?site='); }); + it('marks the site connected when .patchstackrc.json records the claim, with no caller answer', async () => { + wiredProject(); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID, claimed: true }); + + const output = renderGuideChecklist(await collectGuideState(cwd), false); + + expect(output).toContain('✔ Connect project to Patchstack account'); + expect(output).toContain('Next: deploy your project to protect the live app'); + expect(output).not.toContain('anyone who opens your app'); + expect(output).not.toContain('/monitor/claim?site='); + }); + + it('lets a caller that just heard from Patchstack overrule the note on disk', async () => { + wiredProject(); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID, claimed: true }); + + const output = renderGuideChecklist(await collectGuideState(cwd), false, { connected: false }); + + expect(output).toContain('✘ Connect project to Patchstack account'); + expect(output).toContain('anyone who opens your app can connect it to their own account'); + }); + it('says all done only when every step is', async () => { wiredProject();