mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-08 04:57:36 +00:00
config(git,claude,ssh): templated gitconfig; claude settings/commands/hooks/skills; ssh config
gitconfig: name/email from chezmoi data, gh credential helper by PATH lookup (portable to darwin). claude: no credentials, history, projects, sessions, or plugin caches. ssh config as private_ (0600). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c691e8dc5
commit
20891f3910
476 files changed
+59086
-7
No files matched your search
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Error formatting + helpers. Keep error responses consistent with
|
||||
* Cloudflare's documented siteverify shape so callers can use one handler.
|
||||
*/
|
||||
|
||||
import type { ErrorCode, SiteverifyResponse } from './types.js';
|
||||
|
||||
export class SiteverifyError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: ErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SiteverifyError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a siteverify-shaped response for a Worker-level failure. */
|
||||
export function errorResponse(code: ErrorCode, durationMs: number, workerVersion: string): SiteverifyResponse {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': [code],
|
||||
_worker: {
|
||||
duration_ms: durationMs,
|
||||
worker_version: workerVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* turnstile-siteverify — the managed siteverify backend for Turnstile Spin.
|
||||
*
|
||||
* A drop-in implementation of Cloudflare Turnstile server-side validation
|
||||
* built entirely on Workers. Customers deploy this Worker into their account
|
||||
* via Turnstile Spin (AI agent or Deploy-to-Cloudflare button) and point
|
||||
* their frontend at it.
|
||||
*
|
||||
* Accepts both `application/x-www-form-urlencoded` and `application/json`
|
||||
* bodies. Forwards the token to Cloudflare's siteverify endpoint using a
|
||||
* secret stored as a Worker secret. Emits structured logs visible in Workers
|
||||
* Observability.
|
||||
*
|
||||
* Endpoints:
|
||||
* POST / siteverify proxy (main entry point)
|
||||
* POST /siteverify alias of POST /
|
||||
* GET /health health check, returns {ok: true, version}
|
||||
* GET / same as /health
|
||||
* GET /post-deploy the post-deploy form (served from public/)
|
||||
*/
|
||||
|
||||
import { SiteverifyError, errorResponse } from './errors.js';
|
||||
import { logSiteverify } from './observability.js';
|
||||
import { validate } from './validate.js';
|
||||
import type { Env, SiteverifyRequest } from './types.js';
|
||||
|
||||
export const WORKER_VERSION = '1.0.0';
|
||||
|
||||
function corsHeaders(env: Env): Record<string, string> {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN || '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
Vary: 'Origin',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status: number, env: Env): Response {
|
||||
return new Response(JSON.stringify(body, null, 2), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...corsHeaders(env),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function parseBody(request: Request): Promise<SiteverifyRequest> {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
return {
|
||||
// `response` is accepted for compatibility with code migrated from reCAPTCHA/hCaptcha,
|
||||
// whose backends POST {secret, response, remoteip}. Spin's Worker holds the secret,
|
||||
// so callers should send {token} or {cf-turnstile-response}; we accept {response}
|
||||
// too so migrated backends work without backend code changes.
|
||||
token: String(body.token ?? body['cf-turnstile-response'] ?? body.response ?? ''),
|
||||
remoteip: body.remoteip ? String(body.remoteip) : undefined,
|
||||
idempotency_key: body.idempotency_key ? String(body.idempotency_key) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data')) {
|
||||
const form = await request.formData();
|
||||
return {
|
||||
token: String(form.get('token') ?? form.get('cf-turnstile-response') ?? form.get('response') ?? ''),
|
||||
remoteip: form.get('remoteip') ? String(form.get('remoteip')) : undefined,
|
||||
idempotency_key: form.get('idempotency_key') ? String(form.get('idempotency_key')) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
throw new SiteverifyError(
|
||||
415,
|
||||
'invalid-content-type',
|
||||
`Unsupported Content-Type: ${contentType || '(missing)'}. Use application/json or application/x-www-form-urlencoded.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSiteverify(request: Request, env: Env): Promise<Response> {
|
||||
const startedAt = Date.now();
|
||||
let parsed: SiteverifyRequest;
|
||||
try {
|
||||
parsed = await parseBody(request);
|
||||
} catch (err) {
|
||||
if (err instanceof SiteverifyError) {
|
||||
const body = errorResponse(err.code, Date.now() - startedAt, WORKER_VERSION);
|
||||
logSiteverify('error', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCodes: [err.code],
|
||||
remoteipPresent: false,
|
||||
idempotencyKeyPresent: false,
|
||||
});
|
||||
return jsonResponse(body, err.status, env);
|
||||
}
|
||||
// Body parse failure (malformed JSON, etc.)
|
||||
const body = errorResponse('bad-request', Date.now() - startedAt, WORKER_VERSION);
|
||||
logSiteverify('error', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCodes: ['bad-request'],
|
||||
remoteipPresent: false,
|
||||
idempotencyKeyPresent: false,
|
||||
});
|
||||
return jsonResponse(body, 400, env);
|
||||
}
|
||||
|
||||
// Fall back to CF-Connecting-IP if customer didn't pass remoteip explicitly.
|
||||
const remoteip = parsed.remoteip ?? request.headers.get('CF-Connecting-IP') ?? undefined;
|
||||
const requestForValidate: SiteverifyRequest = { ...parsed, remoteip };
|
||||
|
||||
try {
|
||||
const { response, upstreamStatus, durationMs } = await validate(requestForValidate, env, WORKER_VERSION);
|
||||
|
||||
logSiteverify(response.success ? 'ok' : 'fail', {
|
||||
durationMs,
|
||||
upstreamStatus,
|
||||
errorCodes: response['error-codes'],
|
||||
cdataValue: response.cdata,
|
||||
remoteipPresent: !!remoteip,
|
||||
idempotencyKeyPresent: !!parsed.idempotency_key,
|
||||
});
|
||||
|
||||
// Per spec, return 200 even on validation failure — callers should check
|
||||
// response.success, not the HTTP status. (Matches Cloudflare's own
|
||||
// siteverify behavior; the Worker is a thin proxy.)
|
||||
return jsonResponse(response, 200, env);
|
||||
} catch (err) {
|
||||
if (err instanceof SiteverifyError) {
|
||||
const body = errorResponse(err.code, Date.now() - startedAt, WORKER_VERSION);
|
||||
logSiteverify('error', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCodes: [err.code],
|
||||
remoteipPresent: !!remoteip,
|
||||
idempotencyKeyPresent: !!parsed.idempotency_key,
|
||||
});
|
||||
return jsonResponse(body, err.status, env);
|
||||
}
|
||||
const body = errorResponse('internal-error', Date.now() - startedAt, WORKER_VERSION);
|
||||
logSiteverify('error', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCodes: ['internal-error'],
|
||||
remoteipPresent: !!remoteip,
|
||||
idempotencyKeyPresent: !!parsed.idempotency_key,
|
||||
});
|
||||
return jsonResponse(body, 500, env);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHealth(env: Env): Response {
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
version: WORKER_VERSION,
|
||||
service: 'turnstile-siteverify',
|
||||
docs: 'https://developers.cloudflare.com/turnstile/spin/',
|
||||
},
|
||||
200,
|
||||
env,
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: corsHeaders(env) });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/health' || url.pathname === '/healthz')) {
|
||||
return handleHealth(env);
|
||||
}
|
||||
|
||||
// Post-deploy form (served from public/post-deploy.html via the ASSETS binding).
|
||||
// The form prefills the Worker URL and lets a Deploy-button user create a widget
|
||||
// without leaving the flow.
|
||||
if (request.method === 'GET' && url.pathname === '/post-deploy') {
|
||||
if (env.ASSETS) {
|
||||
return env.ASSETS.fetch(new Request(new URL('/post-deploy.html', url.origin)));
|
||||
}
|
||||
}
|
||||
|
||||
const isSiteverifyPath = url.pathname === '/' || url.pathname === '/siteverify';
|
||||
if (request.method === 'POST' && isSiteverifyPath) {
|
||||
return handleSiteverify(request, env);
|
||||
}
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
'error-codes': ['bad-request'],
|
||||
hint: 'POST a Turnstile token to / (or /siteverify) as JSON or form-encoded body.',
|
||||
},
|
||||
404,
|
||||
env,
|
||||
);
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Structured logging helpers. Output is JSON-per-line so Workers Observability
|
||||
* + log-shipping pipelines can parse + index without regex hacks.
|
||||
*/
|
||||
|
||||
import type { SiteverifyLogEntry, ErrorCode } from './types.js';
|
||||
|
||||
export interface LogContext {
|
||||
durationMs: number;
|
||||
upstreamStatus?: number;
|
||||
errorCodes?: ErrorCode[];
|
||||
cdataValue?: string;
|
||||
remoteipPresent: boolean;
|
||||
idempotencyKeyPresent: boolean;
|
||||
}
|
||||
|
||||
export function logSiteverify(outcome: 'ok' | 'fail' | 'error', ctx: LogContext): void {
|
||||
const entry: SiteverifyLogEntry = {
|
||||
level: outcome === 'ok' ? 'info' : outcome === 'fail' ? 'warn' : 'error',
|
||||
event: 'siteverify',
|
||||
outcome,
|
||||
duration_ms: ctx.durationMs,
|
||||
upstream_status: ctx.upstreamStatus,
|
||||
error_codes: ctx.errorCodes ?? [],
|
||||
cdata_present: typeof ctx.cdataValue === 'string' && ctx.cdataValue.length > 0,
|
||||
cdata_value: ctx.cdataValue,
|
||||
remoteip_present: ctx.remoteipPresent,
|
||||
idempotency_key_present: ctx.idempotencyKeyPresent,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
// console.log goes to Workers logs and Observability.
|
||||
// Using a JSON line for machine-readability.
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Types for Turnstile siteverify request + response.
|
||||
*
|
||||
* Mirrors Cloudflare's documented siteverify API:
|
||||
* https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
*/
|
||||
|
||||
export interface Env {
|
||||
/** Turnstile secret key. Set via `wrangler secret put TURNSTILE_SECRET_KEY`. */
|
||||
TURNSTILE_SECRET_KEY: string;
|
||||
/** CORS allowed origin. Default "*" for dev; must be set explicitly for prod. */
|
||||
ALLOWED_ORIGIN: string;
|
||||
/**
|
||||
* Optional. If set, the Worker rejects siteverify responses whose `hostname`
|
||||
* does not match this value. Defends against cross-site token replay.
|
||||
* Leave empty to accept any hostname (default; for multi-site deployments).
|
||||
*/
|
||||
EXPECTED_HOSTNAME?: string;
|
||||
/** Log verbosity. One of: debug, info, warn, error. Defaults to info. */
|
||||
LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error';
|
||||
/** Static assets binding (post-deploy form). Set automatically by [assets] config. */
|
||||
ASSETS?: Fetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input we accept from the customer's frontend.
|
||||
* Both form-encoded and JSON bodies are supported.
|
||||
*/
|
||||
export interface SiteverifyRequest {
|
||||
/** The Turnstile token from the widget. */
|
||||
token: string;
|
||||
/** Optional visitor IP. If omitted, we use CF-Connecting-IP. */
|
||||
remoteip?: string;
|
||||
/** Optional idempotency key for retries. */
|
||||
idempotency_key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare's siteverify response shape.
|
||||
* See: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#accepted-parameters
|
||||
*/
|
||||
export interface SiteverifyResponse {
|
||||
success: boolean;
|
||||
challenge_ts?: string;
|
||||
hostname?: string;
|
||||
action?: string;
|
||||
cdata?: string;
|
||||
'error-codes': ErrorCode[];
|
||||
/** Free-form metadata appended by this Worker. Not from upstream. */
|
||||
_worker?: WorkerMetadata;
|
||||
}
|
||||
|
||||
export interface WorkerMetadata {
|
||||
/** Latency of the upstream siteverify call, milliseconds. */
|
||||
duration_ms: number;
|
||||
/** This Worker's version, from package.json. */
|
||||
worker_version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Documented error codes from Turnstile siteverify.
|
||||
* See: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'missing-input-secret'
|
||||
| 'invalid-input-secret'
|
||||
| 'missing-input-response'
|
||||
| 'invalid-input-response'
|
||||
| 'bad-request'
|
||||
| 'timeout-or-duplicate'
|
||||
| 'internal-error'
|
||||
// Worker-side codes (not from upstream)
|
||||
| 'missing-token'
|
||||
| 'invalid-content-type'
|
||||
| 'upstream-unreachable'
|
||||
| 'upstream-timeout'
|
||||
| 'hostname-mismatch';
|
||||
|
||||
/**
|
||||
* Structured log emitted per siteverify call.
|
||||
* These show up in Workers Observability.
|
||||
*/
|
||||
export interface SiteverifyLogEntry {
|
||||
level: 'info' | 'warn' | 'error';
|
||||
event: 'siteverify';
|
||||
outcome: 'ok' | 'fail' | 'error';
|
||||
duration_ms: number;
|
||||
upstream_status?: number;
|
||||
error_codes: ErrorCode[];
|
||||
cdata_present: boolean;
|
||||
cdata_value?: string;
|
||||
remoteip_present: boolean;
|
||||
idempotency_key_present: boolean;
|
||||
ts: string;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Calls Cloudflare's siteverify endpoint. One retry on transient (5xx + network)
|
||||
* failures, with a tight timeout. Returns the upstream JSON unmodified plus
|
||||
* Worker metadata.
|
||||
*/
|
||||
|
||||
import { SiteverifyError } from './errors.js';
|
||||
import type { Env, SiteverifyRequest, SiteverifyResponse } from './types.js';
|
||||
|
||||
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const UPSTREAM_TIMEOUT_MS = 5000;
|
||||
const RETRY_ON_STATUSES = new Set([500, 502, 503, 504]);
|
||||
|
||||
export interface ValidateResult {
|
||||
response: SiteverifyResponse;
|
||||
upstreamStatus: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Turnstile token against Cloudflare's siteverify endpoint.
|
||||
*
|
||||
* @throws SiteverifyError on Worker-level failures (missing secret, unreachable upstream, timeout)
|
||||
*/
|
||||
export async function validate(req: SiteverifyRequest, env: Env, workerVersion: string): Promise<ValidateResult> {
|
||||
if (!env.TURNSTILE_SECRET_KEY) {
|
||||
throw new SiteverifyError(500, 'missing-input-secret', 'TURNSTILE_SECRET_KEY not configured');
|
||||
}
|
||||
if (!req.token) {
|
||||
throw new SiteverifyError(400, 'missing-token', 'token is required');
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('secret', env.TURNSTILE_SECRET_KEY);
|
||||
body.append('response', req.token);
|
||||
if (req.remoteip) body.append('remoteip', req.remoteip);
|
||||
if (req.idempotency_key) body.append('idempotency_key', req.idempotency_key);
|
||||
|
||||
const start = Date.now();
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const result = await fetchWithTimeout(SITEVERIFY_URL, body, UPSTREAM_TIMEOUT_MS);
|
||||
const durationMs = Date.now() - start;
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
const data = (await result.json()) as SiteverifyResponse;
|
||||
data._worker = {
|
||||
duration_ms: durationMs,
|
||||
worker_version: workerVersion,
|
||||
};
|
||||
// Hostname-mismatch defense. If the customer set EXPECTED_HOSTNAME,
|
||||
// reject responses whose hostname doesn't match — defends against
|
||||
// cross-site token replay even if a token leaks.
|
||||
if (data.success && env.EXPECTED_HOSTNAME && data.hostname && data.hostname !== env.EXPECTED_HOSTNAME) {
|
||||
const rejected: SiteverifyResponse = {
|
||||
...data,
|
||||
success: false,
|
||||
'error-codes': ['hostname-mismatch'],
|
||||
};
|
||||
return { response: rejected, upstreamStatus: result.status, durationMs };
|
||||
}
|
||||
return { response: data, upstreamStatus: result.status, durationMs };
|
||||
}
|
||||
if (!RETRY_ON_STATUSES.has(result.status) || attempt === 1) {
|
||||
// Don't retry on 4xx, return the upstream body as-is so customers
|
||||
// see exactly what siteverify said.
|
||||
const data = (await result.json().catch(() => ({}))) as Partial<SiteverifyResponse>;
|
||||
const response: SiteverifyResponse = {
|
||||
success: false,
|
||||
'error-codes': data['error-codes'] ?? ['bad-request'],
|
||||
...data,
|
||||
_worker: { duration_ms: durationMs, worker_version: workerVersion },
|
||||
};
|
||||
return { response, upstreamStatus: result.status, durationMs };
|
||||
}
|
||||
// 5xx + first attempt: retry
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt === 1) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastErr instanceof DOMException && lastErr.name === 'AbortError') {
|
||||
throw new SiteverifyError(504, 'upstream-timeout', `Upstream timeout after ${UPSTREAM_TIMEOUT_MS}ms`);
|
||||
}
|
||||
throw new SiteverifyError(502, 'upstream-unreachable', `Upstream siteverify unreachable: ${String(lastErr)}`);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, body: FormData, timeoutMs: number): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user