feat(door): P3-C live grant/revoke/open-lock-unlock actions
door.operate, rate-limited, every attempt writes one redacted door_access_log row. Operators have no RLS read on door_connections/ member_cards so reads use the service client after the permission check, space-scoped. grantCard reserves the slot in the DB first (unique constraints arbitrate concurrency), calls the controller, rolls the reservation back on failure; idempotent re-grant; slot_exhausted when full. revokeCard idempotent; frees the slot only on confirmed controller success so the app map never diverges from the device. doorControl open/unlock/lock via the single hardened executor. API_REFERENCE.
This commit is contained in:
@@ -929,6 +929,8 @@ Registry management is gated by `equipment.manage`; reserving needs only members
|
|||||||
- `listEquipmentReservations(input)` — `equipment.manage`; reservations for one item (joined member) for the schedule view.
|
- `listEquipmentReservations(input)` — `equipment.manage`; reservations for one item (joined member) for the schedule view.
|
||||||
- `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view).
|
- `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view).
|
||||||
|
|
||||||
### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migration 035)
|
### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migrations 035-036)
|
||||||
Card UID is a credential. `member-cards.ts`: `addMemberCard`/`updateMemberCard`/`deleteMemberCard`/`listMemberCards` are `door.manage` (full UID); `getMyCards()` is the member's own MASKED view (service client, returns only count + last4, never the raw UID — `member_cards` has no member RLS SELECT).
|
Card UID is a credential. `member-cards.ts`: `addMemberCard`/`updateMemberCard`/`deleteMemberCard`/`listMemberCards` are `door.manage` (full UID); `getMyCards()` is the member's own MASKED view (service client, returns only count + last4, never the raw UID — `member_cards` has no member RLS SELECT).
|
||||||
`door.ts` (all `door.manage` except the read log): `createDoorConnection`/`updateDoorConnection`/`deleteDoorConnection`/`listDoorConnections`; `listSecretTitles()` (titles only, to pick a vault secret for the password). `testDoorConnection(input)` runs only the `status` verb through the hardened executor and writes one redacted audit row. `listDoorAccessLog()` is `door.manage` OR `door.operate`. Security model: the shared password is read from the AES-256-GCM secrets vault server-side and embedded in the request per the verified firmware (auth is a query param); `lib/door/executor.ts` is the single egress — it enforces `validateDoorTarget` (request host must equal the connection's pinned host; metadata/link-local always blocked; http(s) only), refuses redirects, caps time/body, and `redactDoorSecrets` scrubs the password before anything is logged. Native HeatSync verbs are encoded with the verified fixed-width zero-padding (slot 3 / perm 3 / tag 8 / `&e=` password); generic connections use admin `{slot}/{tag}/{pw}` templates. Live grant/revoke/open and the per-connection member self-entry are phase 3.
|
`door.ts` (all `door.manage` except the read log): `createDoorConnection`/`updateDoorConnection`/`deleteDoorConnection`/`listDoorConnections`; `listSecretTitles()` (titles only, to pick a vault secret for the password). `testDoorConnection(input)` runs only the `status` verb through the hardened executor and writes one redacted audit row. `listDoorAccessLog()` is `door.manage` OR `door.operate`. Security model: the shared password is read from the AES-256-GCM secrets vault server-side and embedded in the request per the verified firmware (auth is a query param); `lib/door/executor.ts` is the single egress — it enforces `validateDoorTarget` (request host must equal the connection's pinned host; metadata/link-local always blocked; http(s) only), refuses redirects, caps time/body, and `redactDoorSecrets` scrubs the password before anything is logged. Native HeatSync verbs are encoded with the verified fixed-width zero-padding (slot 3 / perm 3 / tag 8 / `&e=` password); generic connections use admin `{slot}/{tag}/{pw}` templates.
|
||||||
|
|
||||||
|
Phase 3 live actions (all `door.operate`, rate-limited, every attempt writes one redacted `door_access_log` row, service client after the permission check since operators have no RLS read on `door_connections`/`member_cards`): `grantCard({connectionId, cardId, permissionMask?})` allocates the card's per-connection integer slot via the pure lowest-free allocator (`door_card_slots`; idempotent re-grant, `slot_exhausted` when full), reserves it in the DB first, calls the controller, and rolls the reservation back if the call fails. `revokeCard({connectionId, cardId})` is idempotent (no slot assigned means already revoked) and frees the slot only on confirmed controller success so the app's map never diverges from the device. `doorControl({connectionId, verb})` with `verb` of `open`/`unlock`/`lock` (HeatSync `open` is a momentary `o1`; generic controllers need the matching verb template) touches no slot. The per-connection member self-entry action is the next, separately reviewed step.
|
||||||
|
|||||||
@@ -14,14 +14,21 @@ import {
|
|||||||
createDoorConnectionSchema,
|
createDoorConnectionSchema,
|
||||||
updateDoorConnectionSchema,
|
updateDoorConnectionSchema,
|
||||||
doorConnectionIdSchema,
|
doorConnectionIdSchema,
|
||||||
|
doorGrantSchema,
|
||||||
|
doorRevokeSchema,
|
||||||
|
doorControlSchema,
|
||||||
} from '@/lib/validations'
|
} from '@/lib/validations'
|
||||||
import {
|
import {
|
||||||
encodeHeatSyncControl,
|
encodeHeatSyncControl,
|
||||||
|
encodeHeatSyncGrant,
|
||||||
|
encodeHeatSyncRevoke,
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
redactDoorSecrets,
|
redactDoorSecrets,
|
||||||
} from '@/lib/door-logic'
|
} from '@/lib/door-logic'
|
||||||
|
import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic'
|
||||||
import { callDoor } from '@/lib/door/executor'
|
import { callDoor } from '@/lib/door/executor'
|
||||||
import { decryptSecret } from '@/lib/secrets/crypto'
|
import { decryptSecret } from '@/lib/secrets/crypto'
|
||||||
|
import { checkRateLimit } from '@/lib/security'
|
||||||
|
|
||||||
type Gate =
|
type Gate =
|
||||||
| { ok: true; supabase: ServerSupabase; member: Member }
|
| { ok: true; supabase: ServerSupabase; member: Member }
|
||||||
@@ -308,3 +315,334 @@ export async function listDoorAccessLog() {
|
|||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
return { data: data ?? [] }
|
return { data: data ?? [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Live actions (door.operate) ─────────────────────────────────────────────
|
||||||
|
// These reach the physical controller. door.operate holders deliberately do
|
||||||
|
// NOT have RLS SELECT on door_connections / member_cards (the UID is a
|
||||||
|
// credential), so every read here goes through the service client AFTER the
|
||||||
|
// operator permission is verified, always scoped by space_id. Every attempt
|
||||||
|
// writes one redacted door_access_log row; the executor is the single egress.
|
||||||
|
|
||||||
|
async function requireDoorOperator(): 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.operate',
|
||||||
|
})
|
||||||
|
if (error) return { ok: false, error: error.message }
|
||||||
|
if (!allowed) return { ok: false, error: 'You do not have permission to operate the door' }
|
||||||
|
return { ok: true, supabase, member }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnRow = {
|
||||||
|
id: string
|
||||||
|
adapter: string
|
||||||
|
base_url: string
|
||||||
|
pinned_host: string
|
||||||
|
secret_ref: string | null
|
||||||
|
verbs: Record<string, string> | null
|
||||||
|
is_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEnabledConnection(
|
||||||
|
admin: ReturnType<typeof createAdminClient>,
|
||||||
|
spaceId: string,
|
||||||
|
connectionId: string,
|
||||||
|
): Promise<{ ok: true; conn: ConnRow } | { ok: false; error: string }> {
|
||||||
|
const { data } = await admin
|
||||||
|
.from('door_connections')
|
||||||
|
.select('id, adapter, base_url, pinned_host, secret_ref, verbs, is_enabled')
|
||||||
|
.eq('id', connectionId)
|
||||||
|
.eq('space_id', spaceId)
|
||||||
|
.maybeSingle()
|
||||||
|
if (!data) return { ok: false, error: 'Connection not found' }
|
||||||
|
if (!data.is_enabled) return { ok: false, error: 'This connection is disabled.' }
|
||||||
|
return { ok: true, conn: data as unknown as ConnRow }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditDoor(
|
||||||
|
admin: ReturnType<typeof createAdminClient>,
|
||||||
|
row: {
|
||||||
|
spaceId: string
|
||||||
|
connectionId: string
|
||||||
|
actorMemberId: string
|
||||||
|
targetMemberId?: string | null
|
||||||
|
action: string
|
||||||
|
success: boolean
|
||||||
|
detail: string
|
||||||
|
password?: string | null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await admin.from('door_access_log').insert({
|
||||||
|
space_id: row.spaceId,
|
||||||
|
connection_id: row.connectionId,
|
||||||
|
actor_member_id: row.actorMemberId,
|
||||||
|
target_member_id: row.targetMemberId ?? null,
|
||||||
|
action: row.action,
|
||||||
|
success: row.success,
|
||||||
|
detail: redactDoorSecrets(row.detail.slice(0, 1000), row.password),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve (or reuse) the card's slot on this connection. The DB unique
|
||||||
|
// constraints arbitrate concurrency: (connection_id, card_id) makes re-grant
|
||||||
|
// idempotent; (connection_id, slot) makes a slot race fail loudly so we retry.
|
||||||
|
async function reserveSlot(
|
||||||
|
admin: ReturnType<typeof createAdminClient>,
|
||||||
|
spaceId: string,
|
||||||
|
connectionId: string,
|
||||||
|
cardId: string,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<
|
||||||
|
| { ok: true; slot: number; created: boolean }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const { data: existing } = await admin
|
||||||
|
.from('door_card_slots')
|
||||||
|
.select('slot')
|
||||||
|
.eq('connection_id', connectionId)
|
||||||
|
.eq('card_id', cardId)
|
||||||
|
.maybeSingle()
|
||||||
|
if (existing) return { ok: true, slot: existing.slot as number, created: false }
|
||||||
|
|
||||||
|
const { data: used } = await admin
|
||||||
|
.from('door_card_slots')
|
||||||
|
.select('slot')
|
||||||
|
.eq('connection_id', connectionId)
|
||||||
|
const pick = pickLowestFreeSlot((used ?? []).map(r => r.slot as number))
|
||||||
|
if (!pick.ok) {
|
||||||
|
const cap = slotCapacity()
|
||||||
|
return { ok: false, error: `This controller is full (${cap}/${cap} card slots used). Revoke an unused card before granting another.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: insErr } = await admin.from('door_card_slots').insert({
|
||||||
|
space_id: spaceId,
|
||||||
|
connection_id: connectionId,
|
||||||
|
card_id: cardId,
|
||||||
|
slot: pick.slot,
|
||||||
|
created_by: createdBy,
|
||||||
|
})
|
||||||
|
if (!insErr) return { ok: true, slot: pick.slot, created: true }
|
||||||
|
// Unique violation = a concurrent grant took this slot or this card.
|
||||||
|
// Loop: re-read (the card row may now exist → reuse it; else re-pick).
|
||||||
|
if (!isUniqueViolation(insErr.message)) return { ok: false, error: insErr.message }
|
||||||
|
}
|
||||||
|
return { ok: false, error: 'Could not allocate a free slot (contention). Try again.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantCard(input: unknown) {
|
||||||
|
const gate = await requireDoorOperator()
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(doorGrantSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
const { connectionId, cardId, permissionMask } = v.data
|
||||||
|
|
||||||
|
const rl = checkRateLimit(`door-grant:${member.space_id}:${member.id}`, 30, 60_000)
|
||||||
|
if (!rl.allowed) return { error: 'Too many door changes. Slow down and try again shortly.' }
|
||||||
|
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const cx = await loadEnabledConnection(admin, member.space_id, connectionId)
|
||||||
|
if (!cx.ok) return { error: cx.error }
|
||||||
|
const conn = cx.conn
|
||||||
|
|
||||||
|
const { data: card } = await admin
|
||||||
|
.from('member_cards')
|
||||||
|
.select('id, member_id, card_uid, is_active')
|
||||||
|
.eq('id', cardId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.maybeSingle()
|
||||||
|
if (!card) return { error: 'Card not found in this space' }
|
||||||
|
if (!card.is_active) return { error: 'That card is inactive. Reactivate it before granting access.' }
|
||||||
|
const targetMemberId = (card.member_id as string) ?? null
|
||||||
|
|
||||||
|
const password = await resolveSecret(admin, member.space_id, conn.secret_ref)
|
||||||
|
|
||||||
|
const reserved = await reserveSlot(admin, member.space_id, connectionId, cardId, member.id)
|
||||||
|
if (!reserved.ok) {
|
||||||
|
await auditDoor(admin, {
|
||||||
|
spaceId: member.space_id, connectionId, actorMemberId: member.id,
|
||||||
|
targetMemberId, action: 'grant', success: false,
|
||||||
|
detail: `failed: ${reserved.error}`, password,
|
||||||
|
})
|
||||||
|
return { error: reserved.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
let query: string
|
||||||
|
if (conn.adapter === 'native_heatsync') {
|
||||||
|
const enc = encodeHeatSyncGrant({
|
||||||
|
slot: reserved.slot,
|
||||||
|
permissionMask,
|
||||||
|
tagHex: card.card_uid as string,
|
||||||
|
password: password ?? '',
|
||||||
|
})
|
||||||
|
if (!enc.ok) {
|
||||||
|
if (reserved.created) await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId)
|
||||||
|
return { error: enc.reason }
|
||||||
|
}
|
||||||
|
query = enc.query
|
||||||
|
} else {
|
||||||
|
const tmpl = conn.verbs?.grant
|
||||||
|
if (!tmpl) {
|
||||||
|
if (reserved.created) await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId)
|
||||||
|
return { error: 'This connection has no "grant" verb template configured.' }
|
||||||
|
}
|
||||||
|
query = applyTemplate(tmpl, {
|
||||||
|
slot: reserved.slot, tag: card.card_uid as string, perm: permissionMask, pw: password ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password })
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
// Roll back the reservation so slots do not leak on a failed write.
|
||||||
|
if (reserved.created) {
|
||||||
|
await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId)
|
||||||
|
}
|
||||||
|
await auditDoor(admin, {
|
||||||
|
spaceId: member.space_id, connectionId, actorMemberId: member.id,
|
||||||
|
targetMemberId, action: 'grant', success: false,
|
||||||
|
detail: `failed: ${result.reason}`, password,
|
||||||
|
})
|
||||||
|
return { error: result.reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditDoor(admin, {
|
||||||
|
spaceId: member.space_id, connectionId, actorMemberId: member.id,
|
||||||
|
targetMemberId, action: 'grant', success: true,
|
||||||
|
detail: `slot ${reserved.slot} · status ${result.status}: ${result.snippet}`, password,
|
||||||
|
})
|
||||||
|
revalidatePath('/door/manage')
|
||||||
|
return { data: { ok: true, slot: reserved.slot } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeCard(input: unknown) {
|
||||||
|
const gate = await requireDoorOperator()
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(doorRevokeSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
const { connectionId, cardId } = v.data
|
||||||
|
|
||||||
|
const rl = checkRateLimit(`door-revoke:${member.space_id}:${member.id}`, 30, 60_000)
|
||||||
|
if (!rl.allowed) return { error: 'Too many door changes. Slow down and try again shortly.' }
|
||||||
|
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const cx = await loadEnabledConnection(admin, member.space_id, connectionId)
|
||||||
|
if (!cx.ok) return { error: cx.error }
|
||||||
|
const conn = cx.conn
|
||||||
|
|
||||||
|
const { data: card } = await admin
|
||||||
|
.from('member_cards')
|
||||||
|
.select('id, member_id')
|
||||||
|
.eq('id', cardId)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.maybeSingle()
|
||||||
|
const targetMemberId = (card?.member_id as string) ?? null
|
||||||
|
|
||||||
|
const { data: slotRow } = await admin
|
||||||
|
.from('door_card_slots')
|
||||||
|
.select('slot')
|
||||||
|
.eq('connection_id', connectionId)
|
||||||
|
.eq('card_id', cardId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
const password = await resolveSecret(admin, member.space_id, conn.secret_ref)
|
||||||
|
|
||||||
|
// Idempotent: nothing assigned means it is already revoked.
|
||||||
|
if (!slotRow) {
|
||||||
|
await auditDoor(admin, {
|
||||||
|
spaceId: member.space_id, connectionId, actorMemberId: member.id,
|
||||||
|
targetMemberId, action: 'revoke', success: true,
|
||||||
|
detail: 'no slot assigned (already revoked)', password,
|
||||||
|
})
|
||||||
|
revalidatePath('/door/manage')
|
||||||
|
return { data: { ok: true, alreadyRevoked: true } }
|
||||||
|
}
|
||||||
|
const slot = slotRow.slot as number
|
||||||
|
|
||||||
|
let query: string
|
||||||
|
if (conn.adapter === 'native_heatsync') {
|
||||||
|
const enc = encodeHeatSyncRevoke(slot, password ?? '')
|
||||||
|
if (!enc.ok) return { error: enc.reason }
|
||||||
|
query = enc.query
|
||||||
|
} else {
|
||||||
|
const tmpl = conn.verbs?.revoke
|
||||||
|
if (!tmpl) return { error: 'This connection has no "revoke" verb template configured.' }
|
||||||
|
query = applyTemplate(tmpl, { slot, pw: password ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password })
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
// Keep the slot row so the app's map stays in sync with the device's
|
||||||
|
// belief; the operator can retry.
|
||||||
|
await auditDoor(admin, {
|
||||||
|
spaceId: member.space_id, connectionId, actorMemberId: member.id,
|
||||||
|
targetMemberId, action: 'revoke', success: false,
|
||||||
|
detail: `failed (slot ${slot} kept): ${result.reason}`, password,
|
||||||
|
})
|
||||||
|
return { error: result.reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId)
|
||||||
|
await auditDoor(admin, {
|
||||||
|
spaceId: member.space_id, connectionId, actorMemberId: member.id,
|
||||||
|
targetMemberId, action: 'revoke', success: true,
|
||||||
|
detail: `slot ${slot} freed · status ${result.status}: ${result.snippet}`, password,
|
||||||
|
})
|
||||||
|
revalidatePath('/door/manage')
|
||||||
|
return { data: { ok: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open / unlock / lock. No slot involvement. HeatSync 'open' = momentary o1.
|
||||||
|
// Generic controllers must have the matching verb template configured.
|
||||||
|
export async function doorControl(input: unknown) {
|
||||||
|
const gate = await requireDoorOperator()
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(doorControlSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
const { connectionId, verb } = v.data
|
||||||
|
|
||||||
|
const rl = checkRateLimit(`door-control:${member.space_id}:${member.id}`, 20, 60_000)
|
||||||
|
if (!rl.allowed) return { error: 'Too many door commands. Slow down and try again shortly.' }
|
||||||
|
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const cx = await loadEnabledConnection(admin, member.space_id, connectionId)
|
||||||
|
if (!cx.ok) return { error: cx.error }
|
||||||
|
const conn = cx.conn
|
||||||
|
|
||||||
|
const password = await resolveSecret(admin, member.space_id, conn.secret_ref)
|
||||||
|
|
||||||
|
let query: string
|
||||||
|
if (conn.adapter === 'native_heatsync') {
|
||||||
|
const enc = encodeHeatSyncControl(verb === 'open' ? 'open1' : verb, password ?? '')
|
||||||
|
if (!enc.ok) return { error: enc.reason }
|
||||||
|
query = enc.query
|
||||||
|
} else {
|
||||||
|
const tmpl = conn.verbs?.[verb]
|
||||||
|
if (!tmpl) return { error: `This connection has no "${verb}" verb template configured.` }
|
||||||
|
query = applyTemplate(tmpl, { pw: password ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password })
|
||||||
|
|
||||||
|
await auditDoor(admin, {
|
||||||
|
spaceId: member.space_id, connectionId, actorMemberId: member.id,
|
||||||
|
action: verb, success: result.ok,
|
||||||
|
detail: result.ok ? `status ${result.status}: ${result.snippet}` : `failed: ${result.reason}`,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/door/manage')
|
||||||
|
return result.ok ? { data: { ok: true, status: result.status } } : { error: result.reason }
|
||||||
|
}
|
||||||
|
|||||||
@@ -946,6 +946,25 @@ export const doorConnectionIdSchema = z.object({
|
|||||||
connectionId: z.string().uuid('Invalid connection ID'),
|
connectionId: z.string().uuid('Invalid connection ID'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Door live actions (door.operate) ────────────────────────────────────────
|
||||||
|
|
||||||
|
export const doorGrantSchema = z.object({
|
||||||
|
connectionId: z.string().uuid('Invalid connection ID'),
|
||||||
|
cardId: z.string().uuid('Invalid card ID'),
|
||||||
|
// HeatSync permission mask; defaults to 1 (basic access).
|
||||||
|
permissionMask: z.number().int().min(0).max(255).optional().default(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const doorRevokeSchema = z.object({
|
||||||
|
connectionId: z.string().uuid('Invalid connection ID'),
|
||||||
|
cardId: z.string().uuid('Invalid card ID'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const doorControlSchema = z.object({
|
||||||
|
connectionId: z.string().uuid('Invalid connection ID'),
|
||||||
|
verb: z.enum(['open', 'unlock', 'lock']),
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const uuidSchema = z.string().uuid('Invalid ID format')
|
export const uuidSchema = z.string().uuid('Invalid ID format')
|
||||||
|
|||||||
Reference in New Issue
Block a user