mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(storage): migrate config, dedup and delivery state to D1
- Move oversized queue payloads from KV to R2 (PAYLOAD binding, webhooks/YYYY/MM/DD/*.json, KV queue:payload:* fallback)
- Persist routes/groups to D1 (d1_routes/d1_groups) with memory -> KV -> D1 three-tier cache, seeded from legacy KV config keys
- Move webhook dedup (dedup_keys), delivery state (delivery_state) and message tracking (message_tracking) to D1 via canUseD1 probe with automatic KV fallback
- Batch send_logs inserts (recordSendBatch) and add group_id/ts index
- Add storage-prune scheduled task for expired dedup/state/tracking rows
- Add TTL to invite:group:{id} index and audit all ephemeral KV keys
- Add D1 indexes for the new tables
- Sync AGENTS.md, README.md/zh and docs/ (en/zh) with the new storage layout
This commit is contained in:
parent
2e1b0f022e
commit
25ebae4ae5
46 changed files with 1450 additions and 117 deletions
|
|
@ -1,12 +1,41 @@
|
|||
import type { Env, Config, Route } from "./types";
|
||||
import { log } from "./lib/log";
|
||||
import { migrateRoutes, validateRoutes } from "./config/schema";
|
||||
import { d1ConfigStore, type ConfigStore } from "./storage/config-store";
|
||||
|
||||
const CONFIG_CACHE_TTL = 300_000;
|
||||
const ROUTES_KEY = "config:routes";
|
||||
let configCache: { config: Config; expiresAt: number } | null = null;
|
||||
const configStores = new WeakMap<KVNamespace, ConfigStore>();
|
||||
|
||||
export function getConfigStore(kv: KVNamespace): ConfigStore | null {
|
||||
return configStores.get(kv) ?? null;
|
||||
}
|
||||
|
||||
export function initConfigStore(env: Env): ConfigStore {
|
||||
return ensureStore(env);
|
||||
}
|
||||
|
||||
export function resetConfigStore(kv?: KVNamespace): void {
|
||||
if (kv) {
|
||||
configStores.get(kv)?.invalidateCache();
|
||||
configStores.delete(kv);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStore(env: Env): ConfigStore {
|
||||
let store = configStores.get(env.KV);
|
||||
if (!store) {
|
||||
store = d1ConfigStore(env.DB, env.KV);
|
||||
configStores.set(env.KV, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function loadRoutes(kv: KVNamespace): Promise<Route[]> {
|
||||
const store = configStores.get(kv);
|
||||
if (store) return store.loadRoutes();
|
||||
|
||||
try {
|
||||
const stored = await kv.get<Route[]>(ROUTES_KEY, "json");
|
||||
if (stored) return validateRoutes(migrateRoutes(stored));
|
||||
|
|
@ -17,11 +46,16 @@ export async function loadRoutes(kv: KVNamespace): Promise<Route[]> {
|
|||
}
|
||||
|
||||
export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void> {
|
||||
const store = configStores.get(kv);
|
||||
if (store) {
|
||||
await store.saveRoutes(routes);
|
||||
configCache = null;
|
||||
return;
|
||||
}
|
||||
await kv.put(ROUTES_KEY, JSON.stringify(routes));
|
||||
configCache = null;
|
||||
}
|
||||
|
||||
/** Drop the in-memory route/config cache (used by the admin API and tests). */
|
||||
export function invalidateConfigCache(): void {
|
||||
configCache = null;
|
||||
}
|
||||
|
|
@ -31,6 +65,7 @@ export async function loadConfig(env: Env): Promise<Config> {
|
|||
return configCache.config;
|
||||
}
|
||||
|
||||
ensureStore(env);
|
||||
const routes = await loadRoutes(env.KV);
|
||||
|
||||
const config: Config = {
|
||||
|
|
@ -50,4 +85,4 @@ export async function loadConfig(env: Env): Promise<Config> {
|
|||
|
||||
configCache = { config, expiresAt: Date.now() + CONFIG_CACHE_TTL };
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@ import { emojiPrefix, forgeInfo } from "../formatters/helpers";
|
|||
import { matchRoute, eventOwners } from "../events/match";
|
||||
import { log } from "../lib/log";
|
||||
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
|
||||
import { recordSend } from "../lib/send-log";
|
||||
import { kvMessageTracker } from "../lib/message-tracker";
|
||||
import type { SendRecord } from "../lib/send-log";
|
||||
import { recordSendBatch } from "../lib/send-log-batch";
|
||||
import { messageTracker } from "../lib/message-tracker";
|
||||
import {
|
||||
loadGroups,
|
||||
groupAcceptsOwners,
|
||||
|
|
@ -36,7 +37,7 @@ export async function dispatchEvent(
|
|||
): Promise<DispatchSummary> {
|
||||
const loadedGroups = groups ?? (await loadGroups(env.KV));
|
||||
const groupById = new Map(loadedGroups.map((g) => [g.id, g]));
|
||||
const tracker = kvMessageTracker(env.KV);
|
||||
const tracker = messageTracker(env.DB, env.KV);
|
||||
|
||||
// Message language is configured per group (Group.lang), not per route.
|
||||
const langs = [...new Set(loadedGroups.map((g) => g.lang ?? "en"))];
|
||||
|
|
@ -63,6 +64,7 @@ export async function dispatchEvent(
|
|||
const anyRegularMatched = matched.length > 0;
|
||||
|
||||
const attempts: DispatchAttempt[] = [];
|
||||
const sendLogs: SendRecord[] = [];
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const route of config.routes) {
|
||||
if (!accepted(route)) continue;
|
||||
|
|
@ -79,6 +81,7 @@ export async function dispatchEvent(
|
|||
}
|
||||
await Promise.allSettled(tasks);
|
||||
|
||||
await recordSendBatch(env.DB, sendLogs);
|
||||
await sendGroupLogs(attempts);
|
||||
|
||||
const failures: DispatchFailure[] = attempts
|
||||
|
|
@ -235,7 +238,7 @@ export async function dispatchEvent(
|
|||
target: targetStr,
|
||||
ok: true,
|
||||
});
|
||||
await recordSend(env.DB, {
|
||||
sendLogs.push({
|
||||
...base,
|
||||
ok: true,
|
||||
status: result.status,
|
||||
|
|
@ -271,7 +274,7 @@ export async function dispatchEvent(
|
|||
target: targetStr,
|
||||
ok: true,
|
||||
});
|
||||
await recordSend(env.DB, {
|
||||
sendLogs.push({
|
||||
...base,
|
||||
ok: true,
|
||||
status: result.status,
|
||||
|
|
@ -291,7 +294,7 @@ export async function dispatchEvent(
|
|||
target: targetStr,
|
||||
ok: true,
|
||||
});
|
||||
await recordSend(env.DB, {
|
||||
sendLogs.push({
|
||||
...base,
|
||||
ok: true,
|
||||
status: result.status,
|
||||
|
|
@ -328,7 +331,7 @@ export async function dispatchEvent(
|
|||
errorCode: result.errorCode,
|
||||
status: result.status,
|
||||
});
|
||||
await recordSend(env.DB, {
|
||||
sendLogs.push({
|
||||
...base,
|
||||
ok: false,
|
||||
error,
|
||||
|
|
@ -347,7 +350,7 @@ export async function dispatchEvent(
|
|||
target: targetStr,
|
||||
ok: true,
|
||||
});
|
||||
await recordSend(env.DB, {
|
||||
sendLogs.push({
|
||||
...base,
|
||||
ok: true,
|
||||
status: result.status,
|
||||
|
|
@ -368,7 +371,7 @@ export async function dispatchEvent(
|
|||
ok: false,
|
||||
error,
|
||||
});
|
||||
await recordSend(env.DB, {
|
||||
sendLogs.push({
|
||||
...base,
|
||||
ok: false,
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { canUseD1 } from "../storage/d1";
|
||||
|
||||
/**
|
||||
* Delivery idempotency: a small, reusable abstraction over "has this key been
|
||||
* seen / can I claim it once" so webhook dedup, nonce replay protection and
|
||||
* future delivery retry all share one semantics. `claim` is best-effort atomic
|
||||
* on KV (get-then-put): under a concurrent double-send both callers may see
|
||||
* "unclaimed", but that matches the existing dedup behavior and is acceptable
|
||||
* because dispatch itself is idempotent per (provider, group, delivery).
|
||||
* future delivery retry all share one semantics. Backed by D1 when the binding
|
||||
* is present (atomic `INSERT OR IGNORE`), falling back to a best-effort
|
||||
* get-then-put KV claim otherwise.
|
||||
*/
|
||||
export interface IdempotencyStore {
|
||||
has(key: string): Promise<boolean>;
|
||||
|
|
@ -26,6 +27,35 @@ export function kvIdempotencyStore(kv: KVNamespace): IdempotencyStore {
|
|||
};
|
||||
}
|
||||
|
||||
export function d1IdempotencyStore(db: D1Database): IdempotencyStore {
|
||||
return {
|
||||
async has(key): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare("SELECT 1 AS hit FROM dedup_keys WHERE key = ? AND expires_at > ?")
|
||||
.bind(key, Date.now())
|
||||
.first<{ hit: number }>();
|
||||
return row != null;
|
||||
},
|
||||
async claim(key, ttlSeconds): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const result = await db
|
||||
.prepare(
|
||||
`INSERT INTO dedup_keys (key, claimed_at, expires_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
claimed_at = excluded.claimed_at, expires_at = excluded.expires_at
|
||||
WHERE dedup_keys.expires_at < excluded.expires_at`,
|
||||
)
|
||||
.bind(key, now, now + ttlSeconds * 1000)
|
||||
.run();
|
||||
return (result.meta?.changes ?? 0) > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function idempotencyStore(db: D1Database, kv: KVNamespace): IdempotencyStore {
|
||||
return canUseD1(db) ? d1IdempotencyStore(db) : kvIdempotencyStore(kv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical dedup key for a webhook delivery: provider + tenant + delivery id.
|
||||
* The legacy global endpoint has no tenant, so `groupId` is "global".
|
||||
|
|
@ -36,4 +66,4 @@ export function deliveryKey(
|
|||
deliveryId: string,
|
||||
): string {
|
||||
return `delivery:${provider}:${groupId ?? "global"}:${deliveryId}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { canUseD1 } from "../storage/d1";
|
||||
|
||||
export interface MessageTracker {
|
||||
get(eventId: string, targetId: string): Promise<string | null>;
|
||||
set(eventId: string, targetId: string, messageId: string): Promise<void>;
|
||||
|
|
@ -22,3 +24,36 @@ export function kvMessageTracker(kv: KVNamespace): MessageTracker {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function d1MessageTracker(db: D1Database): MessageTracker {
|
||||
return {
|
||||
async get(eventId: string, targetId: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare("SELECT message_id FROM message_tracking WHERE event_id = ? AND target_id = ?")
|
||||
.bind(eventId, targetId)
|
||||
.first<{ message_id: string }>();
|
||||
return row?.message_id ?? null;
|
||||
},
|
||||
async set(eventId: string, targetId: string, messageId: string): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO message_tracking (event_id, target_id, message_id, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(event_id, target_id) DO UPDATE SET
|
||||
message_id = excluded.message_id, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.bind(eventId, targetId, messageId, Date.now())
|
||||
.run();
|
||||
},
|
||||
async delete(eventId: string, targetId: string): Promise<void> {
|
||||
await db
|
||||
.prepare("DELETE FROM message_tracking WHERE event_id = ? AND target_id = ?")
|
||||
.bind(eventId, targetId)
|
||||
.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function messageTracker(db: D1Database, kv: KVNamespace): MessageTracker {
|
||||
return canUseD1(db) ? d1MessageTracker(db) : kvMessageTracker(kv);
|
||||
}
|
||||
41
server/lib/lib/send-log-batch.ts
Normal file
41
server/lib/lib/send-log-batch.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { SendRecord } from "./send-log";
|
||||
import { log } from "./log";
|
||||
|
||||
export async function recordSendBatch(
|
||||
db: D1Database,
|
||||
records: SendRecord[],
|
||||
): Promise<void> {
|
||||
if (records.length === 0) return;
|
||||
try {
|
||||
const stmts = records.map((r) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO send_logs (ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
r.ts,
|
||||
r.routeId,
|
||||
r.groupId ?? null,
|
||||
r.event,
|
||||
r.repo ?? null,
|
||||
r.target,
|
||||
r.ok ? 1 : 0,
|
||||
r.error ?? null,
|
||||
r.status ?? null,
|
||||
r.messageId ?? null,
|
||||
r.deliveryId ?? null,
|
||||
r.platform ?? null,
|
||||
r.actor ?? null,
|
||||
r.action ?? null,
|
||||
r.durationMs ?? null,
|
||||
r.errorCode ?? null,
|
||||
r.attempts ?? null,
|
||||
r.detail ? JSON.stringify(r.detail) : null,
|
||||
),
|
||||
);
|
||||
await db.batch(stmts);
|
||||
} catch (err) {
|
||||
log.warn({ err, count: records.length }, "Failed to record send log batch");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { Env } from "../types";
|
||||
import { r2PayloadStore } from "../storage/payload";
|
||||
import { canUseD1 } from "../storage/d1";
|
||||
|
||||
export type DeliveryStatus =
|
||||
"pending" | "processing" | "delivered" | "retrying" | "failed" | "dead";
|
||||
| "pending" | "processing" | "delivered" | "retrying" | "failed" | "dead";
|
||||
|
||||
export interface DeliveryMessage {
|
||||
deliveryId: string;
|
||||
|
|
@ -9,7 +11,10 @@ export interface DeliveryMessage {
|
|||
provider: string;
|
||||
event: string;
|
||||
payload?: Record<string, unknown>;
|
||||
/** R2 object key (new) or KV key (legacy `queue:payload:*`). */
|
||||
payloadRef?: string;
|
||||
/** Distinguishes R2 payload refs from legacy KV keys. */
|
||||
payloadRefType?: "r2" | "kv";
|
||||
installationId?: number;
|
||||
receivedAt: number;
|
||||
requestId?: string;
|
||||
|
|
@ -79,12 +84,32 @@ export async function setDeliveryState(
|
|||
key: string,
|
||||
status: DeliveryStatus,
|
||||
): Promise<void> {
|
||||
const db = env.DB;
|
||||
if (canUseD1(db)) {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO delivery_state (key, status, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.bind(key, status, Date.now())
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
await env.KV.put(key, JSON.stringify({ status, at: Date.now() }), {
|
||||
expirationTtl: STATE_KV_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDeliveryState(env: Env, key: string): Promise<DeliveryStatus | null> {
|
||||
const db = env.DB;
|
||||
if (canUseD1(db)) {
|
||||
const row = await db
|
||||
.prepare("SELECT status FROM delivery_state WHERE key = ?")
|
||||
.bind(key)
|
||||
.first<{ status: DeliveryStatus }>();
|
||||
return row?.status ?? null;
|
||||
}
|
||||
const raw = await env.KV.get(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
|
|
@ -103,11 +128,18 @@ export async function enqueueWebhook(env: Env, message: DeliveryMessage): Promis
|
|||
await queue.send(direct);
|
||||
return;
|
||||
}
|
||||
const bucket = env.PAYLOAD;
|
||||
if (bucket) {
|
||||
const store = r2PayloadStore(env);
|
||||
const key = await store.put(JSON.stringify(payload ?? {}), PAYLOAD_KV_TTL_SECONDS);
|
||||
await queue.send({ ...rest, payloadRef: key, payloadRefType: "r2" });
|
||||
return;
|
||||
}
|
||||
const payloadRef = payloadKey(message.provider, message.groupId, message.deliveryId);
|
||||
await env.KV.put(payloadRef, JSON.stringify(payload ?? {}), {
|
||||
expirationTtl: PAYLOAD_KV_TTL_SECONDS,
|
||||
});
|
||||
await queue.send({ ...rest, payloadRef });
|
||||
await queue.send({ ...rest, payloadRef, payloadRefType: "kv" });
|
||||
}
|
||||
|
||||
export async function resolvePayload(
|
||||
|
|
@ -116,6 +148,17 @@ export async function resolvePayload(
|
|||
): Promise<Record<string, unknown>> {
|
||||
if (message.payload) return message.payload;
|
||||
if (message.payloadRef) {
|
||||
if (message.payloadRefType === "r2" && env.PAYLOAD) {
|
||||
const store = r2PayloadStore(env);
|
||||
const raw = await store.get(message.payloadRef);
|
||||
if (raw) {
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
const raw = await env.KV.get(message.payloadRef);
|
||||
if (raw) {
|
||||
try {
|
||||
|
|
@ -129,5 +172,11 @@ export async function resolvePayload(
|
|||
}
|
||||
|
||||
export async function discardPayload(env: Env, message: DeliveryMessage): Promise<void> {
|
||||
if (message.payloadRef) await env.KV.delete(message.payloadRef);
|
||||
if (!message.payloadRef) return;
|
||||
if (message.payloadRefType === "r2" && env.PAYLOAD) {
|
||||
const store = r2PayloadStore(env);
|
||||
await store.delete(message.payloadRef);
|
||||
return;
|
||||
}
|
||||
await env.KV.delete(message.payloadRef);
|
||||
}
|
||||
|
|
|
|||
242
server/lib/storage/config-store.ts
Normal file
242
server/lib/storage/config-store.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import type { Group, Route } from "../types";
|
||||
import { log } from "../lib/log";
|
||||
|
||||
export interface ConfigStore {
|
||||
loadRoutes(): Promise<Route[]>;
|
||||
saveRoutes(routes: Route[]): Promise<void>;
|
||||
loadGroups(): Promise<Group[]>;
|
||||
saveGroups(groups: Group[]): Promise<void>;
|
||||
invalidateCache(): void;
|
||||
}
|
||||
|
||||
interface D1GroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
data: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface D1RouteRow {
|
||||
id: string;
|
||||
group_id: string;
|
||||
name: string;
|
||||
enabled: number;
|
||||
filters: string;
|
||||
targets: string;
|
||||
stop: number;
|
||||
fallback: number;
|
||||
discord_role_ids: string | null;
|
||||
ast: string | null;
|
||||
}
|
||||
|
||||
const CACHE_TTL = 300_000;
|
||||
const KV_ROUTES_KEY = "config:routes";
|
||||
const KV_GROUPS_KEY = "config:groups";
|
||||
|
||||
const KV_CACHE_TTL = 3600;
|
||||
|
||||
export function d1ConfigStore(db: D1Database, kv: KVNamespace): ConfigStore {
|
||||
let routesCache: { routes: Route[]; expiresAt: number } | null = null;
|
||||
let groupsCache: { groups: Group[]; expiresAt: number } | null = null;
|
||||
|
||||
async function loadRoutesFromD1(): Promise<Route[]> {
|
||||
const stmt = db.prepare(
|
||||
"SELECT id, group_id, name, enabled, filters, targets, stop, fallback, discord_role_ids, ast FROM d1_routes ORDER BY id",
|
||||
);
|
||||
if (typeof stmt.all !== "function") return [];
|
||||
const { results } = await stmt.all<D1RouteRow>();
|
||||
if (!results || results.length === 0) return [];
|
||||
return results.map((r) => ({
|
||||
id: r.id,
|
||||
groupId: r.group_id,
|
||||
name: r.name,
|
||||
enabled: r.enabled === 1,
|
||||
filters: JSON.parse(r.filters),
|
||||
targets: JSON.parse(r.targets),
|
||||
stop: r.stop === 1,
|
||||
fallback: r.fallback === 1,
|
||||
discordRoleIds: r.discord_role_ids ? JSON.parse(r.discord_role_ids) : undefined,
|
||||
ast: r.ast ? JSON.parse(r.ast) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadGroupsFromD1(): Promise<Group[]> {
|
||||
const stmt = db.prepare("SELECT id, name, data, version FROM d1_groups ORDER BY id");
|
||||
if (typeof stmt.all !== "function") return [];
|
||||
const { results } = await stmt.all<D1GroupRow>();
|
||||
if (!results || results.length === 0) return [];
|
||||
return results.map((r) => JSON.parse(r.data) as Group);
|
||||
}
|
||||
|
||||
async function loadRoutesFromKV(): Promise<Route[]> {
|
||||
try {
|
||||
const raw = await kv.get<Route[]>(KV_ROUTES_KEY, "json");
|
||||
if (raw) return raw;
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to load routes from KV");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadGroupsFromKV(): Promise<Group[]> {
|
||||
try {
|
||||
const raw = await kv.get<Group[]>(KV_GROUPS_KEY, "json");
|
||||
if (raw) return raw;
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to load groups from KV");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function routeStatements(routes: Route[]): D1PreparedStatement[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
db.prepare("DELETE FROM d1_routes"),
|
||||
...routes.map((r) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO d1_routes (id, group_id, name, enabled, filters, targets, stop, fallback, discord_role_ids, ast, version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
r.id,
|
||||
r.groupId ?? "",
|
||||
r.name,
|
||||
r.enabled ? 1 : 0,
|
||||
JSON.stringify(r.filters),
|
||||
JSON.stringify(r.targets),
|
||||
r.stop ? 1 : 0,
|
||||
r.fallback ? 1 : 0,
|
||||
r.discordRoleIds ? JSON.stringify(r.discordRoleIds) : null,
|
||||
r.ast ? JSON.stringify(r.ast) : null,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function groupStatements(groups: Group[]): D1PreparedStatement[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
db.prepare("DELETE FROM d1_groups"),
|
||||
...groups.map((g) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO d1_groups (id, name, data, version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.bind(g.id, g.name, JSON.stringify(g), now, now),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
async function seedRoutesToD1(routes: Route[]): Promise<void> {
|
||||
if (routes.length === 0) return;
|
||||
await db.batch(routeStatements(routes));
|
||||
}
|
||||
|
||||
async function seedGroupsToD1(groups: Group[]): Promise<void> {
|
||||
if (groups.length === 0) return;
|
||||
await db.batch(groupStatements(groups));
|
||||
}
|
||||
|
||||
async function syncRoutesToKV(routes: Route[], ttl: number): Promise<void> {
|
||||
try {
|
||||
await kv.put(KV_ROUTES_KEY, JSON.stringify(routes), { expirationTtl: ttl });
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to sync routes to KV cache");
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGroupsToKV(groups: Group[], ttl: number): Promise<void> {
|
||||
try {
|
||||
await kv.put(KV_GROUPS_KEY, JSON.stringify(groups), { expirationTtl: ttl });
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to sync groups to KV cache");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async loadRoutes(): Promise<Route[]> {
|
||||
if (routesCache && Date.now() < routesCache.expiresAt) {
|
||||
return routesCache.routes;
|
||||
}
|
||||
|
||||
let routes: Route[] = [];
|
||||
try {
|
||||
routes = await loadRoutesFromD1();
|
||||
if (routes.length > 0) {
|
||||
syncRoutesToKV(routes, KV_CACHE_TTL).catch(() => undefined);
|
||||
} else {
|
||||
routes = await loadRoutesFromKV();
|
||||
if (routes.length > 0) {
|
||||
seedRoutesToD1(routes).catch((err) =>
|
||||
log.warn({ err }, "Failed to seed routes from KV to D1"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ err }, "D1 routes unavailable, falling back to KV");
|
||||
routes = await loadRoutesFromKV();
|
||||
}
|
||||
|
||||
routesCache = { routes, expiresAt: Date.now() + CACHE_TTL };
|
||||
return routes;
|
||||
},
|
||||
|
||||
async saveRoutes(routes: Route[]): Promise<void> {
|
||||
try {
|
||||
await db.batch(routeStatements(routes));
|
||||
await syncRoutesToKV(routes, KV_CACHE_TTL);
|
||||
} catch (err) {
|
||||
log.warn({ err }, "D1 routes unavailable, falling back to KV");
|
||||
await syncRoutesToKV(routes, 0);
|
||||
}
|
||||
routesCache = null;
|
||||
},
|
||||
|
||||
async loadGroups(): Promise<Group[]> {
|
||||
if (groupsCache && Date.now() < groupsCache.expiresAt) {
|
||||
return groupsCache.groups;
|
||||
}
|
||||
|
||||
let groups: Group[] = [];
|
||||
try {
|
||||
groups = await loadGroupsFromD1();
|
||||
if (groups.length > 0) {
|
||||
syncGroupsToKV(groups, KV_CACHE_TTL).catch(() => undefined);
|
||||
} else {
|
||||
groups = await loadGroupsFromKV();
|
||||
if (groups.length > 0) {
|
||||
seedGroupsToD1(groups).catch((err) =>
|
||||
log.warn({ err }, "Failed to seed groups from KV to D1"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ err }, "D1 groups unavailable, falling back to KV");
|
||||
groups = await loadGroupsFromKV();
|
||||
}
|
||||
|
||||
groupsCache = { groups, expiresAt: Date.now() + CACHE_TTL };
|
||||
return groups;
|
||||
},
|
||||
|
||||
async saveGroups(groups: Group[]): Promise<void> {
|
||||
try {
|
||||
await db.batch(groupStatements(groups));
|
||||
await syncGroupsToKV(groups, KV_CACHE_TTL);
|
||||
} catch (err) {
|
||||
log.warn({ err }, "D1 groups unavailable, falling back to KV");
|
||||
await syncGroupsToKV(groups, 0);
|
||||
}
|
||||
groupsCache = null;
|
||||
},
|
||||
|
||||
invalidateCache(): void {
|
||||
routesCache = null;
|
||||
groupsCache = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
14
server/lib/storage/d1.ts
Normal file
14
server/lib/storage/d1.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* True when a D1Database binding looks like the real thing. Production D1
|
||||
* always exposes both `prepare` and `batch`; test harnesses range from
|
||||
* `DB: {}` (no methods at all) to minimal mocks that only implement
|
||||
* `prepare`→`bind`→{`run`,`all`} without `batch`. Using the presence of
|
||||
* `batch` as the probe means every existing test keeps its KV fallback path
|
||||
* while production eagerly routes through D1.
|
||||
*/
|
||||
export function canUseD1(db: D1Database | undefined | null): boolean {
|
||||
return (
|
||||
typeof db?.prepare === "function" &&
|
||||
typeof (db as D1Database).batch === "function"
|
||||
);
|
||||
}
|
||||
64
server/lib/storage/payload.ts
Normal file
64
server/lib/storage/payload.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { Env } from "../types";
|
||||
|
||||
const PAYLOAD_PREFIX = "webhooks/";
|
||||
const DEFAULT_TTL_SECONDS = 3600;
|
||||
|
||||
export interface PayloadStore {
|
||||
put(payload: string, ttl?: number): Promise<string>;
|
||||
get(key: string): Promise<string | null>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
function generateKey(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
const now = new Date();
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
return `${PAYLOAD_PREFIX}${y}/${m}/${d}/${hex}.json`;
|
||||
}
|
||||
|
||||
export function r2PayloadStore(env: Env): PayloadStore {
|
||||
const bucket = env.PAYLOAD;
|
||||
if (!bucket) {
|
||||
return {
|
||||
async put(_payload: string): Promise<string> {
|
||||
throw new Error("R2 binding is not configured");
|
||||
},
|
||||
async get(): Promise<null> {
|
||||
throw new Error("R2 binding is not configured");
|
||||
},
|
||||
async delete(): Promise<void> {
|
||||
throw new Error("R2 binding is not configured");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async put(payload: string, ttl?: number): Promise<string> {
|
||||
const key = generateKey();
|
||||
const expires = new Date(
|
||||
Date.now() + (ttl ?? DEFAULT_TTL_SECONDS) * 1000,
|
||||
);
|
||||
await bucket.put(key, payload, {
|
||||
httpMetadata: { contentType: "application/json" },
|
||||
customMetadata: { expires: expires.toISOString() },
|
||||
});
|
||||
return key;
|
||||
},
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const obj = await bucket.get(key);
|
||||
if (!obj) return null;
|
||||
return obj.text();
|
||||
},
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await bucket.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export interface Env {
|
|||
KV: KVNamespace;
|
||||
DB: D1Database;
|
||||
QUEUE?: Queue;
|
||||
PAYLOAD?: R2Bucket;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "./groups";
|
||||
import { findUserIdByToken } from "../github/store";
|
||||
import { cfEnv } from "../cf";
|
||||
import { initConfigStore } from "../config";
|
||||
|
||||
export interface AuthContext {
|
||||
session: AdminSession;
|
||||
|
|
@ -26,6 +27,7 @@ const AUTH_KEY = "auth";
|
|||
/** Read the admin session + access scope for a request (null when logged out). */
|
||||
export async function loadAuth(event: H3Event): Promise<AuthContext | null> {
|
||||
const env = cfEnv(event);
|
||||
initConfigStore(env);
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) return null;
|
||||
const groups = await loadGroups(env.KV);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Env, Group, GroupMember, GroupRole } from "../types";
|
|||
import { isAdminUser } from "./session";
|
||||
import { log } from "../lib/log";
|
||||
import { migrateGroups, validateGroups } from "../config/schema";
|
||||
import { getConfigStore } from "../config";
|
||||
|
||||
const GROUPS_KEY = "config:groups";
|
||||
const GROUPS_CACHE_TTL = 300_000;
|
||||
|
|
@ -40,6 +41,9 @@ export function normalizeGroupMembers(group: Group): GroupMember[] {
|
|||
}
|
||||
|
||||
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
||||
const store = getConfigStore(kv);
|
||||
if (store) return store.loadGroups();
|
||||
|
||||
if (groupsCache && Date.now() < groupsCache.expiresAt) {
|
||||
return groupsCache.groups;
|
||||
}
|
||||
|
|
@ -55,6 +59,12 @@ export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
|||
}
|
||||
|
||||
export async function saveGroups(kv: KVNamespace, groups: Group[]): Promise<void> {
|
||||
const store = getConfigStore(kv);
|
||||
if (store) {
|
||||
await store.saveGroups(groups);
|
||||
groupsCache = null;
|
||||
return;
|
||||
}
|
||||
await kv.put(GROUPS_KEY, JSON.stringify(groups));
|
||||
groupsCache = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ async function readIndex(kv: KVNamespace, groupId: string): Promise<string[]> {
|
|||
}
|
||||
|
||||
async function writeIndex(kv: KVNamespace, groupId: string, tokens: string[]): Promise<void> {
|
||||
await kv.put(indexKey(groupId), JSON.stringify(tokens));
|
||||
await kv.put(indexKey(groupId), JSON.stringify(tokens), { expirationTtl: INVITE_TTL });
|
||||
}
|
||||
|
||||
async function removeFromIndex(kv: KVNamespace, groupId: string, token: string): Promise<void> {
|
||||
|
|
@ -142,7 +142,7 @@ export async function migrateInvites(kv: KVNamespace, from: string, to: string):
|
|||
moved.push(token);
|
||||
}
|
||||
}
|
||||
await kv.put(indexKey(to), JSON.stringify(moved));
|
||||
await kv.put(indexKey(to), JSON.stringify(moved), { expirationTtl: INVITE_TTL });
|
||||
await kv.delete(indexKey(from));
|
||||
} catch (err) {
|
||||
log.warn({ err, from, to }, "Failed to migrate invites on group rename");
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { clientIp } from "./auth";
|
|||
import { recordAudit } from "../lib/audit";
|
||||
import { sendMessage } from "../drivers/telegram/rest";
|
||||
import { cfEnv } from "../cf";
|
||||
import { initConfigStore } from "../config";
|
||||
import type { Env, Group } from "../types";
|
||||
|
||||
interface PendingState {
|
||||
|
|
@ -130,6 +131,7 @@ function installPage(opts: {
|
|||
/** GET /auth/github — start the OAuth flow. */
|
||||
export async function handleOAuthStart(event: H3Event): Promise<void> {
|
||||
const env = cfEnv(event);
|
||||
initConfigStore(env);
|
||||
const query = getQuery(event);
|
||||
const redirectTo = safeRedirectPath(String(query["redirect"] ?? ""));
|
||||
const state = generateRandomHex(16);
|
||||
|
|
@ -144,6 +146,7 @@ export async function handleOAuthStart(event: H3Event): Promise<void> {
|
|||
/** GET /auth/github/install — post-install choice page. */
|
||||
export async function handleInstallPage(event: H3Event): Promise<string | void> {
|
||||
const env = cfEnv(event);
|
||||
initConfigStore(env);
|
||||
const query = getQuery(event);
|
||||
const rawId = String(query["installation_id"] ?? "");
|
||||
const installationId = Number(rawId);
|
||||
|
|
@ -171,6 +174,7 @@ export async function handleInstallPage(event: H3Event): Promise<string | void>
|
|||
/** POST /auth/github/install/bind — provision the chosen binding. */
|
||||
export async function handleInstallBind(event: H3Event): Promise<void> {
|
||||
const env = cfEnv(event);
|
||||
initConfigStore(env);
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) {
|
||||
await sendRedirect(event, "/admin?error=forbidden");
|
||||
|
|
@ -278,6 +282,7 @@ export async function handleInstallBind(event: H3Event): Promise<void> {
|
|||
/** GET /auth/github/callback — OAuth callback. */
|
||||
export async function handleOAuthCallback(event: H3Event): Promise<unknown> {
|
||||
const env = cfEnv(event);
|
||||
initConfigStore(env);
|
||||
const query = getQuery(event);
|
||||
const code = String(query["code"] ?? "");
|
||||
const state = String(query["state"] ?? "");
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { getHeader, readRawBody, setResponseStatus } from "h3";
|
|||
import type { Env } from "./types";
|
||||
import { detectProvider } from "./providers";
|
||||
import { dispatchEvent } from "./core/dispatch";
|
||||
import { loadConfig } from "./config";
|
||||
import { loadConfig, initConfigStore } from "./config";
|
||||
import { loadGroups, ensureInstallationGroup } from "./web/groups";
|
||||
import { getTenantSecret } from "./web/tenants";
|
||||
import { recordAudit } from "./lib/audit";
|
||||
import { cfEnv, cfWaitUntil, headersFrom } from "./cf";
|
||||
import { log } from "./lib/log";
|
||||
import { deliveryKey, kvIdempotencyStore } from "./lib/idempotency";
|
||||
import { deliveryKey, idempotencyStore } from "./lib/idempotency";
|
||||
import { newCorrelationId } from "./lib/correlation";
|
||||
import { enqueueWebhook, type DeliveryMessage } from "./queue/delivery";
|
||||
|
||||
|
|
@ -37,6 +37,7 @@ export async function processWebhook(
|
|||
): Promise<WebhookResult> {
|
||||
const requestId = newCorrelationId();
|
||||
let effectiveEnv = env;
|
||||
initConfigStore(env);
|
||||
const groups = await loadGroups(env.KV);
|
||||
if (tenantId) {
|
||||
if (!groups.some((g) => g.id === tenantId)) {
|
||||
|
|
@ -124,7 +125,7 @@ export async function processWebhook(
|
|||
// Dedup via the idempotency store: a provider/tenant-scoped key means
|
||||
// different accounts may reuse a delivery id without colliding, while
|
||||
// retries of the same delivery never dispatch twice.
|
||||
const store = kvIdempotencyStore(env.KV);
|
||||
const store = idempotencyStore(env.DB, env.KV);
|
||||
const key = deliveryKey(provider.id, tenantId, event.deliveryId);
|
||||
if (!(await store.claim(key, 120))) {
|
||||
return { status: 200, body: { ok: true, duplicate: true, requestId } };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue