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:
RhenCloud 2026-08-17 15:01:20 +08:00
parent 2e1b0f022e
commit 25ebae4ae5
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
46 changed files with 1450 additions and 117 deletions

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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}`;
}
}

View file

@ -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);
}

View 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");
}
}

View file

@ -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);
}

View 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
View 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"
);
}

View 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);
},
};
}

View file

@ -24,6 +24,7 @@ export interface Env {
KV: KVNamespace;
DB: D1Database;
QUEUE?: Queue;
PAYLOAD?: R2Bucket;
}
export interface Config {

View file

@ -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);

View file

@ -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;
}

View file

@ -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");

View file

@ -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"] ?? "");

View file

@ -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 } };