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:
@@ -385,7 +385,7 @@ export const federationPeers = sqliteTable('federation_peers', {
|
||||
export const peerApprovalRequests = sqliteTable('peer_approval_requests', {
|
||||
id: text('id').primaryKey(),
|
||||
origin: text('origin').notNull(),
|
||||
direction: text('direction').notNull().default('inbound'),
|
||||
direction: text('direction', { enum: ['inbound', 'outbound'] }).notNull().default('inbound'),
|
||||
instanceName: text('instance_name'),
|
||||
hmacSecret: text('hmac_secret'),
|
||||
requestedAt: integer('requested_at').notNull(),
|
||||
@@ -410,7 +410,7 @@ export const peerApprovalSubscribers = sqliteTable('peer_approval_subscribers',
|
||||
export const peerApprovalNotifications = sqliteTable('peer_approval_notifications', {
|
||||
id: text('id').primaryKey(),
|
||||
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(),
|
||||
triggerReason: text('trigger_reason').notNull(),
|
||||
triggerTarget: text('trigger_target').notNull(),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { getDb } from '../db/index.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 { getOurOrigin, generateHmacSecret } from './federationAuth.js';
|
||||
import { validateOrigin } from '../routes/federation.js';
|
||||
import { onPeerActivated, onPeerDeactivated } from './federationPeerActivation.js';
|
||||
import type { EnsurePeeredCallerIntent } from '@backspace/shared';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +13,8 @@ export type EnsurePeeredResult =
|
||||
| { status: 'active'; peerId: string }
|
||||
| { status: 'rejected'; error: string }
|
||||
| { status: 'failed'; error: string }
|
||||
| { status: 'pending'; error: string };
|
||||
| { status: 'pending'; error: string }
|
||||
| { status: 'admin_required'; error: string };
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +28,106 @@ function getInstanceName(): string | 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 ─────────────────────────────────────────────────
|
||||
|
||||
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: '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
|
||||
const normalized = validateOrigin(origin);
|
||||
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
|
||||
const inflight = inFlightPeering.get(normalized);
|
||||
if (inflight) {
|
||||
@@ -280,9 +413,13 @@ export function _clearInFlightPeering(): void {
|
||||
export async function racePeering(
|
||||
origin: string,
|
||||
timeoutMs: number,
|
||||
ensurePeeredFn: (origin: string) => Promise<EnsurePeeredResult> = ensurePeered,
|
||||
intent: EnsurePeeredCallerIntent,
|
||||
ensurePeeredFn: (
|
||||
origin: string,
|
||||
intent: EnsurePeeredCallerIntent,
|
||||
) => Promise<EnsurePeeredResult> = ensurePeered,
|
||||
): Promise<EnsurePeeredResult | { status: 'timeout' }> {
|
||||
const handshake = ensurePeeredFn(origin);
|
||||
const handshake = ensurePeeredFn(origin, intent);
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => {
|
||||
|
||||
Reference in New Issue
Block a user