feat(federation): outbound gate in ensurePeered with required intent argument

- New 'admin_required' EnsurePeeredResult variant.
- Required intent argument (no optional default) prevents future
  user-initiated callers from silently getting system behavior.
- Gate runs only when no peer row exists; toggling autoAccept later
  does not retroactively gate established peer rows.
- queueOutboundApproval helper upserts parent + subscriber rows and
  broadcasts to admins + user.
- Schema enum-narrows direction ('inbound' | 'outbound') and
  notifications.kind ('approved' | 'denied' | 'expired') at the column
  level; drizzle generate confirmed no migration delta.
- Existing call sites now fail typecheck — fixed in Task 5.
This commit is contained in:
Jannis Braun
2026-04-26 21:18:43 +02:00
parent 94c291c472
commit 50bc510e8d
2 changed files with 144 additions and 7 deletions
+2 -2
View File
@@ -385,7 +385,7 @@ export const federationPeers = sqliteTable('federation_peers', {
export const peerApprovalRequests = sqliteTable('peer_approval_requests', { export const peerApprovalRequests = sqliteTable('peer_approval_requests', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
origin: text('origin').notNull(), origin: text('origin').notNull(),
direction: text('direction').notNull().default('inbound'), direction: text('direction', { enum: ['inbound', 'outbound'] }).notNull().default('inbound'),
instanceName: text('instance_name'), instanceName: text('instance_name'),
hmacSecret: text('hmac_secret'), hmacSecret: text('hmac_secret'),
requestedAt: integer('requested_at').notNull(), requestedAt: integer('requested_at').notNull(),
@@ -410,7 +410,7 @@ export const peerApprovalSubscribers = sqliteTable('peer_approval_subscribers',
export const peerApprovalNotifications = sqliteTable('peer_approval_notifications', { export const peerApprovalNotifications = sqliteTable('peer_approval_notifications', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(), kind: text('kind', { enum: ['approved', 'denied', 'expired'] }).notNull(),
peerOrigin: text('peer_origin').notNull(), peerOrigin: text('peer_origin').notNull(),
triggerReason: text('trigger_reason').notNull(), triggerReason: text('trigger_reason').notNull(),
triggerTarget: text('trigger_target').notNull(), triggerTarget: text('trigger_target').notNull(),
+142 -5
View File
@@ -1,10 +1,11 @@
import { getDb } from '../db/index.js'; import { getDb } from '../db/index.js';
import * as schema from '../db/schema.js'; import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { generateSnowflake } from './snowflake.js'; import { generateSnowflake } from './snowflake.js';
import { getOurOrigin, generateHmacSecret } from './federationAuth.js'; import { getOurOrigin, generateHmacSecret } from './federationAuth.js';
import { validateOrigin } from '../routes/federation.js'; import { validateOrigin } from '../routes/federation.js';
import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js'; import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js';
import type { EnsurePeeredCallerIntent } from '@backspace/shared';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -12,7 +13,8 @@ export type EnsurePeeredResult =
| { status: 'active'; peerId: string } | { status: 'active'; peerId: string }
| { status: 'rejected'; error: string } | { status: 'rejected'; error: string }
| { status: 'failed'; error: string } | { status: 'failed'; error: string }
| { status: 'pending'; error: string }; | { status: 'pending'; error: string }
| { status: 'admin_required'; error: string };
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -26,6 +28,106 @@ function getInstanceName(): string | undefined {
return row?.name ?? undefined; return row?.name ?? undefined;
} }
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* When the outbound gate fires for a `user_action` intent, upsert the
* `peer_approval_requests` (parent, keyed on origin+direction='outbound')
* and `peer_approval_subscribers` (per-user, keyed on parent+user+reason+target)
* rows, then broadcast WS events so the admin queue and the user's pending
* list refresh. Idempotent: repeated calls for the same (origin, user, reason,
* target) refresh `created_at` rather than creating duplicate rows.
*
* NOTE: parent row is created with `hmac_secret = NULL`. The CHECK constraint
* permits this for `direction='outbound'`. The approve handler generates fresh
* HMAC at the moment it actually sends `/peer/accept` to the remote.
*/
async function queueOutboundApproval(
origin: string,
intent: Extract<EnsurePeeredCallerIntent, { kind: 'user_action' }>,
): Promise<void> {
const db = getDb();
const now = Date.now();
// Upsert parent row keyed on (origin, direction='outbound').
let parent = db
.select()
.from(schema.peerApprovalRequests)
.where(
and(
eq(schema.peerApprovalRequests.origin, origin),
eq(schema.peerApprovalRequests.direction, 'outbound'),
),
)
.get();
if (!parent) {
const id = generateSnowflake();
db.insert(schema.peerApprovalRequests)
.values({
id,
origin,
direction: 'outbound',
instanceName: null,
hmacSecret: null,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
approvalToken: null,
})
.run();
parent = db
.select()
.from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.id, id))
.get()!;
}
// Upsert subscriber row.
const existingSub = db
.select({ id: schema.peerApprovalSubscribers.id })
.from(schema.peerApprovalSubscribers)
.where(
and(
eq(schema.peerApprovalSubscribers.requestId, parent.id),
eq(schema.peerApprovalSubscribers.userId, intent.userId),
eq(schema.peerApprovalSubscribers.triggerReason, intent.reason),
eq(schema.peerApprovalSubscribers.triggerTarget, intent.target),
),
)
.get();
if (existingSub) {
db.update(schema.peerApprovalSubscribers)
.set({ createdAt: now })
.where(eq(schema.peerApprovalSubscribers.id, existingSub.id))
.run();
} else {
db.insert(schema.peerApprovalSubscribers)
.values({
id: generateSnowflake(),
requestId: parent.id,
userId: intent.userId,
triggerReason: intent.reason,
triggerTarget: intent.target,
createdAt: now,
})
.run();
}
// Broadcast: admins refresh queue; the calling user refreshes pending list.
// Dynamic import is the existing circular-dep workaround in this file
// (see ws/handler.js imports later). Keep it consistent.
const { connectionManager } = await import('../ws/handler.js');
connectionManager.sendToAdmins({
type: 'federation_approval_request_received' as const,
origin,
instanceName: undefined,
});
connectionManager.sendToUser(intent.userId, {
type: 'peering_subscription_changed' as const,
});
}
// ─── In-flight deduplication ───────────────────────────────────────────────── // ─── In-flight deduplication ─────────────────────────────────────────────────
const inFlightPeering = new Map<string, Promise<EnsurePeeredResult>>(); const inFlightPeering = new Map<string, Promise<EnsurePeeredResult>>();
@@ -40,7 +142,10 @@ const inFlightPeering = new Map<string, Promise<EnsurePeeredResult>>();
* - { status: 'rejected', error } — remote rejected auto-peering, or peer was revoked * - { status: 'rejected', error } — remote rejected auto-peering, or peer was revoked
* - { status: 'failed', error } — transient error (network, timeout), will retry * - { status: 'failed', error } — transient error (network, timeout), will retry
*/ */
export async function ensurePeered(origin: string): Promise<EnsurePeeredResult> { export async function ensurePeered(
origin: string,
intent: EnsurePeeredCallerIntent,
): Promise<EnsurePeeredResult> {
// Validate origin format // Validate origin format
const normalized = validateOrigin(origin); const normalized = validateOrigin(origin);
if (!normalized) { if (!normalized) {
@@ -106,6 +211,34 @@ export async function ensurePeered(origin: string): Promise<EnsurePeeredResult>
}; };
} }
// Outbound gate: when autoAcceptPeering=0, regular-user-initiated outbound
// becomes admin-approvable; system-initiated outbound is refused outright.
// Runs only when no peer row exists (existing rows already passed the gate
// when first created — toggling autoAccept later doesn't retroactively gate).
if (!existing) {
const settings = db
.select({ autoAcceptPeering: schema.instanceSettings.autoAcceptPeering })
.from(schema.instanceSettings)
.where(eq(schema.instanceSettings.id, 1))
.get();
const autoAccept = settings?.autoAcceptPeering ?? 1;
if (autoAccept === 0) {
if (intent.kind === 'user_action') {
await queueOutboundApproval(normalized, intent);
return {
status: 'admin_required',
error: 'Awaiting your admin\'s approval to initiate peering',
};
}
// system intent — refuse without queue
return {
status: 'admin_required',
error: 'Outbound peering requires admin approval on this instance',
};
}
}
// Deduplicate: if a handshake is already in flight, share the promise // Deduplicate: if a handshake is already in flight, share the promise
const inflight = inFlightPeering.get(normalized); const inflight = inFlightPeering.get(normalized);
if (inflight) { if (inflight) {
@@ -280,9 +413,13 @@ export function _clearInFlightPeering(): void {
export async function racePeering( export async function racePeering(
origin: string, origin: string,
timeoutMs: number, timeoutMs: number,
ensurePeeredFn: (origin: string) => Promise<EnsurePeeredResult> = ensurePeered, intent: EnsurePeeredCallerIntent,
ensurePeeredFn: (
origin: string,
intent: EnsurePeeredCallerIntent,
) => Promise<EnsurePeeredResult> = ensurePeered,
): Promise<EnsurePeeredResult | { status: 'timeout' }> { ): Promise<EnsurePeeredResult | { status: 'timeout' }> {
const handshake = ensurePeeredFn(origin); const handshake = ensurePeeredFn(origin, intent);
let timeoutHandle: ReturnType<typeof setTimeout> | undefined; let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => { const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => {