Get this on every PR automatically. Webhook fires, AI reviews, you approve.
Install on stripe/link-cli →
// reviewed by pulllight
PullLight found 5 issues
1 high
4 medium
// convert to your repo
PullLight caught 5 1 high, 4 medium on this PR.
Install on stripe/link-cli to get this on every PR automatically — 60 seconds.
Findings
5 issues
medium
other
The HTML response body `<html><body><p>${message}</p></body></html>` interpolates `message` which is sourced from `STATUS_MESSAGES[raw]` — a fixed lookup table — so that part is safe. However, `raw` itself (the unsanitized query-param value) is never interpolated into the response, so there is no XSS here. No action needed on that front.
// packages/cli/src/utils/local-callback-server.ts:62
high
resource-leak
`resolveCallback` is called at most once (on the first HTTP request), but the server is never closed after that first callback fires. Subsequent requests keep the server alive indefinitely. The caller is expected to call `close()`, but if the caller's cleanup path is skipped (e.g. an exception between `waitForCallback` resolving and the `finally` block in the effect), the server stays open and the Node process will not exit. The server should auto-close itself after the first callback is received.
// packages/cli/src/utils/local-callback-server.ts:80
⚡ Suggested fix
resolveCallback?.({ status });
server.close();
medium
error-handling
When `pollUntilApproved` resolves with a terminal status, `onTerminal` is called but `setStatus` is never called inside `useApprovalPolling` — the caller's `onTerminal` is responsible for calling `setStatus`. However, if `onTerminal` throws, the `setTimeout(() => onComplete(final), ...)` on the next line still fires, potentially calling `onComplete` with a partially-updated state. Wrap the `setTimeout` in the same try block or move it inside `onTerminal`.
// packages/cli/src/commands/spend-request/use-approval-polling.ts:76
medium
logic
`useApprovalPolling` is passed `setStatus: () => setStatus('polling')` — it ignores the argument and always sets `'polling'`. This means if the hook ever calls `setStatus('error')` (which the new signature no longer does, but the contract `setStatus: (s: 'polling') => void` still allows), the error would be silently swallowed. More importantly, the `status` state passed to `useApprovalPolling` is the component's full status (including `'denied'`, `'expired'`, `'error'`), but the hook's `isWaiting` guard only checks `'waiting' | 'polling'`, so once the callback path sets `status` to `'success'`/`'denied'`/`'expired'`, the polling hook correctly stays idle — but if `pollingFallback` is set to `true` after the callback path already resolved, polling will start racing with the already-completed flow. Ensure `pollingFallback` is never set to `true` after a terminal status has been reached.
// packages/cli/src/commands/spend-request/create.tsx:63
medium
logic
In the `denied`, `expired`, and error branches of the callback path, `setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS)` is called, but in the `denied`/`expired` UI branches the component renders a terminal view with no further action — `onComplete` is still invoked, which may be unexpected for callers that treat `onComplete` as a success-only signal. Verify that callers of `RequestApproval` handle non-approved results from `onComplete` correctly, or use a separate `onError`/`onDenied` callback.
// packages/cli/src/commands/spend-request/request-approval.tsx:57