Harden archive bot for production; exempt container egress in edge firewall
- archive_sync_state keyed by room per migration 029 (drop synthetic id cursor) - putStaging buffers the stream and sends content-length (oCIS rejects chunked) - mimeClass passthrough from sniffed payload - edge-firewall: RETURN docker-bridge ingress before the CF-range catch-all so container egress (Matrix/R2/platform) survives the 80/443 source filter - files scaffold: keep the ocdav data-gateway hop on-loopback
This commit is contained in:
parent
23bd4dd5ad
commit
2cb77ae6b4
9 changed files with 211 additions and 38 deletions
|
|
@ -28,10 +28,20 @@ export interface ArchiveRecord {
|
|||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor rows are keyed by room (029: archive_sync_state(room_id PRIMARY KEY,
|
||||
* workspace_id NOT NULL, ...)), so the store needs the room → tenant pin the
|
||||
* config mappings already carry. RoomMapping satisfies this structurally.
|
||||
*/
|
||||
export interface RoomCursorTarget {
|
||||
roomId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/** Durable state the processor depends on; faked in tests. */
|
||||
export interface ArchiveStore {
|
||||
loadCursor(workspaceIds: readonly string[]): Promise<string | null>;
|
||||
saveCursor(workspaceIds: readonly string[], cursor: string): Promise<void>;
|
||||
loadCursor(rooms: readonly RoomCursorTarget[]): Promise<string | null>;
|
||||
saveCursor(rooms: readonly RoomCursorTarget[], cursor: string): Promise<void>;
|
||||
claim(workspaceId: string, roomId: string, eventId: string, claimKey: string): Promise<OutboxRow>;
|
||||
/** Index row + outbox done in one transaction. Returns the archive row id and indexed time. */
|
||||
commitArchived(record: ArchiveRecord, outboxId: string): Promise<{ id: string; indexedAt: Date }>;
|
||||
|
|
@ -57,14 +67,11 @@ const OUTBOX_COLUMNS = "id, status, attempts, last_error, updated_at";
|
|||
const LAST_ERROR_MAX = 1000;
|
||||
|
||||
export class PgArchiveStore implements ArchiveStore {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly cursorId: string,
|
||||
) {}
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/** Single service connection; tenant context per transaction, like apps/api withTenant. */
|
||||
static create(databaseUrl: string, cursorId: string): PgArchiveStore {
|
||||
return new PgArchiveStore(new Pool({ connectionString: databaseUrl, max: 1 }), cursorId);
|
||||
static create(databaseUrl: string): PgArchiveStore {
|
||||
return new PgArchiveStore(new Pool({ connectionString: databaseUrl, max: 1 }));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
|
@ -72,17 +79,17 @@ export class PgArchiveStore implements ArchiveStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* The sync stream is global but rows are tenant-scoped, so each mapped
|
||||
* workspace holds a copy. A partial write leaves copies disagreeing: resume
|
||||
* from the least recently written (oldest) one — replay is idempotent.
|
||||
* The sync stream is global but cursor rows are per room, so each mapped room
|
||||
* holds a copy. A partial write leaves copies disagreeing: resume from the
|
||||
* least recently written (oldest) one — replay is idempotent.
|
||||
*/
|
||||
async loadCursor(workspaceIds: readonly string[]): Promise<string | null> {
|
||||
async loadCursor(rooms: readonly RoomCursorTarget[]): Promise<string | null> {
|
||||
let oldest: { cursor: string; at: Date } | null = null;
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const row = await this.withTenant(workspaceId, async (c) => {
|
||||
for (const room of rooms) {
|
||||
const row = await this.withTenant(room.workspaceId, async (c) => {
|
||||
const res = await c.query<{ sync_cursor: string | null; updated_at: Date }>(
|
||||
"SELECT sync_cursor, updated_at FROM archive_sync_state WHERE id = $1 AND workspace_id = $2",
|
||||
[this.stateId(workspaceId), workspaceId],
|
||||
"SELECT sync_cursor, updated_at FROM archive_sync_state WHERE room_id = $1 AND workspace_id = $2",
|
||||
[room.roomId, room.workspaceId],
|
||||
);
|
||||
return res.rows[0];
|
||||
});
|
||||
|
|
@ -92,13 +99,13 @@ export class PgArchiveStore implements ArchiveStore {
|
|||
return oldest?.cursor ?? null;
|
||||
}
|
||||
|
||||
async saveCursor(workspaceIds: readonly string[], cursor: string): Promise<void> {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await this.withTenant(workspaceId, (c) => c.query(
|
||||
`INSERT INTO archive_sync_state (id, workspace_id, sync_cursor, updated_at)
|
||||
async saveCursor(rooms: readonly RoomCursorTarget[], cursor: string): Promise<void> {
|
||||
for (const room of rooms) {
|
||||
await this.withTenant(room.workspaceId, (c) => c.query(
|
||||
`INSERT INTO archive_sync_state (room_id, workspace_id, sync_cursor, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (id) DO UPDATE SET sync_cursor = EXCLUDED.sync_cursor, updated_at = now()`,
|
||||
[this.stateId(workspaceId), workspaceId, cursor],
|
||||
ON CONFLICT (room_id) DO UPDATE SET sync_cursor = EXCLUDED.sync_cursor, updated_at = now()`,
|
||||
[room.roomId, room.workspaceId, cursor],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -201,10 +208,6 @@ export class PgArchiveStore implements ArchiveStore {
|
|||
return total;
|
||||
}
|
||||
|
||||
private stateId(workspaceId: string): string {
|
||||
return `${this.cursorId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
private async withTenant<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue