Skip to content
Draft
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
13 changes: 10 additions & 3 deletions api/public/pricing.html
Original file line number Diff line number Diff line change
Expand Up @@ -310,17 +310,24 @@ <h3>⭐ Featured</h3>
try { apiKey = (sessionStorage.getItem('arch_api_key') || localStorage.getItem('arch_api_key') || '').trim(); } catch(e) {}
var headers = { 'Content-Type': 'application/json' };
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
function pricingNextForBody(payload) {
var pack = payload && payload.pack;
return (pack === 'starter' || pack === 'pro' || pack === 'business')
? '/pricing?pack=' + pack
: '/pricing';
}
try {
var res = await fetch(endpoint, { method: 'POST', headers: headers, credentials: 'include', body: JSON.stringify(body) });
if (res.status === 401) {
var next = pricingNextForBody(body);
if (apiKey) {
// A stored key failed — clear it and send them to sign back in.
try { localStorage.removeItem('arch_api_key'); sessionStorage.removeItem('arch_api_key'); } catch(e) {}
window.location.href = '/login?next=' + encodeURIComponent('/pricing');
window.location.href = '/login?next=' + encodeURIComponent(next);
} else {
// Anonymous buy-intent: they have no account to log into — send them to
// sign up (free), then return to pricing to complete the purchase.
window.location.href = '/signup?next=' + encodeURIComponent('/pricing');
// sign up (free), then return to the selected pack to complete checkout.
window.location.href = '/signup?next=' + encodeURIComponent(next);
}
return;
}
Expand Down
9 changes: 6 additions & 3 deletions api/src/assets/signupHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,18 @@ export const SIGNUP_HTML = `<!DOCTYPE html>
// ?next= round-trips (server strips anything else too — this is defense
// in depth; validated values are safe to embed in a double-quoted href):
// - OAuth consent: /signup?next=/oauth/authorize?... resume-consent CTA.
// - Page intent: an EXACT allowlisted path (keys of PAGE_NEXT_LABELS,
// - Page intent: an EXACT allowlisted target (keys of PAGE_NEXT_LABELS,
// mirroring utils/oauthNext.ts SIGNUP_NEXT_LABELS) renders a
// "Continue to <label>" button on success — e.g. pricing.html sends
// logged-out buyers here with next=%2Fpricing so purchase intent
// survives signup.
// logged-out buyers here with next=%2Fpricing%3Fpack%3Dpro so purchase
// intent survives signup.
var oauthNext = '';
var pageNext = '';
var PAGE_NEXT_LABELS = {
'/pricing': 'pricing',
'/pricing?pack=starter': 'pricing',
'/pricing?pack=pro': 'pricing',
'/pricing?pack=business': 'pricing',
'/dashboard': 'dashboard',
'/docs': 'docs',
'/playground': 'playground'
Expand Down
8 changes: 4 additions & 4 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,10 @@ app.use("/api/chat", chatRouter);
app.get("/signup", (req: Request, res: Response) => {
// Open-redirect guard: ?next= is only honored for the same-origin OAuth
// authorize path (consent-page "create account" round-trip) or an exact
// allowlisted page path (/pricing, /dashboard, /docs, /playground — intent
// preservation, e.g. pricing → signup → back to pricing). Anything else
// is stripped server-side before the page's client JS can read it; the
// signup page JS re-validates with the same rule (defense in depth).
// allowlisted page target (/pricing, known /pricing?pack=... preselects,
// /dashboard, /docs, /playground). Anything else is stripped server-side
// before the page's client JS can read it; the signup page JS re-validates
// with the same rule (defense in depth).
if (req.query.next !== undefined && !isSafeSignupNext(req.query.next)) {
const cleaned = new URLSearchParams();
for (const [k, v] of Object.entries(req.query)) {
Expand Down
23 changes: 13 additions & 10 deletions api/src/utils/oauthNext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ export function isSafeOAuthNext(next: unknown): next is string {
}

/**
* Signup ?next= page allowlist — EXACT paths only (no query string, no
* trailing slash), each mapped to the label used in the post-signup
* "Continue to <label>" button. Exact string equality against these literals
* means traversal, lookalike paths, external URLs, schemes, and HTML-breakout
* characters are rejected by construction. Every path here must be a real
* same-origin route (all four are registered in index.ts).
* Signup ?next= page allowlist — exact same-origin targets only, each mapped
* to the label used in the post-signup "Continue to <label>" button. Exact
* string equality against these literals means traversal, lookalike paths,
* external URLs, schemes, and HTML-breakout characters are rejected by
* construction. The only query-bearing targets are the known pricing pack
* preselects generated by pricing.html.
*/
export const SIGNUP_NEXT_LABELS: ReadonlyMap<string, string> = new Map([
["/pricing", "pricing"],
["/pricing?pack=starter", "pricing"],
["/pricing?pack=pro", "pricing"],
["/pricing?pack=business", "pricing"],
["/dashboard", "dashboard"],
["/docs", "docs"],
["/playground", "playground"],
Expand All @@ -53,10 +56,10 @@ export const SIGNUP_NEXT_LABELS: ReadonlyMap<string, string> = new Map([
/**
* True iff `next` is safe for the /signup round-trip: either a same-origin
* OAuth authorize path (isSafeOAuthNext — the consent-page resume flow) or
* one of the exact allowlisted page paths above (purchase/product intent
* preservation, e.g. pricing.html sends logged-out buyers through
* /signup?next=%2Fpricing). isSafeOAuthNext itself stays oauth-only — the
* consent-resume surfaces keep their tighter rule.
* one of the exact allowlisted page targets above (purchase/product intent
* preservation, e.g. pricing.html sends logged-out buyers through a validated
* /pricing or /pricing?pack=<known-pack> target). isSafeOAuthNext itself stays
* oauth-only — the consent-resume surfaces keep their tighter rule.
*/
export function isSafeSignupNext(next: unknown): next is string {
if (isSafeOAuthNext(next)) return true;
Expand Down
36 changes: 25 additions & 11 deletions api/tests/intent-funnel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
*
* Covers:
* A isSafeSignupNext — the widened /signup ?next= allowlist: exact page
* paths (/pricing, /dashboard, /docs, /playground) plus the original
* /oauth/authorize prefix rule. Injection hardening unchanged: external
* URLs, protocol-relative, javascript:, traversal, lookalikes, query
* strings on page paths, and HTML-breakout chars are all rejected.
* isSafeOAuthNext itself stays oauth-only (consent-resume surfaces keep
* the tighter rule).
* targets (/pricing, validated pricing pack preselects, /dashboard,
* /docs, /playground) plus the original /oauth/authorize prefix rule.
* Injection hardening unchanged: external URLs, protocol-relative,
* javascript:, traversal, lookalikes, unknown query strings, and
* HTML-breakout chars are all rejected. isSafeOAuthNext itself stays
* oauth-only (consent-resume surfaces keep the tighter rule).
* B recommendPack — smallest sufficient pack from credits_needed, with
* largest-pack fallback and never-throw degradation; packUrl shape.
* C Surface pins — 402 body carries recommended_pack + links.buy_now;
Expand Down Expand Up @@ -50,19 +50,25 @@ async function main() {
const { DASHBOARD_HTML } = await import(distPath("assets", "dashboardHtml.js"));

// ── A: the signup ?next= allowlist ─────────────────────────────────────
console.log("A — isSafeSignupNext (exact page paths + oauth prefix):");
console.log("A — isSafeSignupNext (exact page targets + oauth prefix):");

for (const p of ["/pricing", "/dashboard", "/docs", "/playground"]) {
test(`accepts exact allowlisted path ${p}`, () =>
assert.strictEqual(isSafeSignupNext(p), true));
}
for (const p of ["/pricing?pack=starter", "/pricing?pack=pro", "/pricing?pack=business"]) {
test(`accepts exact pricing pack preselect ${p}`, () =>
assert.strictEqual(isSafeSignupNext(p), true));
}
test("accepts the oauth authorize path (rule preserved)", () => {
assert.strictEqual(isSafeSignupNext("/oauth/authorize"), true);
assert.strictEqual(isSafeSignupNext("/oauth/authorize?client_id=arch_x&state=y"), true);
});

const rejected = [
["query string on a page path", "/pricing?pack=starter"],
["unknown pack query string", "/pricing?pack=enterprise"],
["extra query parameter on pricing pack", "/pricing?pack=starter&coupon=x"],
["query string on another page path", "/docs?from=pricing"],
["trailing slash", "/pricing/"],
["case variant", "/Pricing"],
["subpath", "/pricing/evil"],
Expand Down Expand Up @@ -91,9 +97,9 @@ async function main() {
assert.strictEqual(isSafeOAuthNext(p), false, `${p} must NOT pass the oauth-only guard`);
}
});
test("every allowlisted path is a registered route in index.ts", () => {
test("every allowlisted target points at a registered route in index.ts", () => {
const indexSrc = fs.readFileSync(src("index.ts"), "utf-8");
for (const p of SIGNUP_NEXT_LABELS.keys()) {
for (const p of new Set(Array.from(SIGNUP_NEXT_LABELS.keys()).map((target) => target.split("?")[0]))) {
assert.ok(indexSrc.includes(`app.get("${p}"`), `route missing for ${p}`);
}
});
Expand Down Expand Up @@ -146,7 +152,7 @@ async function main() {
assert.ok(refusalBlock.includes('res.setHeader("X-Upgrade-URL", packUrl(rec.id))')));

test("signup page mirrors the page allowlist client-side", () => {
for (const p of ["/pricing", "/dashboard", "/docs", "/playground"]) {
for (const p of ["/pricing", "/pricing?pack=starter", "/pricing?pack=pro", "/pricing?pack=business", "/dashboard", "/docs", "/playground"]) {
assert.ok(SIGNUP_HTML.includes(`'${p}':`), `client map missing ${p}`);
}
assert.ok(SIGNUP_HTML.includes("Object.prototype.hasOwnProperty.call(PAGE_NEXT_LABELS, rawNext)"));
Expand Down Expand Up @@ -184,6 +190,14 @@ async function main() {
assert.ok(!block.includes(forbidden), `preselect block must not call ${forbidden}`);
}
});
test("pricing checkout auth redirects preserve clicked pack selection", () => {
const pricing = fs.readFileSync(pub("pricing.html"), "utf-8");
assert.ok(pricing.includes("function pricingNextForBody(payload)"));
assert.ok(pricing.includes("'starter' || pack === 'pro' || pack === 'business'"));
assert.ok(pricing.includes("'/pricing?pack=' + pack"));
assert.ok(pricing.includes("'/signup?next=' + encodeURIComponent(next)"));
assert.ok(pricing.includes("'/login?next=' + encodeURIComponent(next)"));
});

test("alert emails link the pre-selected starter pack URL", () => {
const emailSrc = fs.readFileSync(src("services", "email.ts"), "utf-8");
Expand Down
Loading