feat(door): P2 connections + SSRF-safe executor + native adapter + audit
Migration 035 adds door_connections (per-space controller integration: secret_ref -> AES-256-GCM secrets vault, never a plaintext password; pinned_host SSRF pin; native_heatsync|generic_http adapter; allow_member_self_entry opt-in for phase 3) and door_access_log (append-only, secrets redacted, service-client-only writes; immutable). RLS additive/default-deny: connections CRUD = door.manage; log SELECT = door.manage/operate. Safety core is pure and heavily unit-tested (door-logic, suite 409): validateDoorTarget (request host must equal the pinned host; metadata/link-local always blocked; http(s) only), the firmware-verified HeatSync encoders with fixed-width zero-padding (slot 3 / perm 3 / tag 8 / 4-char password), generic template substitution, and redactDoorSecrets. lib/door/executor.ts is the single hardened egress: re-validates the pin, refuses redirects, caps time and body, redacts before any audit write. The door password is read from the secrets vault and decrypted server-side only, never returned or logged. /door/manage (door.manage) configures connections, picks a vault secret, and runs a safe status-only test. Sidebar Admin link added; "Learn" moved before "People" per request. Docs updated.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import {
|
||||
requireMember,
|
||||
logActivity,
|
||||
parseInput,
|
||||
type Member,
|
||||
type ServerSupabase,
|
||||
} from '@/lib/auth-helpers'
|
||||
import {
|
||||
createDoorConnectionSchema,
|
||||
updateDoorConnectionSchema,
|
||||
doorConnectionIdSchema,
|
||||
} from '@/lib/validations'
|
||||
import {
|
||||
encodeHeatSyncControl,
|
||||
applyTemplate,
|
||||
redactDoorSecrets,
|
||||
} from '@/lib/door-logic'
|
||||
import { callDoor } from '@/lib/door/executor'
|
||||
import { decryptSecret } from '@/lib/secrets/crypto'
|
||||
|
||||
type Gate =
|
||||
| { ok: true; supabase: ServerSupabase; member: Member }
|
||||
| { ok: false; error: string }
|
||||
|
||||
async function requireDoorManager(): Promise<Gate> {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { ok: false, error: auth.error }
|
||||
const { member } = auth
|
||||
const { data: allowed, error } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm: 'door.manage',
|
||||
})
|
||||
if (error) return { ok: false, error: error.message }
|
||||
if (!allowed) return { ok: false, error: 'You do not have permission to manage door connections' }
|
||||
return { ok: true, supabase, member }
|
||||
}
|
||||
|
||||
// Load + decrypt a connection's door password from the encrypted secrets
|
||||
// vault. Service client: a door manager may legitimately not hold
|
||||
// ops.secrets.read, and the plaintext must never reach the browser. Returns
|
||||
// null when no secret is referenced (auth_mode 'none').
|
||||
async function resolveSecret(
|
||||
admin: ReturnType<typeof createAdminClient>,
|
||||
spaceId: string,
|
||||
secretRef: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!secretRef) return null
|
||||
const { data } = await admin
|
||||
.from('secrets')
|
||||
.select('encryption_version, encrypted_value, value')
|
||||
.eq('id', secretRef)
|
||||
.eq('space_id', spaceId)
|
||||
.maybeSingle()
|
||||
if (!data) return null
|
||||
if (data.encryption_version === 1 && data.encrypted_value) {
|
||||
const raw = data.encrypted_value as unknown
|
||||
const buf =
|
||||
typeof raw === 'string'
|
||||
? Buffer.from((raw as string).replace(/^\\x/, ''), 'hex')
|
||||
: Buffer.from(raw as Uint8Array)
|
||||
try {
|
||||
return decryptSecret(buf, 1)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return (data.value as string | null) ?? null
|
||||
}
|
||||
|
||||
function isUniqueViolation(message: string): boolean {
|
||||
return /duplicate key value|already exists|unique constraint/i.test(message)
|
||||
}
|
||||
|
||||
// ─── Connection CRUD (door.manage) ───────────────────────────────────────────
|
||||
|
||||
export async function createDoorConnection(input: unknown) {
|
||||
const gate = await requireDoorManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(createDoorConnectionSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const c = v.data
|
||||
|
||||
if (c.secret_ref) {
|
||||
const admin = createAdminClient()
|
||||
const { data: sec } = await admin
|
||||
.from('secrets')
|
||||
.select('id')
|
||||
.eq('id', c.secret_ref)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!sec) return { error: 'The referenced secret was not found in this space.' }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('door_connections')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
name: c.name,
|
||||
adapter: c.adapter,
|
||||
base_url: c.base_url,
|
||||
pinned_host: c.pinned_host,
|
||||
auth_mode: c.auth_mode,
|
||||
auth_param: c.auth_param ?? null,
|
||||
secret_ref: c.secret_ref ?? null,
|
||||
verbs: c.verbs,
|
||||
allow_member_self_entry: c.allow_member_self_entry,
|
||||
is_enabled: c.is_enabled,
|
||||
created_by: member.id,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (error) {
|
||||
if (isUniqueViolation(error.message)) return { error: 'A connection with that name already exists.' }
|
||||
return { error: error.message }
|
||||
}
|
||||
|
||||
await logActivity(supabase, member, 'created', 'door_connection', data.id as string, c.name)
|
||||
revalidatePath('/door/manage')
|
||||
return { data: { id: data.id as string } }
|
||||
}
|
||||
|
||||
export async function updateDoorConnection(input: unknown) {
|
||||
const gate = await requireDoorManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(updateDoorConnectionSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const u = v.data
|
||||
|
||||
if (u.secret_ref) {
|
||||
const admin = createAdminClient()
|
||||
const { data: sec } = await admin
|
||||
.from('secrets')
|
||||
.select('id')
|
||||
.eq('id', u.secret_ref)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!sec) return { error: 'The referenced secret was not found in this space.' }
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {}
|
||||
for (const k of [
|
||||
'name', 'adapter', 'base_url', 'pinned_host', 'auth_mode',
|
||||
'auth_param', 'secret_ref', 'verbs', 'allow_member_self_entry', 'is_enabled',
|
||||
] as const) {
|
||||
if (u[k] !== undefined) patch[k] = u[k] === undefined ? null : u[k] ?? null
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return { data: { id: u.connectionId } }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('door_connections')
|
||||
.update(patch)
|
||||
.eq('id', u.connectionId)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'updated', 'door_connection', u.connectionId)
|
||||
revalidatePath('/door/manage')
|
||||
return { data: { id: u.connectionId } }
|
||||
}
|
||||
|
||||
export async function deleteDoorConnection(input: unknown) {
|
||||
const gate = await requireDoorManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(doorConnectionIdSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('door_connections')
|
||||
.delete()
|
||||
.eq('id', v.data.connectionId)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'deleted', 'door_connection', v.data.connectionId)
|
||||
revalidatePath('/door/manage')
|
||||
return { data: { id: v.data.connectionId } }
|
||||
}
|
||||
|
||||
export async function listDoorConnections() {
|
||||
const gate = await requireDoorManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('door_connections')
|
||||
.select('id, name, adapter, base_url, pinned_host, auth_mode, auth_param, secret_ref, verbs, allow_member_self_entry, is_enabled, updated_at')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('name', { ascending: true })
|
||||
if (error) return { error: error.message }
|
||||
return { data: data ?? [] }
|
||||
}
|
||||
|
||||
// Available secrets (titles only) so the admin can pick one for a connection
|
||||
// without the values ever being exposed here.
|
||||
export async function listSecretTitles() {
|
||||
const gate = await requireDoorManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { member } = gate
|
||||
const admin = createAdminClient()
|
||||
const { data, error } = await admin
|
||||
.from('secrets')
|
||||
.select('id, title')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('title', { ascending: true })
|
||||
if (error) return { error: error.message }
|
||||
return { data: (data ?? []).map(s => ({ id: s.id as string, title: s.title as string })) }
|
||||
}
|
||||
|
||||
// Safe connectivity check: runs the 'status' verb only. Never opens a door.
|
||||
// Writes one redacted audit row.
|
||||
export async function testDoorConnection(input: unknown) {
|
||||
const gate = await requireDoorManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(doorConnectionIdSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const { data: conn } = await supabase
|
||||
.from('door_connections')
|
||||
.select('id, space_id, adapter, base_url, pinned_host, secret_ref, verbs, is_enabled')
|
||||
.eq('id', v.data.connectionId)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!conn) return { error: 'Connection not found' }
|
||||
if (!conn.is_enabled) return { error: 'This connection is disabled.' }
|
||||
|
||||
const admin = createAdminClient()
|
||||
const password = await resolveSecret(admin, member.space_id, conn.secret_ref as string | null)
|
||||
|
||||
let query: string
|
||||
if (conn.adapter === 'native_heatsync') {
|
||||
const enc = encodeHeatSyncControl('status', password ?? '')
|
||||
if (!enc.ok) return { error: enc.reason }
|
||||
query = enc.query
|
||||
} else {
|
||||
const tmpl = (conn.verbs as Record<string, string> | null)?.status
|
||||
if (!tmpl) return { error: 'This connection has no "status" verb template configured.' }
|
||||
query = applyTemplate(tmpl, { pw: password ?? '' })
|
||||
}
|
||||
|
||||
const result = await callDoor({
|
||||
url: (conn.base_url as string) + query,
|
||||
pinnedHost: conn.pinned_host as string,
|
||||
password,
|
||||
})
|
||||
|
||||
const detail = result.ok
|
||||
? redactDoorSecrets(`status ${result.status}: ${result.snippet}`.slice(0, 1000), password)
|
||||
: redactDoorSecrets(`failed: ${result.reason}`.slice(0, 1000), password)
|
||||
|
||||
await admin.from('door_access_log').insert({
|
||||
space_id: member.space_id,
|
||||
connection_id: conn.id,
|
||||
actor_member_id: member.id,
|
||||
action: 'test',
|
||||
success: result.ok,
|
||||
detail,
|
||||
})
|
||||
|
||||
revalidatePath('/door/manage')
|
||||
return result.ok
|
||||
? { data: { ok: true, status: result.status } }
|
||||
: { error: result.reason }
|
||||
}
|
||||
|
||||
export async function listDoorAccessLog() {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data: canManage } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm: 'door.manage',
|
||||
})
|
||||
let permitted = !!canManage
|
||||
if (!permitted) {
|
||||
const { data: canOp } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm: 'door.operate',
|
||||
})
|
||||
permitted = !!canOp
|
||||
}
|
||||
if (!permitted) return { error: 'You do not have permission to view the access log' }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('door_access_log')
|
||||
.select('id, connection_id, action, success, detail, occurred_at')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('occurred_at', { ascending: false })
|
||||
.limit(200)
|
||||
if (error) return { error: error.message }
|
||||
return { data: data ?? [] }
|
||||
}
|
||||
@@ -59,3 +59,6 @@ export * from './equipment'
|
||||
|
||||
// Member access cards (Door epic)
|
||||
export * from './member-cards'
|
||||
|
||||
// Door connections + executor (Door epic)
|
||||
export * from './door'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
// Server-component guard for the /door/manage surface. Mirrors the other
|
||||
// module guards: resolve the member, check door.manage via the same RPC the
|
||||
// RLS uses, redirect non-managers before any data is fetched. The RLS
|
||||
// independently enforces this.
|
||||
export async function requireDoorManagerPage() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, space_id, role, user_id, display_name')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
if (!member) redirect('/login')
|
||||
|
||||
const { data: allowed } = await supabase.rpc('user_has_permission', {
|
||||
uid: user.id,
|
||||
sid: member.space_id,
|
||||
perm: 'door.manage',
|
||||
})
|
||||
if (!allowed) redirect('/dashboard')
|
||||
|
||||
return { supabase, member }
|
||||
}
|
||||
@@ -12,3 +12,147 @@ export function maskCardUid(uid: string): string {
|
||||
if (uid.length <= 4) return '•'.repeat(uid.length || 4)
|
||||
return '•'.repeat(uid.length - 4) + uid.slice(-4)
|
||||
}
|
||||
|
||||
// ─── SSRF guard (the dangerous part) ─────────────────────────────────────────
|
||||
// A door connection deliberately targets a LAN device, so private ranges are
|
||||
// allowed — but ONLY the exact host the admin pinned, and never the cloud
|
||||
// metadata / link-local range. The executor additionally forbids redirects
|
||||
// and caps response size/time; this pure function decides if a target URL is
|
||||
// allowed to be called at all.
|
||||
|
||||
// Always-blocked, regardless of pin: IMDS / link-local and unspecified.
|
||||
export function isAlwaysBlockedHost(host: string): boolean {
|
||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '')
|
||||
if (h === '169.254.169.254' || h === 'metadata' || h === 'metadata.google.internal') return true
|
||||
if (h.startsWith('169.254.')) return true // IPv4 link-local (includes IMDS)
|
||||
if (h === '0.0.0.0' || h === '::' || h === '0:0:0:0:0:0:0:0') return true
|
||||
if (h === 'fd00:ec2::254') return true // AWS IPv6 IMDS
|
||||
if (h.startsWith('fe80:')) return true // IPv6 link-local
|
||||
return false
|
||||
}
|
||||
|
||||
export type DoorTarget =
|
||||
| { ok: true; url: string; host: string }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
// Validate a fully-formed request URL against the connection's pinned host.
|
||||
// pinnedHost is exactly what the admin entered (hostname or IP, optional
|
||||
// :port is compared on the host only). The request host must equal the pin
|
||||
// (case-insensitive) and must not be an always-blocked address.
|
||||
export function validateDoorTarget(rawUrl: string, pinnedHost: string): DoorTarget {
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(rawUrl)
|
||||
} catch {
|
||||
return { ok: false, reason: 'The request URL is not a valid URL.' }
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
return { ok: false, reason: 'Only http and https are allowed.' }
|
||||
}
|
||||
const host = u.hostname.toLowerCase()
|
||||
const pin = pinnedHost.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0]
|
||||
if (!pin) return { ok: false, reason: 'This connection has no pinned host.' }
|
||||
if (isAlwaysBlockedHost(host)) {
|
||||
return { ok: false, reason: 'That host is blocked (link-local / metadata).' }
|
||||
}
|
||||
if (host !== pin) {
|
||||
return { ok: false, reason: `Request host "${host}" does not match the pinned host "${pin}".` }
|
||||
}
|
||||
return { ok: true, url: u.toString(), host }
|
||||
}
|
||||
|
||||
// ─── HeatSync native adapter encoders ────────────────────────────────────────
|
||||
// Firmware (zyphlar/Open_Access_Control_Ethernet.ino, re-verified 2026-05-17)
|
||||
// parses FIXED-WIDTH ZERO-PADDED query params by byte offset: user slot = 3
|
||||
// digits, perm mask = 3 digits, tag = 8 hex chars, password = the shared
|
||||
// secret. Auth is satisfied when "?e=" or "&e=" is present, so every
|
||||
// privileged verb appends "&e=<pw>". Getting the widths wrong addresses the
|
||||
// WRONG slot — so these are pure and unit-tested.
|
||||
|
||||
function pad(n: number, width: number): string {
|
||||
return String(n).padStart(width, '0')
|
||||
}
|
||||
|
||||
export type EncodeResult = { ok: true; query: string } | { ok: false; reason: string }
|
||||
|
||||
function checkSlot(slot: number): string | null {
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > 200) return 'Card slot must be an integer 0-200.'
|
||||
return null
|
||||
}
|
||||
|
||||
export function encodeHeatSyncGrant(input: {
|
||||
slot: number
|
||||
permissionMask: number
|
||||
tagHex: string
|
||||
password: string
|
||||
}): EncodeResult {
|
||||
const slotErr = checkSlot(input.slot)
|
||||
if (slotErr) return { ok: false, reason: slotErr }
|
||||
if (!Number.isInteger(input.permissionMask) || input.permissionMask < 0 || input.permissionMask > 255) {
|
||||
return { ok: false, reason: 'Permission mask must be an integer 0-255.' }
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{1,8}$/.test(input.tagHex)) {
|
||||
return { ok: false, reason: 'Tag must be 1-8 hexadecimal characters.' }
|
||||
}
|
||||
if (input.password.length < 1 || input.password.length > 16) {
|
||||
return { ok: false, reason: 'Door password is missing or implausible.' }
|
||||
}
|
||||
const tag = input.tagHex.toLowerCase().padStart(8, '0')
|
||||
return {
|
||||
ok: true,
|
||||
query: `?m${pad(input.slot, 3)}&p${pad(input.permissionMask, 3)}&t${tag}&e=${input.password}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeHeatSyncRevoke(slot: number, password: string): EncodeResult {
|
||||
const slotErr = checkSlot(slot)
|
||||
if (slotErr) return { ok: false, reason: slotErr }
|
||||
if (!password) return { ok: false, reason: 'Door password is missing.' }
|
||||
return { ok: true, query: `?r${pad(slot, 3)}&e=${password}` }
|
||||
}
|
||||
|
||||
// verb: 'open1' | 'open2' | 'unlock' | 'lock' | 'arm' | 'disarm' | 'status' | 'log'
|
||||
export function encodeHeatSyncControl(verb: string, password: string): EncodeResult {
|
||||
if (!password) return { ok: false, reason: 'Door password is missing.' }
|
||||
const map: Record<string, string> = {
|
||||
open1: '?o1',
|
||||
open2: '?o2',
|
||||
unlock: '?u',
|
||||
lock: '?l',
|
||||
disarm: '?1',
|
||||
arm: '?2',
|
||||
status: '?9',
|
||||
log: '?z',
|
||||
}
|
||||
const base = map[verb]
|
||||
if (!base) return { ok: false, reason: `Unknown door verb "${verb}".` }
|
||||
return { ok: true, query: `${base}&e=${password}` }
|
||||
}
|
||||
|
||||
// ─── Generic adapter template substitution ───────────────────────────────────
|
||||
// A generic connection stores a path/query template per verb with {slot},
|
||||
// {tag}, {perm}, {door}, {pw} placeholders. Substitution is literal and the
|
||||
// values are URL-encoded; unknown placeholders are left untouched so a
|
||||
// misconfig fails loudly rather than silently calling something unintended.
|
||||
|
||||
export function applyTemplate(
|
||||
template: string,
|
||||
vars: Record<string, string | number>,
|
||||
): string {
|
||||
return template.replace(/\{(\w+)\}/g, (m, key) =>
|
||||
key in vars ? encodeURIComponent(String(vars[key])) : m,
|
||||
)
|
||||
}
|
||||
|
||||
// Before anything (URL, response snippet) is written to the audit log, scrub
|
||||
// the shared door password: any literal occurrence, and any e=/&e= query
|
||||
// value (the firmware auth param), become "<redacted>". The password is a
|
||||
// credential and must never land in door_access_log.
|
||||
export function redactDoorSecrets(text: string, password?: string | null): string {
|
||||
let out = text
|
||||
if (password && password.length > 0) {
|
||||
out = out.split(password).join('<redacted>')
|
||||
}
|
||||
out = out.replace(/([?&](?:e|pw|password|api[_-]?key|token)=)[^&\s"']*/gi, '$1<redacted>')
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Server-only. The single hardened egress point for door-controller HTTP.
|
||||
// Every door call funnels through here so the SSRF guard, no-redirect rule,
|
||||
// timeout, response cap and secret redaction cannot be bypassed. The secret
|
||||
// is already embedded in `url` by the caller (per the verified firmware,
|
||||
// auth is a query param); it is never logged -- only a redacted snippet is
|
||||
// ever returned.
|
||||
|
||||
import { validateDoorTarget, redactDoorSecrets } from '@/lib/door-logic'
|
||||
|
||||
export type DoorCallResult =
|
||||
| { ok: true; status: number; snippet: string }
|
||||
| { ok: false; reason: string; status?: number }
|
||||
|
||||
const MAX_BODY = 4096
|
||||
const SNIPPET = 500
|
||||
|
||||
export async function callDoor(opts: {
|
||||
url: string
|
||||
pinnedHost: string
|
||||
password?: string | null
|
||||
timeoutMs?: number
|
||||
}): Promise<DoorCallResult> {
|
||||
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
|
||||
if (!guard.ok) return { ok: false, reason: guard.reason }
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
|
||||
try {
|
||||
const res = await fetch(guard.url, {
|
||||
method: 'GET',
|
||||
// Never chase a redirect: a 3xx could bounce us off the pinned host.
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'text/plain, text/html' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'The controller returned a redirect, which is refused (anti-SSRF).',
|
||||
status: res.status || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let received = ''
|
||||
const reader = res.body?.getReader()
|
||||
if (reader) {
|
||||
const dec = new TextDecoder()
|
||||
while (received.length < MAX_BODY) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
received += dec.decode(value, { stream: true })
|
||||
}
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password)
|
||||
return { ok: res.ok, status: res.status, snippet }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'request failed'
|
||||
return { ok: false, reason: redactDoorSecrets(msg, opts.password) }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -903,6 +903,49 @@ export const listMemberCardsSchema = z.object({
|
||||
memberId: z.string().uuid('Invalid member ID'),
|
||||
})
|
||||
|
||||
// ─── Door connections (Door epic P2) ─────────────────────────────────────────
|
||||
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.regex(/^https?:\/\/.+/i, 'Must be an http(s) URL')
|
||||
|
||||
const doorAdapter = z.enum(['native_heatsync', 'generic_http'])
|
||||
const doorAuthMode = z.enum(['none', 'query', 'header', 'bearer'])
|
||||
|
||||
export const createDoorConnectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(200),
|
||||
adapter: doorAdapter.optional().default('generic_http'),
|
||||
base_url: httpUrl,
|
||||
pinned_host: z.string().min(1, 'A pinned host is required').max(255),
|
||||
auth_mode: doorAuthMode.optional().default('none'),
|
||||
auth_param: z.string().max(120).optional().nullable(),
|
||||
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
|
||||
// generic adapter per-verb templates (validated as a flat string map)
|
||||
verbs: z.record(z.string().max(2000)).optional().default({}),
|
||||
allow_member_self_entry: z.boolean().optional().default(false),
|
||||
is_enabled: z.boolean().optional().default(true),
|
||||
})
|
||||
|
||||
export const updateDoorConnectionSchema = z.object({
|
||||
connectionId: z.string().uuid('Invalid connection ID'),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
adapter: doorAdapter.optional(),
|
||||
base_url: httpUrl.optional(),
|
||||
pinned_host: z.string().min(1).max(255).optional(),
|
||||
auth_mode: doorAuthMode.optional(),
|
||||
auth_param: z.string().max(120).optional().nullable(),
|
||||
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
|
||||
verbs: z.record(z.string().max(2000)).optional(),
|
||||
allow_member_self_entry: z.boolean().optional(),
|
||||
is_enabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const doorConnectionIdSchema = z.object({
|
||||
connectionId: z.string().uuid('Invalid connection ID'),
|
||||
})
|
||||
|
||||
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
||||
|
||||
export const uuidSchema = z.string().uuid('Invalid ID format')
|
||||
|
||||
Reference in New Issue
Block a user