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', {
|
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(),
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user