Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
91 changes: 85 additions & 6 deletions cli/shared/deployment/deploy-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,12 +582,17 @@ describe("pushed source provenance", () => {
});

describe("environment URL readiness", () => {
// The protected-environment gate only accepts a session JWT, so the probe
// credential has to be JWT-shaped for the authenticated path to be exercised.
const sessionToken = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1XzEifQ.test-signature";
const apiKeyToken = "vf_d157f0000000000000000000000000000000000";

const hostedTarget = {
projectSlug: "my-project",
environmentName: "production",
url: "https://my-project.production.veryfront.com",
protected: false,
apiToken: "test-token",
apiToken: sessionToken,
};

it("retries a transient 404 before accepting the environment URL", async () => {
Expand Down Expand Up @@ -662,7 +667,81 @@ describe("environment URL readiness", () => {
}),
);

assertEquals(cookie, "authToken=test-token");
assertEquals(cookie, `authToken=${sessionToken}`);
});

it("does not send an API key to the protected environment gate", async () => {
const requests: Array<{ url: string; cookie: string | null }> = [];

await withMockFetch(
(input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
requests.push({ url: request.url, cookie: request.headers.get("cookie") });
return Promise.resolve(
new Response(null, {
status: 302,
headers: { location: "https://veryfront.com/sign-in" },
}),
);
},
() =>
waitForEnvironmentReady({
...hostedTarget,
protected: true,
apiToken: apiKeyToken,
}),
);

// The gate only resolves session JWTs, so an API key is rejected exactly
// like no credential at all. Sending it leaks the key for no benefit.
assertEquals(requests, [{
url: "https://my-project.production.veryfront.com/",
cookie: null,
}]);
});

it("treats a sign-in redirect as ready when the credential is an API key", async () => {
// The deployment is already committed by the time this probe runs. A
// redirect proves routing resolves and the gate is serving the
// environment, which is all this probe can establish without a session.
await withMockFetch(
() =>
Promise.resolve(
new Response(null, {
status: 302,
headers: { location: "https://veryfront.com/sign-in" },
}),
),
() =>
waitForEnvironmentReady({
...hostedTarget,
protected: true,
apiToken: apiKeyToken,
}),
);
});

it("classifies a rejected session credential as a deployment error", async () => {
const error = await assertRejects(() =>
withMockFetch(
() =>
Promise.resolve(
new Response(null, {
status: 302,
headers: { location: "https://veryfront.com/sign-in" },
}),
),
() =>
waitForEnvironmentReady({
...hostedTarget,
protected: true,
}),
)
);

// A bare Error here surfaces to the operator as `unknown-error`.
assertStrictEquals(error instanceof VeryfrontError, true);
assertEquals((error as VeryfrontError).slug, DEPLOYMENT_ERROR.slug);
});

it("upgrades authenticated Veryfront environment probes to HTTPS", async () => {
Expand All @@ -685,7 +764,7 @@ describe("environment URL readiness", () => {
);

assertEquals(requestedUrl, "https://my-project.production.veryfront.com/");
assertEquals(cookie, "authToken=test-token");
assertEquals(cookie, `authToken=${sessionToken}`);
});

it("does not send credentials to a mismatched Veryfront project host", async () => {
Expand Down Expand Up @@ -722,7 +801,7 @@ describe("environment URL readiness", () => {
},
{
url: "https://my-project.production.veryfront.com/",
cookie: "authToken=test-token",
cookie: `authToken=${sessionToken}`,
},
]);
});
Expand All @@ -749,7 +828,7 @@ describe("environment URL readiness", () => {

assertEquals(requests, [{
url: "https://my-project.production.veryfront.org/",
cookie: "authToken=test-token",
cookie: `authToken=${sessionToken}`,
}]);
});

Expand Down Expand Up @@ -784,7 +863,7 @@ describe("environment URL readiness", () => {
{ url: "https://app.example.com/", cookie: null },
{
url: "https://my-project.production.veryfront.com/",
cookie: "authToken=test-token",
cookie: `authToken=${sessionToken}`,
},
]);
});
Expand Down
57 changes: 43 additions & 14 deletions cli/shared/deployment/deploy-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,12 +829,33 @@ function isMatchingVeryfrontHostedUrl(
parsed.environment === target.environmentName.toLowerCase();
}

/**
* Whether a stored credential could satisfy the protected-environment gate.
*
* The gate resolves the `authToken` cookie by decoding it as a JWT and reading
* `userId` from the verified payload; it has no API-key branch. An opaque
* `vf_…` API key therefore fails the same way an absent cookie does, so
* presenting one only leaks the key. This is a shape check, not verification —
* the CLI cannot verify the signature and does not need to. It only needs to
* know whether the gate could possibly accept the credential.
*/
function isSessionCredential(apiToken: string): boolean {
const segments = apiToken.split(".");
return segments.length === 3 && segments.every((segment) => segment.length > 0);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

function buildEnvironmentReadinessProbes(
target: EnvironmentReadinessTarget,
): EnvironmentReadinessProbe[] {
const route = target.route === undefined ? "/" : target.route;
if (route === null) return [];

// Without a session credential the probe cannot get past the gate. A sign-in
// redirect still proves routing resolves and the proxy is serving this
// environment, which is the most this step can establish — and the
// deployment it would otherwise fail is already committed and verified.
const canAuthenticate = isSessionCredential(target.apiToken);

const targetUrl = buildEnvironmentProbeUrl(target.url, route);
if (target.protected && !isMatchingVeryfrontHostedUrl(new URL(targetUrl), target)) {
return [
Expand All @@ -848,15 +869,15 @@ function buildEnvironmentReadinessProbes(
buildCanonicalEnvironmentUrl(target.projectSlug, target.environmentName),
route,
),
authenticate: true,
acceptAuthenticationChallenge: false,
authenticate: canAuthenticate,
acceptAuthenticationChallenge: !canAuthenticate,
},
];
}
return [{
url: target.protected ? secureEnvironmentProbeUrl(targetUrl) : targetUrl,
authenticate: target.protected,
acceptAuthenticationChallenge: false,
authenticate: target.protected && canAuthenticate,
acceptAuthenticationChallenge: target.protected && !canAuthenticate,
}];
}

Expand Down Expand Up @@ -935,25 +956,33 @@ export async function waitForEnvironmentReady(

if (ready) break;
if (authenticationChallenge) {
const message = probe.authenticate
? `Could not authenticate the protected environment URL ${probe.url}. Run veryfront login and deploy again.`
: `Environment URL ${probe.url} redirected to sign-in. Check its protection settings and deploy again.`;
throw new Error(message);
// These are diagnosed conditions, so they must carry the deployment
// slug. A bare Error reaches the operator as `unknown-error` with
// "Check logs for more details", which describes nothing.
throw DEPLOYMENT_ERROR.create({
detail: probe.authenticate
? `Could not authenticate the protected environment URL ${probe.url}. Run veryfront login and deploy again.`
: `Environment URL ${probe.url} redirected to sign-in. Check its protection settings and deploy again.`,
context: { url: probe.url },
});
}
if (!isTransientEnvironmentStatus(response.status)) {
throw new Error(
`Environment URL ${probe.url} returned HTTP ${response.status}. Check the environment configuration and deploy again.`,
);
throw DEPLOYMENT_ERROR.create({
detail:
`Environment URL ${probe.url} returned HTTP ${response.status}. Check the environment configuration and deploy again.`,
context: { url: probe.url, status: response.status },
});
}
}

const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new Error(
`Environment URL ${probe.url} did not become ready within ${
throw DEPLOYMENT_ERROR.create({
detail: `Environment URL ${probe.url} did not become ready within ${
Math.ceil(timeoutMs / 1000)
}s (last response: ${lastResponse}). Check the deployment and run deploy again.`,
);
context: { url: probe.url, timeoutMs },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
await wait(Math.min(pollIntervalMs, remainingMs));
}
Expand Down