Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ When the person asks for any of these after setup, run the command and pass on w

Say only what the output says. Do not explain why setup could not finish a part, and do not add steps the output did not print. Never ask the person to copy their API key into a builder's settings or secrets: in an app that runs in the browser, a key saved there can end up in the published page. Never offer to wire protection in by hand when the output says no server was found. `go-live` prints the steps for the person's host when they are ready.

If a later `scan` says the project's API key is missing or was not accepted, run `login` as in "Recovering a lost credential — `login`". Do not ask the person for the key.

Leave out the commands you ran, settings, and anything that worked as expected; the setup output already lists which files changed. If the person asks for the detail, give it then. `--verbose` prints it.

**When the person later asks you to deploy, re-read "Deploying" below before running anything.** A deploy that skips the build, or uploads the project folder, publishes the API key and leaves the live site unrecognised.
Expand Down Expand Up @@ -1053,10 +1055,18 @@ is what fits a conversation.

## Recovering a lost credential — `login`

Use this when the project **already has a site** but its credential is gone or rejected: `.patchstackrc.local.json` was deleted, the repo was cloned without it (it is git-ignored, so a fresh clone never has it), a container was recycled, or ingest started failing with 401. The site UUID `login` needs comes from the committed `.patchstackrc.json`.
Use this when the project **already has a site** but its credential is gone or rejected: `.patchstackrc.local.json` was deleted, the repo was cloned without it (it is git-ignored, so a fresh clone never has it), a container was recycled, a hosted builder's workspace no longer has it (a builder may not keep git-ignored files), or ingest started failing with 401. The site UUID `login` needs comes from the committed `.patchstackrc.json`.

> **Do not "fix" a missing credential by deleting `.patchstackrc.json` and running `scan` again.** That file holds the site UUID, and without it `scan` provisions a **second site** — the original, with all its history and its widget tag already live on the deployed page, is orphaned. `login` recovers the credential for the site you already have.

### In a hosted builder

In Lovable, Replit, Bolt and similar builders, `login` is the fix. In a Lovable or Replit project the CLI's missing-key and rejected-key messages say so. Elsewhere they also name `PATCHSTACK_API_KEY`: that is for CI and for hosts the person deploys to themselves, not for a builder's workspace.

- Do not ask the person for the key, do not open the builder's secrets or environment settings for it, and do not tell them to create or copy one in the Patchstack dashboard. Setup never shows the key, so the person has none to give you; `login` writes a new one.
- If a `PATCHSTACK_API_KEY` secret was already added in the workspace, ask the person to remove it first. It is read before `.patchstackrc.local.json`, so a wrong value keeps being used after `login`.
- After `login`, run `scan`, then ask the person to publish again. See "Consequences to tell the user about" for why.

### What it does

```
Expand Down Expand Up @@ -1097,6 +1107,8 @@ You cannot complete this alone. It is deliberately a human-in-the-loop step: sta

**Approving rotates the credential — the old one stops working immediately.** Anywhere it was configured needs the new value: CI secrets, hosting-platform env vars, preview environments, other developers' checkouts. Say this before they approve, not after.

A hosted builder's published app runs without the key, so there is nothing to update in the builder's settings. Rotating does retire the read-only credential that app carries (see "Package and command behavior"). So after `login`, run `scan`, which writes the new one into the guard's rules file, and ask the person to publish again. Until they do, the live app cannot fetch new rules or report what it blocks.

### When it will not work

| Situation | What happens | What to do |
Expand Down
14 changes: 11 additions & 3 deletions src/build-hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { restoresKeyWithLogin } from './client.js';
import { SECRET_CONFIG_FILENAME } from './config.js';
import { reportTrigger } from './report-context.js';
import type { Config, PatchstackError } from './types.js';
Expand Down Expand Up @@ -53,7 +54,9 @@ export function isPreBundleBuildHook(env: NodeJS.ProcessEnv = process.env, cwd =
*
* The error's own message names the remedy for a developer machine. A build environment needs a different
* one: it never has the credential file, `login` refuses to run there, and the only fix is the platform's
* environment. Said for the credential cases only; a network or server failure has nothing to set.
* environment. A hosted builder's project is the exception: its workspace holds the key and sends the
* package list, so a key that is missing there is restored with `login`, never added as a secret (see
* restoresKeyWithLogin). Said for the credential cases only; a network or server failure has nothing to set.
*
* When no credential was found, the line names every source that was checked and where. Somebody who can
* see the file on their own machine reads "none is configured" as "the file was not read", and the log is
Expand All @@ -64,14 +67,19 @@ export function undeliveredReportLines(err: PatchstackError, config: Config, cwd

if (err.code === 'UNAUTHORIZED') {
const hasCredential = typeof config.pulseAuth === 'string' && config.pulseAuth.length > 0;
const builder = restoresKeyWithLogin(config);
if (hasCredential) {
lines.push(
'patchstack: the credential this environment holds was rejected. If `login` rotated it, set the new value as PATCHSTACK_API_KEY here.',
builder
? "patchstack: the key here was refused. Replace it by running npx @patchstack/connect login in the builder's workspace, not by adding a new secret."
: 'patchstack: the credential this environment holds was rejected. If `login` rotated it, set the new value as PATCHSTACK_API_KEY here.',
);
} else {
lines.push(
`patchstack: PATCHSTACK_API_KEY is not set, and neither ${SECRET_CONFIG_FILENAME} nor .patchstackrc.json in ${cwd} holds a credential.`,
`patchstack: ${SECRET_CONFIG_FILENAME} is git-ignored, so a clean checkout never has it. Set PATCHSTACK_API_KEY in the platform's environment so builds can report.`,
builder
? `patchstack: the key is kept in ${SECRET_CONFIG_FILENAME}, which a builder may not keep. Restore it by running npx @patchstack/connect login in the builder's workspace; do not add the key as a secret.`
: `patchstack: ${SECRET_CONFIG_FILENAME} is git-ignored, so a clean checkout never has it. Set PATCHSTACK_API_KEY in the platform's environment so builds can report.`,
);
}
}
Expand Down
22 changes: 20 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
setRunCommand,
hasSiteCredential,
postRunNotice,
restoresKeyWithLogin,
} from './client.js';
import { runWasLogged } from './report-context.js';
import { detectHostedBuilder } from './environment.js';
Expand Down Expand Up @@ -603,6 +604,9 @@ async function runLogin(args: ParsedArgs): Promise<number> {
}

const config = await resolveCliConfig(args);
// In a builder's workspace the published app runs without the key, so there is nothing to update in the
// builder's settings. What it does carry is the read-only credential, which the rotation also retires.
const builder = restoresKeyWithLogin(config);

// Checked here rather than carried up from the write: the rotation happens several layers down, and the
// claim belongs to the line that prints it.
Expand All @@ -611,7 +615,17 @@ async function runLogin(args: ParsedArgs): Promise<number> {
// The value itself is never printed — only that it landed, and only that it is ignored when it is.
console.log(`\n ✓ Credential restored and saved to ${SECRET_CONFIG_FILENAME}.`);
console.log(gitignoreOutcomeLine(ignore));
console.log(' The old credential no longer works. Update it in CI, hosting and other checkouts.\n');
// Both variables are read before the file, so either one left set keeps the old key in use.
for (const name of ['PATCHSTACK_API_KEY', 'PATCHSTACK_PULSE_AUTH']) {
if ((process.env[name] ?? '').trim() !== '') {
console.log(` ${name} is set here and overrides the new key, so the old one is still used. Remove it.`);
}
}
console.log(
builder
? ' The old key no longer works. Run scan, then publish again so the live site keeps getting new protection rules.\n'
: ' The old credential no longer works. Update it in CI, hosting and other checkouts.\n',
);
return 0;
};

Expand All @@ -637,7 +651,11 @@ async function runLogin(args: ParsedArgs): Promise<number> {
// Said before approval, not after: the person deciding needs to know it is
// a rotation, and an assistant relaying this has to pass the warning on.
console.log(" Open the link and approve as the site's owner. This replaces the current credential,");
console.log(' so CI, deploys and other machines will need the new one.\n');
console.log(
builder
? ' so the app has to be published again afterwards.\n'
: ' so CI, deploys and other machines will need the new one.\n',
);
};

// Nobody is watching this stream. Blocking here would hide the link until the
Expand Down
37 changes: 34 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ export function buildEndpointUrl(base: string, siteUuid?: string | null): string
: trimmed;
}

/**
* Whether a missing or refused key is restored with `login` rather than set as PATCHSTACK_API_KEY.
*
* True in a hosted builder's project. The person there has never been shown the key, so they have
* nothing to paste into the builder's secrets, and the builder's workspace may not keep the git-ignored
* file the key lives in. A deploy platform that identified itself is the exception, even for a project a
* builder generated: its own environment is where the key belongs.
*/
export function restoresKeyWithLogin(config: Config): boolean {
return typeof config.hostedBuilder === 'string' && config.environmentSource !== 'platform';
}

/**
* What restores a builder workspace's key. `login` replaces the key, which also retires the read-only
* credential the published app carries; `scan` writes the new one into the guard's rules file, and only a
* publish takes it to the live app.
*/
const BUILDER_LOGIN_STEPS =
"In the builder's workspace, run npx @patchstack/connect login: the person approves one link, and a new key " +
'is saved there. Do not ask the person for a key; they have never been shown one. Then run scan, and publish ' +
'again so the live site keeps getting new protection rules.';

/**
* What a refusal means, in terms someone can act on.
*
Expand All @@ -114,12 +136,18 @@ export function buildEndpointUrl(base: string, siteUuid?: string | null): string
*/
export function authFailureMessage(status: number, config: Config): string | null {
const hasCredential = typeof config.pulseAuth === 'string' && config.pulseAuth.length > 0;
const builder = restoresKeyWithLogin(config);

if (status === 401 && !hasCredential) {
return "Patchstack needs this project's API key, and none is set here. Run npx @patchstack/connect login, or set PATCHSTACK_API_KEY.";
return builder
? `Patchstack needs this project's API key, and none is set here. ${BUILDER_LOGIN_STEPS}`
: "Patchstack needs this project's API key, and none is set here. Run npx @patchstack/connect login, or set PATCHSTACK_API_KEY.";
}
if (status === 401) {
return "Patchstack did not accept this project's API key: it may have expired or been revoked, or the project may no longer exist. Run npx @patchstack/connect login to get a new one.";
return builder
? "Patchstack did not accept this project's API key. If PATCHSTACK_API_KEY is set in the builder's workspace, " +
`remove it first: it overrides the key login saves. ${BUILDER_LOGIN_STEPS}`
: "Patchstack did not accept this project's API key: it may have expired or been revoked, or the project may no longer exist. Run npx @patchstack/connect login to get a new one.";
}
if (status === 403) {
return 'This API key belongs to a different project. Check that siteUuid in .patchstackrc.json is the project the key was made for.';
Expand Down Expand Up @@ -258,7 +286,10 @@ export async function postInputMap(
// The route refuses a map without the site's credential, so none is sent. A project copy that holds
// the key (setup, or the development sync) uploads the same map under the same identity.
if (!hasSiteCredential(config)) {
return { result: 'skipped', message: 'no API key here, so this build did not send it. The copy of the project that has the key sends it, or set PATCHSTACK_API_KEY here.' };
const message = restoresKeyWithLogin(config)
? 'no API key here, so this build did not send it. The workspace sends it with its key; if this is the workspace, run npx @patchstack/connect login to restore the key.'
: 'no API key here, so this build did not send it. The copy of the project that has the key sends it, or set PATCHSTACK_API_KEY here.';
return { result: 'skipped', message };
}

const url = buildInputMapUrl(config.endpoint, config.siteUuid);
Expand Down
4 changes: 3 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,10 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise<Conf
// environment, so `"environment": "sandbox"` written there for a workspace would otherwise ship the
// live site without its marker, showing visitors the owner's panel. A label meant for one process
// belongs in that process's PATCHSTACK_ENVIRONMENT, which still wins outright.
const hostedBuilder = detectHostedBuilder(options.cwd);
const inferred =
fromEnv.environment === undefined
? inferEnvironment(process.env, detectHostedBuilder(options.cwd))
? inferEnvironment(process.env, hostedBuilder)
: null;
const fileLabelOverruled =
fromEnv.environment === undefined &&
Expand Down Expand Up @@ -256,6 +257,7 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise<Conf
widget: fromFile.widget !== false,
claimed: fromFile.claimed === true,
claimToken,
hostedBuilder,
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ export interface Config {
* not this project, and it stops working within a day. Optional on the same terms as `siteUrl`.
*/
claimToken?: string | null;
/**
* The hosted builder whose project this is (`lovable`, `replit`), from its declared dependencies, or null.
* Optional on the same terms as `siteUrl`.
*/
hostedBuilder?: string | null;
}

/**
Expand Down
40 changes: 40 additions & 0 deletions tests/auth-failure-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,46 @@ describe('authFailureMessage', () => {
});
});

describe("in a hosted builder's project", () => {
// The person there has never been shown the key, so the only fix is `login`: a message that offers
// PATCHSTACK_API_KEY sends an agent to the builder's secrets form, asking for a value nobody has.
const LOVABLE = config({ hostedBuilder: 'lovable', environmentSource: 'builder' });

it('restores a missing key with login, and never asks for one', () => {
const said = authFailureMessage(401, LOVABLE)!;

expect(said).toContain('npx @patchstack/connect login');
expect(said).toContain('publish again');
expect(said).not.toContain('PATCHSTACK_API_KEY');
});

it('replaces a refused key with login, after any secret that would shadow it is removed', () => {
const said = authFailureMessage(401, { ...LOVABLE, pulseAuth: 'a-secret-40-chars-long-enough-for-this-1' })!;

expect(said).toContain('npx @patchstack/connect login');
expect(said).toMatch(/PATCHSTACK_API_KEY is set .*remove it first/);
expect(said).toContain('publish again');
});

it('keeps the environment variable where a deploy platform identified itself', () => {
// A builder's project deployed through Vercel or Netlify: that platform's environment is where the key belongs.
const deployed = config({ hostedBuilder: 'lovable', environmentSource: 'platform' });

expect(authFailureMessage(401, deployed)).toBe(authFailureMessage(401, config()));
});

it('a map upload without the key points the workspace at login', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const outcome = await postInputMap(LOVABLE, { version: 3, endpoints: [] });

expect(fetchMock).not.toHaveBeenCalled();
expect((outcome as { message: string }).message).toContain('npx @patchstack/connect login');
expect((outcome as { message: string }).message).not.toContain('PATCHSTACK_API_KEY');
});
});

describe('the write paths report a refusal in those terms', () => {
it('map upload without a key is not sent, and says how to send it', async () => {
const fetchMock = vi.fn();
Expand Down
22 changes: 22 additions & 0 deletions tests/build-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ describe('undeliveredReportLines', () => {
expect(said).not.toContain('/srv/app');
});

it("points a hosted builder's copy at login, not at a secret", () => {
// The direct `scan` an agent runs in a builder's workspace prints these lines too, so "set
// PATCHSTACK_API_KEY in the platform's environment" there sends it to the builder's secrets form.
const lovable = config({ hostedBuilder: 'lovable', environmentSource: 'builder' });
const missing = undeliveredReportLines(new PatchstackError('none configured', 'UNAUTHORIZED'), lovable, '/srv/app').join('\n');
const refused = undeliveredReportLines(
new PatchstackError('rejected', 'UNAUTHORIZED'),
{ ...lovable, apiKey: 'a-secret', pulseAuth: 'a-secret' },
'/srv/app',
).join('\n');

for (const said of [missing, refused]) {
expect(said).toContain('npx @patchstack/connect login');
expect(said).toContain('not');
expect(said).not.toContain("Set PATCHSTACK_API_KEY in the platform's environment");
expect(said).not.toContain('set the new value as PATCHSTACK_API_KEY');
expect(said).toContain('continuing the build');
}
// Still names where it looked, so a missing file can be told from an unread one.
expect(missing).toContain('/srv/app');
});

it('adds no credential advice to a failure that is not about credentials', () => {
// The control: a network failure with credential advice attached would send someone to set a variable
// that changes nothing.
Expand Down
8 changes: 8 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ describe('resolveConfig', () => {
});
});

it("names the hosted builder a project belongs to, from its own package.json", async () => {
await writeFile(path.join(cwd, 'package.json'), JSON.stringify({ devDependencies: { 'lovable-tagger': '^1.1.0' } }));
expect((await resolveConfig({ cwd, cliSiteUuid: VALID_UUID })).hostedBuilder).toBe('lovable');

await writeFile(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { react: '^19.0.0' } }));
expect((await resolveConfig({ cwd, cliSiteUuid: VALID_UUID })).hostedBuilder).toBeNull();
});

it('reads PATCHSTACK_TIMEOUT_MS from the environment', async () => {
process.env.PATCHSTACK_TIMEOUT_MS = '5000';
const config = await resolveConfig({ cwd, cliSiteUuid: VALID_UUID });
Expand Down
Loading