/*
RFC-0181: EU-resident delivery substrate. buildQstashPublish constructs the HTTPS request that
publishes an IntegrationEvent to Upstash QStash in the EU region (eu-central-1, Frankfurt) — with
content dedup and a retry count — for reliable webhook delivery back to the site's own callback. The
IdempotencyLedger (Upstash Redis EU) is the durable first-seen guard the callback consults before
routing. Pure: buildQstashPublish does no I/O; the ledger is an injectable interface. Replaces the
Cloudflare Queues/KV substrate (RFC-0179), which cannot be EU-pinned.
- Do not log or embed secrets (UPSTASH_QSTASH_TOKEN, Redis token) — caller injects them.
- Do not publish lead PII to the US region or a non-EU base URL.
- Do not verify QStash webhook signatures here — the route uses @upstash/qstash Receiver.
- RFC-0181: initial EU delivery substrate (QStash + Redis, eu-central-1).
*/
import type { IntegrationEvent } from "./port.ts";
/** EU-region QStash base URL — the residency pin (Frankfurt, eu-central-1). RFC-0181. */
export const QSTASH_EU_BASE = "https://qstash-eu-central-1.upstash.io" as const;
/**
* Canonical server-secret NAMES for the EU delivery substrate (RFC-0181). Single
* source of truth for the env schema, the .env.example generator, and validators.
* Values are read via `astro:env/server`; never logged, returned, or committed.
* - `UPSTASH_QSTASH_URL` — the EU QStash base (must be the eu-central-1 endpoint).
* - `UPSTASH_QSTASH_TOKEN` — publish token (Bearer).
* - `UPSTASH_QSTASH_CURRENT_SIGNING_KEY` / `…_NEXT_SIGNING_KEY` — webhook signature verification.
* - `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` — the EU Redis idempotency ledger.
*/
export const UPSTASH_QSTASH_SECRETS = [
"UPSTASH_QSTASH_URL",
"UPSTASH_QSTASH_TOKEN",
"UPSTASH_QSTASH_CURRENT_SIGNING_KEY",
"UPSTASH_QSTASH_NEXT_SIGNING_KEY",
] as const;
export const UPSTASH_REDIS_SECRETS = [
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
] as const;
/** Every EU-delivery secret name (QStash + Redis). RFC-0181. */
export const UPSTASH_DELIVERY_SECRETS = [
...UPSTASH_QSTASH_SECRETS,
...UPSTASH_REDIS_SECRETS,
] as const;
export interface QstashPublishConfig {
/** UPSTASH_QSTASH_TOKEN — server secret. Never logged. */
token: string;
/** The site's own absolute callback URL (its /internal/integration-route). */
callbackUrl: string;
/** QStash retry count (exponential backoff). Default 3. */
retries?: number;
/** Base URL — defaults to the EU region. Overriding to a non-EU base breaks residency. */
baseUrl?: string;
}
/**
* Pure (RFC-0181): build the HTTPS request that publishes `event` to QStash EU
* for reliable webhook delivery to `config.callbackUrl`. QStash dedups on
* `Upstash-Deduplication-Id` (the eventId) and retries to the callback, then
* dead-letters. No I/O — the caller performs the fetch with this Request.
*/
export function buildQstashPublish(event: IntegrationEvent, config: QstashPublishConfig): Request {
const base = config.baseUrl ?? QSTASH_EU_BASE;
// QStash v2: POST /v2/publish/{destination-url}; the body is forwarded to the destination.
// Do NOT encode the callback URL — QStash expects the raw URL in the path segment.
// encodeURIComponent breaks scheme detection because %3A is not decoded back to :.
const url = `${base}/v2/publish/${config.callbackUrl}`;
return new Request(url, {
method: "POST",
headers: {
authorization: `Bearer ${config.token}`,
"content-type": "application/json",
"upstash-deduplication-id": event.eventId,
"upstash-retries": String(config.retries ?? 3),
},
body: JSON.stringify(event),
});
}
/**
* Durable idempotency ledger (RFC-0181) backed by Upstash Redis in the EU.
* `firstSeen` returns true exactly once per eventId (SET NX), so a QStash
* redelivery never double-writes into the client's CRM. Stores only the eventId
* key with a short TTL — never PII.
*/
export interface IdempotencyLedger {
firstSeen(eventId: string, ttlSeconds?: number): Promise;
}
/** Minimal structural type for the Upstash Redis REST endpoint (no hard dep). */
export interface RestRedisConfig {
/** UPSTASH_REDIS_REST_URL — must be the eu-central-1 endpoint for EU residency. */
url: string;
/** UPSTASH_REDIS_REST_TOKEN — server secret. Never logged. */
token: string;
/** Key prefix; defaults to the integration dedup namespace. */
prefix?: string;
}
/**
* Adapt an Upstash Redis REST endpoint to the IdempotencyLedger (RFC-0181). Uses
* `SET key 1 NX EX ttl`; a `{"result":"OK"}` means first-seen, `null` means the
* key already existed (a redelivery). Fails closed on a transport error by
* throwing, so the callback retries rather than risk a double-write.
*/
export function restRedisLedger(
config: RestRedisConfig,
fetchImpl: typeof fetch = fetch,
): IdempotencyLedger {
const prefix = config.prefix ?? "gogol-int-dedup";
return {
async firstSeen(eventId: string, ttlSeconds = 86400): Promise {
const key = `${prefix}:${eventId}`;
const command = ["SET", key, "1", "NX", "EX", String(ttlSeconds)];
const response = await fetchImpl(config.url, {
method: "POST",
headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
body: JSON.stringify(command),
});
if (!response.ok) {
throw new Error(`redis ledger SET failed: ${response.status}`);
}
const data = (await response.json()) as { result?: string | null };
return data.result === "OK";
},
};
}