Skip to content

feat(edge): hosted operator router and credit checkout functions#26

Merged
ElbertePlinio merged 2 commits into
mainfrom
feat/hosted-router
Jul 10, 2026
Merged

feat(edge): hosted operator router and credit checkout functions#26
ElbertePlinio merged 2 commits into
mainfrom
feat/hosted-router

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

Refs #10 · refs pickforge/pickforge#133 (the M3 hosted router). The platform half — the app lane + Buy-credits UI is the consuming PR in pickforge.

What

  • @pickforge/edge-shared gains assertRouteRequest (strict allowlist + forbidden-identifier guard) and createOperatorRouterHandler (pure, injectable) + a shared operatorRouterSystemPrompt
  • supabase/functions/operator-router: verify JWT → boundary-guard → balance pre-check → model call → debit-after-success so a model failure never charges (a retry routes fresh, charges exactly once); model via OPENAI_ROUTER_MODEL (default gpt-5.4-mini) over an OpenAI-compatible endpoint, returns the raw proposal for the app to validate with its own zod schema (same contract as BYO — the model gets no more trust)
  • supabase/functions/create-credit-checkout: authenticated, packs constrained to the three server-side price ids, URLs from env — no arbitrary price/amount/URL
  • Data boundary hardened after a 2-model adversarial panel: URLs, dotted/dotless hostnames, host:port, quoted and mid-token POSIX/Windows/UNC paths all blocked in commandText and every context string; idempotency key namespaced per user
  • All packages bumped to 0.7.0 (lockstep) so the functions' npm:@pickforge/*@0.7.0 imports are source-consistent

Validation

  • bun run check: 195 tests (guard bypass table, debit-after-success + model-failure-no-charge + insufficient-precheck + duplicate-charges-once, checkout price binding), coverage + builds green
  • Panel (Codex + GLM) both flagged the debit-ordering money-loss and boundary bypasses pre-PR; all fixed

Not tested: live model call — deploy after the 0.7.0 publish; the final model pin (5.4-mini vs a 5.6 tier) is the owner eval on #133 and is one env var. Follow-ups: monthly free-credit grant (cron) and variable/usage-based pricing are noted, not in this slice.

@ElbertePlinio

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 314cbd192e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/edge-shared/src/index.ts Outdated
Comment on lines +379 to +383
await debit({
userId,
amountCents: validCreditCostCents,
reason: "Operator routing",
idempotencyKey: newIdempotencyKey("router", `${userId}:${idempotencyKey}`),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind idempotency before calling the model

When a caller reuses the same x-idempotency-key while still having at least creditCostCents balance, this code invokes chatComplete first and then ignores the duplicate result from debit_credits. That means the first request is charged, but every subsequent request with that key can get a fresh model proposal with no new ledger row, making hosted routing free for reused keys; reserve/record the idempotency key before the provider call or reject duplicates before returning a new proposal.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14dd1bc — proposals persist in the ledger row metadata; a replay of a completed key returns the stored proposal with ZERO extra model calls (pre-model duplicate lookup), closing the free-model-call vector; a failed first call leaves no row and retries fresh.

if (!Array.isArray(value)) {
throw boundaryViolation();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp route prompt size before forwarding it

When an authenticated caller sends a very large commandText or many large context labels, this validator accepts the strings without any length or count limit and the handler later forwards JSON.stringify(routeRequest) to the model while charging a fixed credit cost. That allows a single request to consume far more provider tokens than the configured debit, or to repeatedly force context-overflow 500s; add bounded string lengths and array sizes before chatComplete.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14dd1bc — commandText capped 4000, context strings 200 each / arrays 100 items; over-limit rejected.

/\/(?:[^/\s]+\/)+[^/\s]+/.test(value) ||
/[a-z]:[\\/]/i.test(value) ||
/\\\\/.test(value) ||
/\b[a-z0-9_.-]+\\[a-z0-9_.-]+\\/i.test(value) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject single-segment POSIX paths too

For inputs such as open /etc, use ../secrets, or open foo/bar, this regex does not match because it requires at least two slash-separated components after the slash, so readRouteString accepts and forwards those local path identifiers to the hosted model. Since this guard is the boundary preventing project paths from leaving the client, include single-component absolute paths and single-slash relative paths in the rejection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14dd1bc — single-segment and ../ traversal POSIX paths are now caught (guard table extended, sane non-path false-positive checks kept).

}

async function readCheckoutRequest(req: Request): Promise<{ pack: unknown }> {
const body = await req.json();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Return 400 for malformed checkout JSON

When an authenticated caller sends malformed JSON or a body-less request to create-credit-checkout, req.json() throws a SyntaxError that falls through to the generic catch and returns 500 internal_error even though the request is invalid and Stripe is never involved. Mirror the router parser by translating JSON parse failures into an EdgeSharedError/400 so clients do not treat bad checkout input as a transient server failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14dd1bc — malformed checkout JSON returns 400.

const supabaseAnonKey = requiredEnv("SUPABASE_ANON_KEY");
const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY"));

Deno.serve(async (req) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle CORS preflights for app calls

When the app invokes this function (and operator-router) from a browser or Tauri webview using Authorization/JSON headers, the browser sends an OPTIONS preflight; this handler falls into the auth/body path and returns no access-control-* headers, so the real checkout/router request is blocked before it reaches the function. Add an OPTIONS branch and CORS headers to every response for these app-facing functions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14dd1bc — both functions answer the OPTIONS preflight with CORS headers via a shared helper; responses carry ACAO.

@ElbertePlinio

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14dd1bc930

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/\b(?!and\/or\b)[a-z0-9_.-]+\/[a-z0-9_.-]+\b/i.test(value) ||
/[a-z]:[\\/]/i.test(value) ||
/\\\\/.test(value) ||
/\b[a-z0-9_.-]+\\[a-z0-9_.-]+\\/i.test(value) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block two-segment Windows paths before routing

For a Windows relative path with only two segments like open foo\bar, none of the earlier checks match and this regex requires another trailing backslash, so assertRouteRequest accepts it and forwards the path-like identifier to the hosted model. That breaks the boundary guarantee that Windows paths are rejected before routing; make this check also match single-backslash two-segment paths.

Useful? React with 👍 / 👎.

/\\\\/.test(value) ||
/\b[a-z0-9_.-]+\\[a-z0-9_.-]+\\/i.test(value) ||
/\b[a-z0-9-]+(?::\d{1,5})\b/i.test(value) ||
/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+\b/i.test(value) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rejecting dotted model names as hosts

For supported createChat commands that include an exact model/version, e.g. create codex chat using gpt-5.4-mini or node 20.11, this hostname regex matches the dotted token and assertRouteRequest returns boundary_violation before routing. Since the router prompt supports a model field, this blocks legitimate model-selection commands rather than just hostnames; narrow hostname detection so model/version tokens can pass.

Useful? React with 👍 / 👎.

Comment thread packages/edge-shared/src/index.ts Outdated
return {
"access-control-allow-origin": "*",
"access-control-allow-methods": "POST, OPTIONS",
"access-control-allow-headers": "authorization, content-type, x-idempotency-key",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow Supabase client headers in CORS preflights

When these new browser-facing Edge Functions are invoked via supabase.functions.invoke, the request includes the project key in apikey (and commonly x-client-info) in addition to authorization; because this allow-list omits those headers, the browser preflight is rejected before either operator-router or create-credit-checkout reaches its handler. Include the Supabase client headers here along with x-idempotency-key.

Useful? React with 👍 / 👎.

Comment thread packages/edge-shared/src/index.ts Outdated

function containsForbiddenIdentifier(value: string): boolean {
return (
/\b(?:https?|wss?):\/\//i.test(value) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-HTTP URL schemes before routing

A command such as connect ssh://buildserver still passes the boundary check because the URL regex only covers HTTP(S)/WS(S), and a dotless authority is not caught by the later hostname pattern unless it has a port. That forwards a URL/host identifier to the hosted model despite the documented boundary guarantee, so the scheme check should cover generic URI schemes or explicitly reject these authorities.

Useful? React with 👍 / 👎.

Comment on lines +552 to +554
/\b[a-z0-9-]+(?::\d{1,5})\b/i.test(value) ||
/\b[a-z0-9-]+(?:\.[a-z0-9-]+)+\b/i.test(value) ||
/\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b/.test(value) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block bare localhost and IPv6 host literals

When the command includes connect localhost without a port or an IPv6 literal such as [fd7a:115c:a1e0::1], none of these host checks match: the dotless-host rule requires :<port>, and the tailnet rule only covers IPv4. Those host identifiers are therefore sent to the router model, so add explicit handling for bare localhost/dotless hosts that are clearly host references and IPv6 literals.

Useful? React with 👍 / 👎.

@ElbertePlinio ElbertePlinio merged commit e389096 into main Jul 10, 2026
1 check passed
@ElbertePlinio ElbertePlinio deleted the feat/hosted-router branch July 10, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant