Door epic phases 4-5: inbound access-log ingest + universal API-call buttons
Phase 4 (migration 053): pull entry/denied events into door_access_log and match each card to a member, via two transports sharing one ingest core. Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in native-HeatSync connection's ?z log through the existing hardened executor and parses it (firmware-characterized, best-effort ring buffer). Webhook: /api/door/inbound/[connection] accepts pushed event JSON authenticated by a per-connection bearer secret from the vault. Both dedupe on (connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an inbound config panel to /door/manage. Phase 5 (migration 054): admins define api_buttons that fire a configured HTTP call (full verbs + headers + body) when an authorized member presses them, through the same hardened egress (now shared between callDoor and the new callApi). Each button carries a required_permission (default the new apicall.invoke code); members see only buttons they may press. Builder at /door/buttons with a door-template preset; members press from /doors. Every press is recorded in api_call_log with secrets redacted. Both subsystems are inert until an admin enables inbound on a connection or creates a button. 624 unit + 50 integration tests; migrations applied top-to-bottom in schema.sql; types and docs updated.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
'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 {
|
||||
createApiButtonSchema,
|
||||
updateApiButtonSchema,
|
||||
apiButtonIdSchema,
|
||||
} from '@/lib/validations'
|
||||
import { redactDoorSecrets } from '@/lib/door-logic'
|
||||
import { callApi } from '@/lib/door/executor'
|
||||
import { resolveDoorSecret } from '@/lib/door/secret'
|
||||
import { checkRateLimit } from '@/lib/security'
|
||||
|
||||
type Gate =
|
||||
| { ok: true; supabase: ServerSupabase; member: Member }
|
||||
| { ok: false; error: string }
|
||||
|
||||
// Managing API buttons reuses door.manage (the permission catalog already
|
||||
// scopes door.manage to "door integrations, buttons, and member cards").
|
||||
async function requireButtonManager(): 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 API buttons' }
|
||||
return { ok: true, supabase, member }
|
||||
}
|
||||
|
||||
function isUniqueViolation(message: string): boolean {
|
||||
return /duplicate key value|already exists|unique constraint/i.test(message)
|
||||
}
|
||||
|
||||
// ─── Definition CRUD (door.manage) ───────────────────────────────────────────
|
||||
|
||||
const WRITE_FIELDS = [
|
||||
'label', 'button_group', 'sort_order', 'method', 'base_url', 'pinned_host',
|
||||
'url_template', 'headers', 'body_template', 'auth_mode', 'auth_param',
|
||||
'secret_ref', 'required_permission', 'confirm', 'is_enabled',
|
||||
] as const
|
||||
|
||||
export async function createApiButton(input: unknown) {
|
||||
const gate = await requireButtonManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(createApiButtonSchema, 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('api_buttons')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
label: c.label,
|
||||
button_group: c.button_group,
|
||||
sort_order: c.sort_order,
|
||||
method: c.method,
|
||||
base_url: c.base_url,
|
||||
pinned_host: c.pinned_host,
|
||||
url_template: c.url_template ?? null,
|
||||
headers: c.headers,
|
||||
body_template: c.body_template ?? null,
|
||||
auth_mode: c.auth_mode,
|
||||
auth_param: c.auth_param ?? null,
|
||||
secret_ref: c.secret_ref ?? null,
|
||||
required_permission: c.required_permission,
|
||||
confirm: c.confirm,
|
||||
is_enabled: c.is_enabled,
|
||||
created_by: member.id,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'created', 'api_button', data.id as string, c.label)
|
||||
revalidatePath('/door/buttons')
|
||||
return { data: { id: data.id as string } }
|
||||
}
|
||||
|
||||
export async function updateApiButton(input: unknown) {
|
||||
const gate = await requireButtonManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(updateApiButtonSchema, 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 WRITE_FIELDS) {
|
||||
if (u[k] !== undefined) patch[k] = u[k] ?? null
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return { data: { id: u.buttonId } }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('api_buttons')
|
||||
.update(patch)
|
||||
.eq('id', u.buttonId)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'updated', 'api_button', u.buttonId)
|
||||
revalidatePath('/door/buttons')
|
||||
return { data: { id: u.buttonId } }
|
||||
}
|
||||
|
||||
export async function deleteApiButton(input: unknown) {
|
||||
const gate = await requireButtonManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const v = parseInput(apiButtonIdSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('api_buttons')
|
||||
.delete()
|
||||
.eq('id', v.data.buttonId)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'deleted', 'api_button', v.data.buttonId)
|
||||
revalidatePath('/door/buttons')
|
||||
return { data: { id: v.data.buttonId } }
|
||||
}
|
||||
|
||||
// Full definitions for the builder UI (door.manage; RLS already gates SELECT).
|
||||
// secret_ref (an id, not the value) is returned so the picker can show the
|
||||
// current selection; the secret value never leaves the server.
|
||||
export async function listApiButtons() {
|
||||
const gate = await requireButtonManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('api_buttons')
|
||||
.select('id, label, button_group, sort_order, method, base_url, pinned_host, url_template, headers, body_template, auth_mode, auth_param, secret_ref, required_permission, confirm, is_enabled')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('button_group', { ascending: true })
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('label', { ascending: true })
|
||||
if (error) return { error: error.message }
|
||||
return { data: data ?? [] }
|
||||
}
|
||||
|
||||
// ─── Member-facing invoke (per-button required_permission) ───────────────────
|
||||
|
||||
// The curated list a member may press: enabled buttons whose required_permission
|
||||
// the caller holds. Members have NO RLS read on api_buttons (a button's url /
|
||||
// headers / secret_ref are operator config), so this is a service-client read
|
||||
// after requireMember; only presentational fields are returned (never url /
|
||||
// headers / body / secret_ref).
|
||||
export async function listInvokableButtons() {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { data: buttons, error } = await admin
|
||||
.from('api_buttons')
|
||||
.select('id, label, button_group, sort_order, confirm, required_permission')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('is_enabled', true)
|
||||
.order('button_group', { ascending: true })
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('label', { ascending: true })
|
||||
if (error) return { error: error.message }
|
||||
|
||||
// Resolve each distinct required_permission once for this caller.
|
||||
const distinct = Array.from(new Set((buttons ?? []).map(b => b.required_permission as string)))
|
||||
const allowed = new Set<string>()
|
||||
for (const perm of distinct) {
|
||||
const { data: ok } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm,
|
||||
})
|
||||
if (ok) allowed.add(perm)
|
||||
}
|
||||
|
||||
return {
|
||||
data: (buttons ?? [])
|
||||
.filter(b => allowed.has(b.required_permission as string))
|
||||
.map(b => ({
|
||||
id: b.id as string,
|
||||
label: b.label as string,
|
||||
group: b.button_group as string,
|
||||
confirm: b.confirm as boolean,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// Press a button. Any member, gated by the button's own required_permission.
|
||||
// Service client (the definition is operator config a member cannot RLS-read);
|
||||
// the secret is decrypted server-side and injected by callApi, never returned.
|
||||
// Rate-limited; every attempt (incl. a denial) writes one redacted
|
||||
// api_call_log row through the single hardened egress.
|
||||
export async function invokeApiButton(input: unknown) {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const v = parseInput(apiButtonIdSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
// Rate-limit before any DB work so a member cannot flood button loads,
|
||||
// permission RPCs, or denial-audit rows.
|
||||
const rl = checkRateLimit(`api-invoke:${member.space_id}:${member.id}`, 30, 60_000)
|
||||
if (!rl.allowed) return { error: 'Too many actions. Slow down and try again shortly.' }
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { data: btn } = await admin
|
||||
.from('api_buttons')
|
||||
.select('id, label, method, base_url, pinned_host, url_template, headers, body_template, auth_mode, auth_param, secret_ref, required_permission, is_enabled')
|
||||
.eq('id', v.data.buttonId)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!btn || !btn.is_enabled) return { error: 'That button is not available.' }
|
||||
|
||||
const { data: permitted } = await supabase.rpc('user_has_permission', {
|
||||
uid: member.user_id as string,
|
||||
sid: member.space_id,
|
||||
perm: btn.required_permission as string,
|
||||
})
|
||||
if (!permitted) {
|
||||
await admin.from('api_call_log').insert({
|
||||
space_id: member.space_id,
|
||||
button_id: btn.id as string,
|
||||
actor_member_id: member.id,
|
||||
action: 'invoke',
|
||||
success: false,
|
||||
detail: 'denied: missing permission',
|
||||
})
|
||||
return { error: 'You do not have permission to use that button.' }
|
||||
}
|
||||
|
||||
const secret = await resolveDoorSecret(admin, member.space_id, (btn.secret_ref as string | null) ?? null)
|
||||
const authParam = (btn.auth_param as string | null) ?? null
|
||||
|
||||
const result = await callApi({
|
||||
method: btn.method as string,
|
||||
baseUrl: btn.base_url as string,
|
||||
urlTemplate: (btn.url_template as string | null) ?? null,
|
||||
pinnedHost: btn.pinned_host as string,
|
||||
headers: (btn.headers as Record<string, string> | null) ?? undefined,
|
||||
body: (btn.body_template as string | null) ?? null,
|
||||
authMode: (btn.auth_mode as 'none' | 'query' | 'header' | 'bearer') ?? 'none',
|
||||
authParam,
|
||||
secret,
|
||||
})
|
||||
|
||||
const detail = redactDoorSecrets(
|
||||
(result.ok ? `${btn.method} ${result.status}: ${result.snippet}` : `failed: ${result.reason}`).slice(0, 1000),
|
||||
secret,
|
||||
authParam,
|
||||
)
|
||||
await admin.from('api_call_log').insert({
|
||||
space_id: member.space_id,
|
||||
button_id: btn.id as string,
|
||||
actor_member_id: member.id,
|
||||
action: 'invoke',
|
||||
success: result.ok,
|
||||
detail,
|
||||
})
|
||||
|
||||
revalidatePath('/door/buttons')
|
||||
return result.ok
|
||||
? { data: { ok: true, status: result.status } }
|
||||
: { error: 'The API call failed. Ask an admin to check the API-call log for the redacted detail.' }
|
||||
}
|
||||
|
||||
// The press audit (door.manage), for the builder page.
|
||||
export async function listApiCallLog() {
|
||||
const gate = await requireButtonManager()
|
||||
if (!gate.ok) return { error: gate.error }
|
||||
const { supabase, member } = gate
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('api_call_log')
|
||||
.select('id, button_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 ?? [] }
|
||||
}
|
||||
+31
-49
@@ -28,7 +28,7 @@ import {
|
||||
} from '@/lib/door-logic'
|
||||
import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic'
|
||||
import { callDoor } from '@/lib/door/executor'
|
||||
import { decryptSecret } from '@/lib/secrets/crypto'
|
||||
import { resolveDoorSecret } from '@/lib/door/secret'
|
||||
import { checkRateLimit } from '@/lib/security'
|
||||
|
||||
type Gate =
|
||||
@@ -50,37 +50,10 @@ async function requireDoorManager(): Promise<Gate> {
|
||||
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
|
||||
}
|
||||
// Connection door password is loaded + decrypted via the shared
|
||||
// resolveDoorSecret (lib/door/secret.ts); service client, never returned to
|
||||
// the browser. Aliased here to keep the existing call sites unchanged.
|
||||
const resolveSecret = resolveDoorSecret
|
||||
|
||||
function isUniqueViolation(message: string): boolean {
|
||||
return /duplicate key value|already exists|unique constraint/i.test(message)
|
||||
@@ -97,15 +70,18 @@ export async function createDoorConnection(input: unknown) {
|
||||
if (!v.ok) return { error: v.error }
|
||||
const c = v.data
|
||||
|
||||
if (c.secret_ref) {
|
||||
const refs = [c.secret_ref, c.inbound_secret_ref].filter(Boolean) as string[]
|
||||
if (refs.length > 0) {
|
||||
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.' }
|
||||
for (const ref of refs) {
|
||||
const { data: sec } = await admin
|
||||
.from('secrets')
|
||||
.select('id')
|
||||
.eq('id', 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
|
||||
@@ -122,6 +98,8 @@ export async function createDoorConnection(input: unknown) {
|
||||
verbs: c.verbs,
|
||||
allow_member_self_entry: c.allow_member_self_entry,
|
||||
is_enabled: c.is_enabled,
|
||||
inbound_enabled: c.inbound_enabled,
|
||||
inbound_secret_ref: c.inbound_secret_ref ?? null,
|
||||
created_by: member.id,
|
||||
})
|
||||
.select('id')
|
||||
@@ -145,21 +123,25 @@ export async function updateDoorConnection(input: unknown) {
|
||||
if (!v.ok) return { error: v.error }
|
||||
const u = v.data
|
||||
|
||||
if (u.secret_ref) {
|
||||
const refs = [u.secret_ref, u.inbound_secret_ref].filter(Boolean) as string[]
|
||||
if (refs.length > 0) {
|
||||
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.' }
|
||||
for (const ref of refs) {
|
||||
const { data: sec } = await admin
|
||||
.from('secrets')
|
||||
.select('id')
|
||||
.eq('id', 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',
|
||||
'inbound_enabled', 'inbound_secret_ref',
|
||||
] as const) {
|
||||
if (u[k] !== undefined) patch[k] = u[k] === undefined ? null : u[k] ?? null
|
||||
}
|
||||
@@ -204,7 +186,7 @@ export async function listDoorConnections() {
|
||||
|
||||
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')
|
||||
.select('id, name, adapter, base_url, pinned_host, auth_mode, auth_param, secret_ref, verbs, allow_member_self_entry, is_enabled, inbound_enabled, inbound_secret_ref, updated_at')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('name', { ascending: true })
|
||||
if (error) return { error: error.message }
|
||||
|
||||
@@ -62,6 +62,7 @@ export * from './member-cards'
|
||||
|
||||
// Door connections + executor (Door epic)
|
||||
export * from './door'
|
||||
export * from './api-buttons'
|
||||
|
||||
// Presence & attendance (check-in / check-out / hosting)
|
||||
export * from './presence'
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Pure, dependency-free logic for the universal API-call buttons (Door epic
|
||||
// phase 5). No Supabase/React/Next/fetch. The security-relevant part -- where
|
||||
// the secret is injected (query param / custom header / bearer) and how the
|
||||
// request is assembled -- lives here so it is unit-tested in isolation; the
|
||||
// executor (lib/door/executor.ts callApi) consumes the result and performs the
|
||||
// hardened SSRF egress.
|
||||
|
||||
export type ApiAuthMode = 'none' | 'query' | 'header' | 'bearer'
|
||||
export const API_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
|
||||
export type ApiMethod = (typeof API_METHODS)[number]
|
||||
|
||||
export type BuiltApiRequest = {
|
||||
method: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
body: string | null
|
||||
}
|
||||
|
||||
// Assemble the outbound request. The secret (decrypted server-side, never from
|
||||
// the client) is placed per auth_mode: a query param (name = authParam), a
|
||||
// custom header (name = authParam), or an Authorization: Bearer header. A body
|
||||
// is only carried for verbs that take one. Header keys are lower-cased so the
|
||||
// caller cannot smuggle a second Host/host that would survive the executor's
|
||||
// forced host (the executor also forces host last as defense-in-depth).
|
||||
export function buildApiRequest(opts: {
|
||||
baseUrl: string
|
||||
urlTemplate?: string | null
|
||||
method: string
|
||||
headers?: Record<string, string> | null
|
||||
body?: string | null
|
||||
authMode: ApiAuthMode
|
||||
authParam?: string | null
|
||||
secret?: string | null
|
||||
}): BuiltApiRequest {
|
||||
const method = opts.method.toUpperCase()
|
||||
let url = opts.baseUrl + (opts.urlTemplate ?? '')
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(opts.headers ?? {})) {
|
||||
const key = k.trim().toLowerCase()
|
||||
if (key && key !== 'host') headers[key] = v
|
||||
}
|
||||
|
||||
if (opts.secret) {
|
||||
if (opts.authMode === 'query' && opts.authParam) {
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
url += `${sep}${encodeURIComponent(opts.authParam)}=${encodeURIComponent(opts.secret)}`
|
||||
} else if (opts.authMode === 'header' && opts.authParam) {
|
||||
headers[opts.authParam.trim().toLowerCase()] = opts.secret
|
||||
} else if (opts.authMode === 'bearer') {
|
||||
headers['authorization'] = `Bearer ${opts.secret}`
|
||||
}
|
||||
}
|
||||
|
||||
const body = method === 'GET' || method === 'HEAD' ? null : (opts.body ?? null)
|
||||
return { method, url, headers, body }
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Pure, dependency-free logic for Door epic Phase 4 (inbound access-log
|
||||
// ingest). No Supabase/React/Next. Two transports feed one normalized event
|
||||
// shape:
|
||||
// * poll: parse a HeatSync `?z` log dump (parseHeatSyncLog)
|
||||
// * webhook: normalize pushed event JSON (normalizeWebhookEvents)
|
||||
// The ingest action resolves each event's card to a member and dedupe-inserts
|
||||
// it. Card matching is here so it is unit-tested in isolation.
|
||||
|
||||
// HeatSync firmware splits a card number across two log keys with this divisor
|
||||
// (zyphlar/Open_Access_Control_Ethernet.ino, const int divisor = 32767):
|
||||
// granted G(=num%divisor)+g(=num/divisor); denied D+d. Reconstruct num =
|
||||
// high*divisor + low.
|
||||
const HEATSYNC_DIVISOR = 32767n
|
||||
|
||||
export type DoorIngestEvent = {
|
||||
// 'granted'/'denied' for an access decision; 'unknown' when a webhook payload
|
||||
// does not state one.
|
||||
result: 'granted' | 'denied' | 'unknown'
|
||||
// Reconstructed decimal card number (HeatSync poll). null for webhook events
|
||||
// that send a uid string instead.
|
||||
cardNumber: string | null
|
||||
// Raw card identifier string as sent (webhook). null for poll events.
|
||||
cardUid: string | null
|
||||
// ISO 8601 event time when the source provides one (webhook). Poll has no
|
||||
// reliable absolute date, so it is null and the row uses ingest time.
|
||||
occurredAt: string | null
|
||||
// Human-readable audit detail. Redacted again at write time.
|
||||
detail: string
|
||||
// Per-event idempotency token, unique per connection. Poll keys include the
|
||||
// ring-buffer slot position so an unchanged re-poll dedupes; webhook keys are
|
||||
// the caller-supplied event id.
|
||||
dedupeKey: string
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
// Parse a HeatSync `?z` response. Output is `<pre>`-wrapped lines of "K: N"
|
||||
// dumped from a fixed 40-slot ring buffer in raw slot order (empty slots print
|
||||
// as a NUL key with 0). We pair G+g (granted) and D+d (denied), reconstruct
|
||||
// the decimal card number, and stamp best-effort H:M:E time-of-day when the
|
||||
// firmware verbosity emitted it. Raw reads (R/r) are intentionally NOT ingested
|
||||
// (they duplicate the grant decision at high verbosity and carry no decision).
|
||||
//
|
||||
// The ring buffer has no per-entry sequence id, so dedup keys include the slot
|
||||
// position: an unchanged re-poll dedupes, a genuinely new event in a different
|
||||
// slot is distinct. The unavoidable limit is that overwriting a slot with
|
||||
// byte-identical content (same card, same second) collides; the webhook path
|
||||
// (explicit event ids) is the reliable transport for production.
|
||||
export function parseHeatSyncLog(text: string, opts?: { maxEvents?: number }): DoorIngestEvent[] {
|
||||
const max = opts?.maxEvents ?? 200
|
||||
const body = text.replace(/<\/?pre>/gi, '')
|
||||
const lines = body.split(/\r?\n/)
|
||||
|
||||
type Tok = { key: string; val: number; slot: number }
|
||||
const toks: Tok[] = []
|
||||
let slot = 0
|
||||
// The body is from an untrusted controller over plaintext HTTP (MITM-able).
|
||||
// Bound the value to 15 digits: real firmware values are int32 (<=10 digits),
|
||||
// and capping here keeps Number()/BigInt() exact and prevents an Infinity ->
|
||||
// BigInt() throw from a hostile response. Over-long digit runs simply don't
|
||||
// match and are skipped as junk.
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^\s*(\S):\s*(-?\d{1,15})\s*$/)
|
||||
if (!m) continue
|
||||
const key = m[1]
|
||||
// Every printed slot advances the slot counter, but NUL/control keys are
|
||||
// empty ring slots and never emit an event.
|
||||
if (key.charCodeAt(0) >= 32) toks.push({ key, val: Number(m[2]), slot })
|
||||
slot++
|
||||
}
|
||||
|
||||
const events: DoorIngestEvent[] = []
|
||||
let h: number | null = null
|
||||
let mi: number | null = null
|
||||
let e: number | null = null
|
||||
for (let i = 0; i < toks.length && events.length < max; i++) {
|
||||
const t = toks[i]
|
||||
if (t.key === 'H') { h = t.val; continue }
|
||||
if (t.key === 'M') { mi = t.val; continue }
|
||||
if (t.key === 'E') { e = t.val; continue }
|
||||
if (t.key !== 'G' && t.key !== 'D') continue
|
||||
|
||||
const result = t.key === 'G' ? 'granted' : 'denied'
|
||||
const hiKey = t.key === 'G' ? 'g' : 'd'
|
||||
// The firmware emits the high half immediately after the low half, so look
|
||||
// only a few tokens ahead. A bounded window keeps this linear and avoids
|
||||
// pairing a G with a distant unrelated g when this event's half is missing.
|
||||
let hiTok: Tok | undefined
|
||||
for (let j = i + 1; j < toks.length && j <= i + 3; j++) {
|
||||
if (toks[j].key === hiKey) { hiTok = toks[j]; break }
|
||||
}
|
||||
const low = t.val >= 0 ? BigInt(t.val) : 0n
|
||||
const high = hiTok && hiTok.val >= 0 ? BigInt(hiTok.val) : 0n
|
||||
const cardNumber = (high * HEATSYNC_DIVISOR + low).toString()
|
||||
const tod = h !== null && mi !== null && e !== null ? `${pad2(h)}:${pad2(mi)}:${pad2(e)}` : null
|
||||
const raw = hiTok ? `${t.key}:${t.val} ${hiKey}:${hiTok.val}` : `${t.key}:${t.val}`
|
||||
|
||||
events.push({
|
||||
result,
|
||||
cardNumber,
|
||||
cardUid: null,
|
||||
occurredAt: null,
|
||||
detail: tod ? `${result} card #${cardNumber} at ${tod}` : `${result} card #${cardNumber}`,
|
||||
dedupeKey: `hs:${t.slot}:${t.key}:${cardNumber}:${tod ?? ''}`,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// A single inbound webhook event after Zod validation (see
|
||||
// doorWebhookPayloadSchema). The caller-supplied `id` MUST be stable per event
|
||||
// so retries dedupe; everything else is best-effort.
|
||||
export type WebhookEventInput = {
|
||||
id: string
|
||||
card_uid?: string | null
|
||||
card_number?: string | null
|
||||
result?: 'granted' | 'denied' | 'unknown'
|
||||
occurred_at?: string | null
|
||||
}
|
||||
|
||||
export function normalizeWebhookEvents(events: WebhookEventInput[]): DoorIngestEvent[] {
|
||||
return events.map(ev => {
|
||||
const result = ev.result ?? 'unknown'
|
||||
const id = String(ev.id)
|
||||
const cardUid = ev.card_uid ? String(ev.card_uid) : null
|
||||
const cardNumber = ev.card_number ? String(ev.card_number) : null
|
||||
const who = cardUid ? `uid ${cardUid}` : cardNumber ? `card #${cardNumber}` : 'unknown card'
|
||||
return {
|
||||
result,
|
||||
cardNumber,
|
||||
cardUid,
|
||||
occurredAt: ev.occurred_at ? String(ev.occurred_at) : null,
|
||||
detail: `${result} ${who}`,
|
||||
dedupeKey: `wh:${id}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Interpret a card identifier string as both decimal and hex (HeatSync stores
|
||||
// uids as hex but the schema does not enforce it; the poll log reconstructs a
|
||||
// decimal number). Returns every plausible numeric value.
|
||||
function toBigIntCandidates(s: string): bigint[] {
|
||||
const u = s.trim().toLowerCase().replace(/^0x/, '')
|
||||
const out: bigint[] = []
|
||||
// A digit-only string gets both readings (it may be a decimal number or a
|
||||
// hex uid that happens to be all digits); a string with a-f is hex only.
|
||||
if (/^[0-9]+$/.test(u)) {
|
||||
try { out.push(BigInt(u)) } catch { /* ignore */ }
|
||||
}
|
||||
if (/^[0-9a-f]+$/.test(u)) {
|
||||
try { out.push(BigInt('0x' + u)) } catch { /* ignore */ }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// True when a stored member_cards.card_uid refers to the same physical card as
|
||||
// the value seen in a log/webhook event. Matches on exact (case-insensitive)
|
||||
// string equality OR numeric equality across decimal/hex interpretations, so a
|
||||
// hex-stored uid resolves a decimal-reconstructed poll number and vice versa.
|
||||
export function cardUidsEquivalent(stored: string, seen: string): boolean {
|
||||
const a = stored.trim().toLowerCase()
|
||||
const b = seen.trim().toLowerCase()
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
const ca = toBigIntCandidates(stored)
|
||||
const cb = toBigIntCandidates(seen)
|
||||
return ca.some(x => cb.some(y => x === y))
|
||||
}
|
||||
|
||||
// Given an event, the value to resolve against stored card uids (the decimal
|
||||
// number for poll, the raw uid for webhook).
|
||||
export function eventCardValue(ev: DoorIngestEvent): string | null {
|
||||
return ev.cardUid ?? ev.cardNumber
|
||||
}
|
||||
+115
-30
@@ -1,12 +1,12 @@
|
||||
// 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.
|
||||
// Server-only. The single hardened egress point for both the door controller
|
||||
// HTTP (callDoor) and the universal API-call buttons (callApi). Every outbound
|
||||
// call funnels through `egress` so the SSRF guard, no-redirect rule, timeout,
|
||||
// response cap and secret redaction cannot be bypassed. Secrets are never
|
||||
// logged -- only a redacted snippet is ever returned.
|
||||
|
||||
import dns from 'node:dns/promises'
|
||||
import { validateDoorTarget, redactDoorSecrets, isBlockedDoorIp } from '@/lib/door-logic'
|
||||
import { buildApiRequest, type ApiAuthMode } from '@/lib/api-call-logic'
|
||||
|
||||
export type DoorCallResult =
|
||||
| { ok: true; status: number; snippet: string }
|
||||
@@ -14,39 +14,57 @@ export type DoorCallResult =
|
||||
|
||||
const MAX_BODY = 4096
|
||||
const SNIPPET = 500
|
||||
// Log-ingest polls (HeatSync ?z) need the whole ring-buffer dump, which is
|
||||
// larger than a status snippet. Capped so a hostile/garbage response still
|
||||
// cannot exhaust memory.
|
||||
const MAX_BODY_INGEST = 32768
|
||||
// API-button responses: enough for a useful audit detail, not unbounded.
|
||||
const MAX_BODY_API = 8192
|
||||
const SNIPPET_API = 1000
|
||||
|
||||
export async function callDoor(opts: {
|
||||
// The shared hardened egress. Validates the target against the connection's
|
||||
// pinned host, resolves once and connects to the validated IP literal (so
|
||||
// fetch performs no second resolution -> closes the DNS-rebind TOCTOU),
|
||||
// refuses redirects, caps time and body, and redacts secrets from anything
|
||||
// returned. `headers.host` is always forced to the original pinned host, so a
|
||||
// caller-supplied header map can never repoint routing or override it.
|
||||
async function egress(opts: {
|
||||
method: string
|
||||
url: string
|
||||
pinnedHost: string
|
||||
password?: string | null
|
||||
authParam?: string | null
|
||||
headers?: Record<string, string>
|
||||
body?: string | null
|
||||
redactSecret?: string | null
|
||||
redactAuthParam?: string | null
|
||||
timeoutMs?: number
|
||||
maxBody: number
|
||||
snippetLen: number
|
||||
}): Promise<DoorCallResult> {
|
||||
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
|
||||
if (!guard.ok) return { ok: false, reason: guard.reason }
|
||||
|
||||
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host,
|
||||
// and a bare fetch would do its OWN resolution (a hostname could resolve
|
||||
// to 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once
|
||||
// here, reject if ANY resolved address is blocked, then connect to the
|
||||
// validated IP literal so fetch performs no second resolution. Plaintext
|
||||
// HTTP to LAN devices that don't vhost, so connecting by IP (with a
|
||||
// best-effort original Host header) is correct; no TLS/SNI concern.
|
||||
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host, and
|
||||
// a bare fetch would do its OWN resolution (a hostname could resolve to
|
||||
// 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once here,
|
||||
// reject if ANY resolved address is blocked, then connect to the validated
|
||||
// IP literal so fetch performs no second resolution. Plaintext HTTP to LAN
|
||||
// devices that don't vhost, so connecting by IP (with a best-effort original
|
||||
// Host header) is correct; no TLS/SNI concern.
|
||||
const target = new URL(guard.url)
|
||||
let resolved: { address: string; family: number }[]
|
||||
try {
|
||||
resolved = await dns.lookup(target.hostname, { all: true, verbatim: true })
|
||||
} catch {
|
||||
return { ok: false, reason: 'Could not resolve the controller host.' }
|
||||
return { ok: false, reason: 'Could not resolve the target host.' }
|
||||
}
|
||||
if (resolved.length === 0) {
|
||||
return { ok: false, reason: 'The controller host did not resolve.' }
|
||||
return { ok: false, reason: 'The target host did not resolve.' }
|
||||
}
|
||||
for (const a of resolved) {
|
||||
if (isBlockedDoorIp(a.address)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'The controller host resolves to a blocked address (loopback / link-local / metadata).',
|
||||
reason: 'The target host resolves to a blocked address (loopback / link-local / metadata).',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,22 +75,22 @@ export async function callDoor(opts: {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
|
||||
try {
|
||||
// Host forced last so a custom header map cannot override the pinned host.
|
||||
const headers: Record<string, string> = { ...(opts.headers ?? {}), host: target.host }
|
||||
const res = await fetch(pinned.toString(), {
|
||||
method: 'GET',
|
||||
// Never chase a redirect: a 3xx could bounce us off the pinned host
|
||||
// (and would re-resolve, escaping the IP pin).
|
||||
method: opts.method,
|
||||
// Never chase a redirect: a 3xx could bounce us off the pinned host (and
|
||||
// would re-resolve, escaping the IP pin).
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
// Host preserved best-effort so a vhosting controller still routes;
|
||||
// single-purpose door firmware ignores it. The UA may drop a forbidden
|
||||
// Host header — harmless for these devices.
|
||||
headers: { accept: 'text/plain, text/html', host: target.host },
|
||||
headers,
|
||||
body: opts.body ?? undefined,
|
||||
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).',
|
||||
reason: 'The target returned a redirect, which is refused (anti-SSRF).',
|
||||
status: res.status || undefined,
|
||||
}
|
||||
}
|
||||
@@ -81,7 +99,7 @@ export async function callDoor(opts: {
|
||||
const reader = res.body?.getReader()
|
||||
if (reader) {
|
||||
const dec = new TextDecoder()
|
||||
while (received.length < MAX_BODY) {
|
||||
while (received.length < opts.maxBody) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
received += dec.decode(value, { stream: true })
|
||||
@@ -92,12 +110,79 @@ export async function callDoor(opts: {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password, opts.authParam)
|
||||
const snippet = redactDoorSecrets(received.slice(0, opts.snippetLen), opts.redactSecret, opts.redactAuthParam)
|
||||
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, opts.authParam) }
|
||||
return { ok: false, reason: redactDoorSecrets(msg, opts.redactSecret, opts.redactAuthParam) }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// Door controller call. GET only; the secret (if any) is already embedded in
|
||||
// `url` by the caller (per the verified firmware, auth is a query param).
|
||||
// `fullBody` returns the whole capped body (the inbound-log poll); otherwise a
|
||||
// 500-char snippet. Behavior is unchanged from the original single-function
|
||||
// executor.
|
||||
export async function callDoor(opts: {
|
||||
url: string
|
||||
pinnedHost: string
|
||||
password?: string | null
|
||||
authParam?: string | null
|
||||
timeoutMs?: number
|
||||
fullBody?: boolean
|
||||
}): Promise<DoorCallResult> {
|
||||
return egress({
|
||||
method: 'GET',
|
||||
url: opts.url,
|
||||
pinnedHost: opts.pinnedHost,
|
||||
headers: { accept: 'text/plain, text/html' },
|
||||
redactSecret: opts.password,
|
||||
redactAuthParam: opts.authParam,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
maxBody: opts.fullBody ? MAX_BODY_INGEST : MAX_BODY,
|
||||
snippetLen: opts.fullBody ? MAX_BODY_INGEST : SNIPPET,
|
||||
})
|
||||
}
|
||||
|
||||
// Universal API-call button. Full verbs + custom headers + optional body, with
|
||||
// the secret injected server-side per auth_mode (query param / custom header /
|
||||
// bearer) and never returned. Same SSRF guard, host pin, no-redirect, caps and
|
||||
// redaction as the door path.
|
||||
export async function callApi(opts: {
|
||||
method: string
|
||||
baseUrl: string
|
||||
urlTemplate?: string | null
|
||||
pinnedHost: string
|
||||
headers?: Record<string, string> | null
|
||||
body?: string | null
|
||||
authMode: ApiAuthMode
|
||||
authParam?: string | null
|
||||
secret?: string | null
|
||||
timeoutMs?: number
|
||||
}): Promise<DoorCallResult> {
|
||||
const req = buildApiRequest({
|
||||
baseUrl: opts.baseUrl,
|
||||
urlTemplate: opts.urlTemplate,
|
||||
method: opts.method,
|
||||
headers: { accept: '*/*', ...(opts.headers ?? {}) },
|
||||
body: opts.body,
|
||||
authMode: opts.authMode,
|
||||
authParam: opts.authParam,
|
||||
secret: opts.secret,
|
||||
})
|
||||
|
||||
return egress({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
pinnedHost: opts.pinnedHost,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
redactSecret: opts.secret,
|
||||
redactAuthParam: opts.authParam,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
maxBody: MAX_BODY_API,
|
||||
snippetLen: SNIPPET_API,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Server-only. The shared ingest core for Door epic Phase 4. Both transports
|
||||
// (the poll cron and the inbound webhook) parse/normalize events into
|
||||
// DoorIngestEvent[] and hand them here to resolve each card to a member and
|
||||
// idempotently insert audit rows. door_access_log has no client write policy,
|
||||
// so the service (admin) client is the only writer, exactly as for the
|
||||
// outbound actions.
|
||||
|
||||
import type { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { callDoor } from './executor'
|
||||
import { resolveDoorSecret } from './secret'
|
||||
import { encodeHeatSyncControl, applyTemplate, redactDoorSecrets } from '@/lib/door-logic'
|
||||
import {
|
||||
parseHeatSyncLog,
|
||||
cardUidsEquivalent,
|
||||
eventCardValue,
|
||||
type DoorIngestEvent,
|
||||
} from '@/lib/door-log-logic'
|
||||
|
||||
type Admin = ReturnType<typeof createAdminClient>
|
||||
|
||||
// The subset of door_connections the ingest paths read (service client).
|
||||
export type IngestConn = {
|
||||
id: string
|
||||
space_id: string
|
||||
adapter: string
|
||||
base_url: string
|
||||
pinned_host: string
|
||||
auth_param: string | null
|
||||
secret_ref: string | null
|
||||
verbs: Record<string, string> | null
|
||||
is_enabled: boolean
|
||||
}
|
||||
|
||||
function isUniqueViolation(message: string): boolean {
|
||||
return /duplicate key value|already exists|unique constraint/i.test(message)
|
||||
}
|
||||
|
||||
function actionFor(result: DoorIngestEvent['result']): string {
|
||||
if (result === 'granted') return 'entry'
|
||||
if (result === 'denied') return 'entry_denied'
|
||||
return 'inbound'
|
||||
}
|
||||
|
||||
// Resolve each event's card to a member and insert one redacted, deduped
|
||||
// door_access_log row per new event. Returns counts for the caller's response.
|
||||
// Dedup is two-layered: a pre-filter against existing (connection_id,
|
||||
// dedupe_key) cuts the common re-poll case to a single SELECT and zero writes,
|
||||
// and the partial-unique index makes a concurrent insert race a no-op (the
|
||||
// unique violation is swallowed per row).
|
||||
export async function ingestEvents(
|
||||
admin: Admin,
|
||||
spaceId: string,
|
||||
connectionId: string,
|
||||
events: DoorIngestEvent[],
|
||||
): Promise<{ inserted: number; resolved: number; skipped: number }> {
|
||||
if (events.length === 0) return { inserted: 0, resolved: 0, skipped: 0 }
|
||||
|
||||
const { data: cards } = await admin
|
||||
.from('member_cards')
|
||||
.select('id, card_uid, member_id')
|
||||
.eq('space_id', spaceId)
|
||||
.eq('is_active', true)
|
||||
const cardList = (cards ?? []) as { id: string; card_uid: string; member_id: string | null }[]
|
||||
|
||||
const keys = events.map(e => e.dedupeKey)
|
||||
const { data: existing } = await admin
|
||||
.from('door_access_log')
|
||||
.select('dedupe_key')
|
||||
.eq('connection_id', connectionId)
|
||||
.in('dedupe_key', keys)
|
||||
const have = new Set((existing ?? []).map(r => r.dedupe_key as string))
|
||||
|
||||
let inserted = 0
|
||||
let resolved = 0
|
||||
let skipped = 0
|
||||
for (const ev of events) {
|
||||
if (have.has(ev.dedupeKey)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const value = eventCardValue(ev)
|
||||
const card = value ? cardList.find(c => cardUidsEquivalent(c.card_uid, value)) : undefined
|
||||
const targetMemberId = card?.member_id ?? null
|
||||
if (card) resolved++
|
||||
|
||||
const detail = redactDoorSecrets(
|
||||
`${ev.detail} (${card ? 'member matched' : 'no card match'})`.slice(0, 1000),
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
space_id: spaceId,
|
||||
connection_id: connectionId,
|
||||
actor_member_id: null,
|
||||
target_member_id: targetMemberId,
|
||||
action: actionFor(ev.result),
|
||||
success: ev.result === 'granted',
|
||||
detail,
|
||||
dedupe_key: ev.dedupeKey,
|
||||
}
|
||||
// A webhook caller supplies occurred_at; accept only a parseable,
|
||||
// not-in-the-future time (60s skew) so a relay cannot pin spoofed rows to
|
||||
// the top of the operator's occurred_at-ordered log. Otherwise the row
|
||||
// defaults to ingest time.
|
||||
if (ev.occurredAt) {
|
||||
const t = Date.parse(ev.occurredAt)
|
||||
if (!Number.isNaN(t) && t <= Date.now() + 60_000) {
|
||||
row.occurred_at = new Date(t).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await admin.from('door_access_log').insert(row)
|
||||
if (error) {
|
||||
// A concurrent ingest already inserted this exact event: not an error.
|
||||
if (!isUniqueViolation(error.message)) {
|
||||
// Anything else (e.g. a transient DB error) is surfaced via console so
|
||||
// the row is not silently lost; the next poll re-attempts it.
|
||||
console.error('[door-ingest] insert failed:', error.message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
return { inserted, resolved, skipped }
|
||||
}
|
||||
|
||||
// Poll a single native-HeatSync connection's `?z` log and return parsed events.
|
||||
// Only native_heatsync is parsed (its wire format is characterized); generic
|
||||
// controllers ingest via the webhook. The full ring-buffer body is requested
|
||||
// (fullBody) so the parser sees every slot.
|
||||
export async function pollConnectionLog(
|
||||
admin: Admin,
|
||||
conn: IngestConn,
|
||||
): Promise<{ ok: boolean; events: DoorIngestEvent[]; detail: string }> {
|
||||
const password = await resolveDoorSecret(admin, conn.space_id, conn.secret_ref)
|
||||
|
||||
let query: string
|
||||
if (conn.adapter === 'native_heatsync') {
|
||||
const enc = encodeHeatSyncControl('log', password ?? '')
|
||||
if (!enc.ok) return { ok: false, events: [], detail: `encode failed: ${enc.reason}` }
|
||||
query = enc.query
|
||||
} else {
|
||||
return { ok: false, events: [], detail: 'poll supports native_heatsync only; use the webhook' }
|
||||
}
|
||||
|
||||
const result = await callDoor({
|
||||
url: conn.base_url + query,
|
||||
pinnedHost: conn.pinned_host,
|
||||
password,
|
||||
authParam: conn.auth_param,
|
||||
fullBody: true,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return { ok: false, events: [], detail: redactDoorSecrets(`poll failed: ${result.reason}`, password, conn.auth_param) }
|
||||
}
|
||||
const events = parseHeatSyncLog(result.snippet)
|
||||
return { ok: true, events, detail: `polled ${events.length} event(s)` }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Server-only. Load + decrypt a door connection's secret from the AES-256-GCM
|
||||
// secrets vault. Used for both the OUTBOUND door password (secret_ref) and the
|
||||
// INBOUND webhook secret (inbound_secret_ref). The plaintext is decrypted
|
||||
// server-side only and never returned to the browser or logged. Returns null
|
||||
// when no secret is referenced.
|
||||
|
||||
import type { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { decryptSecret } from '@/lib/secrets/crypto'
|
||||
|
||||
export async function resolveDoorSecret(
|
||||
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
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const PERMISSIONS = [
|
||||
{ code: 'equipment.manage', group: 'Equipment', label: 'Manage equipment and reservations' },
|
||||
{ code: 'door.manage', group: 'Access', label: 'Configure door integrations, buttons, and member cards' },
|
||||
{ code: 'door.operate', group: 'Access', label: 'Operate doors: open/lock, push/revoke cards' },
|
||||
{ code: 'apicall.invoke', group: 'Access', label: 'Use API-call buttons (non-door automations)' },
|
||||
{ code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
|
||||
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
|
||||
] as const
|
||||
@@ -51,7 +52,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionCode[]> = {
|
||||
'certifications.manage', 'certifications.grant',
|
||||
'classes.manage', 'classes.instruct',
|
||||
'equipment.manage',
|
||||
'door.manage', 'door.operate',
|
||||
'door.manage', 'door.operate', 'apicall.invoke',
|
||||
'customize.manage', 'settings.manage',
|
||||
],
|
||||
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from 'zod'
|
||||
import { INVITE_ROLES } from './invite-logic'
|
||||
import { MUTEABLE_CATEGORIES } from './notifications-prefs-logic'
|
||||
import { DUES_LINK_PLATFORMS, isSafeDuesUrl } from './dues-payments-logic'
|
||||
import { isValidPermission } from './permissions-catalog'
|
||||
import { API_METHODS } from './api-call-logic'
|
||||
|
||||
/**
|
||||
* Accepts every date-ish string the app produces and normalizes to a
|
||||
@@ -953,6 +955,11 @@ export const createDoorConnectionSchema = z.object({
|
||||
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),
|
||||
// Inbound access-log ingest (P4). inbound_enabled gates both the poll cron
|
||||
// and the per-connection webhook; inbound_secret_ref is the webhook bearer
|
||||
// secret (a secrets-vault row), distinct from secret_ref.
|
||||
inbound_enabled: z.boolean().optional().default(false),
|
||||
inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
|
||||
})
|
||||
|
||||
export const updateDoorConnectionSchema = z.object({
|
||||
@@ -967,6 +974,8 @@ export const updateDoorConnectionSchema = z.object({
|
||||
verbs: z.record(z.string().max(2000)).optional(),
|
||||
allow_member_self_entry: z.boolean().optional(),
|
||||
is_enabled: z.boolean().optional(),
|
||||
inbound_enabled: z.boolean().optional(),
|
||||
inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
|
||||
})
|
||||
|
||||
export const doorConnectionIdSchema = z.object({
|
||||
@@ -992,6 +1001,79 @@ export const doorControlSchema = z.object({
|
||||
verb: z.enum(['open', 'unlock', 'lock']),
|
||||
})
|
||||
|
||||
// ─── Door inbound webhook (Door epic P4) ─────────────────────────────────────
|
||||
// A controller or relay pushes access events to /api/door/inbound/[connection],
|
||||
// authenticated by the connection's inbound bearer secret. Each event MUST
|
||||
// carry a stable id (the dedupe token; retries are idempotent). At least one of
|
||||
// card_uid / card_number identifies the card; result states the access
|
||||
// decision. The batch is bounded so one request cannot enqueue unbounded work.
|
||||
const doorWebhookEvent = z.object({
|
||||
id: z.string().min(1).max(200),
|
||||
card_uid: z.string().trim().min(1).max(200).optional().nullable(),
|
||||
card_number: z.string().trim().regex(/^\d{1,20}$/, 'card_number must be digits').optional().nullable(),
|
||||
result: z.enum(['granted', 'denied', 'unknown']).optional(),
|
||||
occurred_at: z.string().datetime({ offset: true }).optional().nullable(),
|
||||
})
|
||||
|
||||
export const doorWebhookPayloadSchema = z.object({
|
||||
events: z.array(doorWebhookEvent).min(1).max(100),
|
||||
})
|
||||
|
||||
// ─── Universal API-call buttons (Door epic P5) ───────────────────────────────
|
||||
const apiMethod = z.enum(API_METHODS)
|
||||
const apiAuthMode = z.enum(['none', 'query', 'header', 'bearer'])
|
||||
// required_permission must be a real catalog code (any of them; the admin picks
|
||||
// which capability gates pressing the button, default apicall.invoke).
|
||||
const requiredPermission = z
|
||||
.string()
|
||||
.max(60)
|
||||
.refine(isValidPermission, 'Unknown permission code')
|
||||
// A flat string->string header map, bounded in count and value length.
|
||||
const apiHeaders = z
|
||||
.record(z.string().max(2000))
|
||||
.refine(h => Object.keys(h).length <= 20, 'Too many headers (max 20)')
|
||||
|
||||
export const createApiButtonSchema = z.object({
|
||||
label: z.string().min(1, 'Label is required').max(120),
|
||||
button_group: z.string().min(1).max(60).optional().default('General'),
|
||||
sort_order: z.number().int().min(0).max(100000).optional().default(0),
|
||||
method: apiMethod.optional().default('POST'),
|
||||
base_url: httpUrl,
|
||||
pinned_host: z.string().min(1, 'A pinned host is required').max(255),
|
||||
url_template: z.string().max(2000).optional().nullable(),
|
||||
headers: apiHeaders.optional().default({}),
|
||||
body_template: z.string().max(8000).optional().nullable(),
|
||||
auth_mode: apiAuthMode.optional().default('none'),
|
||||
auth_param: z.string().max(120).optional().nullable(),
|
||||
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
|
||||
required_permission: requiredPermission.optional().default('apicall.invoke'),
|
||||
confirm: z.boolean().optional().default(true),
|
||||
is_enabled: z.boolean().optional().default(true),
|
||||
})
|
||||
|
||||
export const updateApiButtonSchema = z.object({
|
||||
buttonId: z.string().uuid('Invalid button ID'),
|
||||
label: z.string().min(1).max(120).optional(),
|
||||
button_group: z.string().min(1).max(60).optional(),
|
||||
sort_order: z.number().int().min(0).max(100000).optional(),
|
||||
method: apiMethod.optional(),
|
||||
base_url: httpUrl.optional(),
|
||||
pinned_host: z.string().min(1).max(255).optional(),
|
||||
url_template: z.string().max(2000).optional().nullable(),
|
||||
headers: apiHeaders.optional(),
|
||||
body_template: z.string().max(8000).optional().nullable(),
|
||||
auth_mode: apiAuthMode.optional(),
|
||||
auth_param: z.string().max(120).optional().nullable(),
|
||||
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
|
||||
required_permission: requiredPermission.optional(),
|
||||
confirm: z.boolean().optional(),
|
||||
is_enabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const apiButtonIdSchema = z.object({
|
||||
buttonId: z.string().uuid('Invalid button ID'),
|
||||
})
|
||||
|
||||
// ─── Presence / attendance ───────────────────────────────────────────────────
|
||||
|
||||
export const checkInSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user