Hmbown/CodeWhale · error · Error
invalid-channel
invalid-channel
Error message
invalid-channel
What it means
fetchCurrentRow validates the channel name with isValidChannel before touching Supabase and throws "invalid-channel" for anything that fails, preventing attacker-controlled channel strings from reaching query construction.
Solutions
- Validate/sanitize the channel against the allowed pattern before calling fetchCurrentRow.
- Check where the channel value originates (route params, query string) and reject invalid values at the route with a 400.
- Compare against the isValidChannel pattern in web/lib/cloud-facts.ts to see exactly which characters/length are accepted.
Example fix
// before
const row = await fetchCurrentRow(params.get("channel") ?? "", env);
// after
const channel = params.get("channel") ?? "";
if (!/^[a-z0-9-]{1,64}$/.test(channel)) return new Response("invalid channel", { status: 400 });
const row = await fetchCurrentRow(channel, env); Defensive patterns
Strategy: type-guard
Validate before calling
// Route-level guard before resolving a channel
const channel = new URL(request.url).searchParams.get("channel") ?? "";
if (!/^[a-z0-9-]{1,64}$/.test(channel)) return new Response("invalid channel", { status: 400 }); Type guard
function isSafeChannel(v: unknown): v is string {
return typeof v === "string" && /^[a-z0-9-]{1,64}$/.test(v); // match isValidChannel's actual pattern
} Try / catch
try {
const row = await fetchCurrentRow(channel, env);
} catch (err) {
if (err.message === "invalid-channel") {
return new Response("invalid channel", { status: 400 });
}
throw err;
} Prevention
- Validate channel route params against the allowed pattern before passing them on.
- URL-decode and trim inputs once, at the route boundary, and reject anything non-conforming.
- Keep the channel regex in one shared helper so clients and server agree on the format.
When it happens
Trigger: Calling fetchCurrentRow (via the row resolver) with a channel string failing isValidChannel — wrong characters, wrong length, wrong format, or a non-conforming value from a request parameter.
Common situations: Passing raw URL path/query segments as the channel without sanitizing; a client using an old channel naming scheme; typos or URL-decoding artifacts (e.g. %20) in the channel value.
Understand the failure class
Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.
Related errors
- artifact id and extension must contain safe ASCII characters
- Invalid pet voice.
- Invalid session id
- supabase-bad-row
- 1
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/e2ed873cf4b5ea8c.
Report an issue: GitHub.
Appendix: source
Thrown at web/lib/cloud-facts.ts:252
if (expires !== null && now >= expires) return { ok: false, reason: "expired" };
return { ok: true, keyId, mode: "verified" };
}
/** Only publishable keys (or legacy anon JWTs), never secret/service-role keys. */
function isPublishableKey(key: string): boolean {
if (/^sb_publishable_[A-Za-z0-9_-]+$/.test(key)) return true;
if (key.length > 8192) return false;
try {
const parts = key.split(".");
if (parts.length !== 3) return false;
const middle = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const payload = JSON.parse(atob(middle.padEnd(Math.ceil(middle.length / 4) * 4, "=")));
return isObject(payload) && payload.role === "anon";
} catch { return false; }
}
export async function fetchCurrentRow(channel: string, env: CloudFactsEnv, opts: ResolveOptions = {}): Promise<FactsCurrentRow | null> {
if (!isValidChannel(channel)) throw new Error("invalid-channel");
const key = env.SUPABASE_PUBLISHABLE_KEY;
let base: URL;
try {
base = new URL(env.SUPABASE_URL ?? "");
if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash || !key || !isPublishableKey(key)) throw new Error();
} catch { throw new Error("supabase-not-configured"); }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? SUPABASE_TIMEOUT_MS);
try {
const url = new URL(`${base.href.replace(/\/+$/, "")}/rest/v1/facts_current`);
url.search = new URLSearchParams({ channel: `eq.${channel}`, scope: "eq.global", select: "channel,release_id,facts_version,schema_version,envelope_version,applies_to,key_id,payload_b64,sig_b64,sigs,payload_sha256,published_at,not_after", limit: "1" }).toString();
const res = await (opts.fetchImpl ?? fetch)(url, {
headers: { apikey: key!, Authorization: `Bearer ${key}`, Accept: "application/json" },
signal: controller.signal,
redirect: "error",
});
if (!res.ok) { await res.body?.cancel(); throw new Error(`supabase-http-${res.status}`); }
const bytes = await readBoundedBody(res, MAX_ENVELOPE_BYTES);View on GitHub (pinned to 433685b202)