feat(scheduling): optional JWT-authed dezky Meet rooms

This commit is contained in:
Ronni Baslund
2026-06-07 08:58:00 +02:00
parent 851018f481
commit e1a77b085f
2 changed files with 77 additions and 3 deletions
@@ -22,6 +22,7 @@ import { SlotService } from '../slots/slot.service.js'
import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js' import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js'
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js' import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.js'
import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js' import { JmapCalendarGateway } from '../stalwart-calendar/jmap-calendar.gateway.js'
import { buildMeetUrl, meetJwtEnabled, type MeetJwtConfig } from './meet-room.js'
const HOLD_MS = 10 * 60 * 1000 const HOLD_MS = 10 * 60 * 1000
@@ -53,6 +54,8 @@ export class BookingsService {
private readonly logger = new Logger(BookingsService.name) private readonly logger = new Logger(BookingsService.name)
private readonly bookingPublicUrl: string private readonly bookingPublicUrl: string
private readonly meetBaseUrl: string private readonly meetBaseUrl: string
private readonly meetDomain: string
private readonly meetJwt: MeetJwtConfig
constructor( constructor(
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>, @InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
@@ -68,6 +71,13 @@ export class BookingsService {
) { ) {
this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '') this.bookingPublicUrl = (config.get<string>('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '')
this.meetBaseUrl = (config.get<string>('MEET_PUBLIC_URL') ?? 'https://meet.dezky.local').replace(/\/$/, '') this.meetBaseUrl = (config.get<string>('MEET_PUBLIC_URL') ?? 'https://meet.dezky.local').replace(/\/$/, '')
// dezky Meet domain for JWT-authed rooms; defaults to the local meet host.
this.meetDomain = config.get<string>('JITSI_DOMAIN') ?? 'meet.dezky.local'
this.meetJwt = {
jwtSecret: config.get<string>('JITSI_JWT_SECRET'),
appId: config.get<string>('JITSI_APP_ID'),
domain: this.meetDomain,
}
} }
// ── Holds (optional reservation during checkout) ─────────────────────────── // ── Holds (optional reservation during checkout) ───────────────────────────
@@ -119,7 +129,7 @@ export class BookingsService {
// (b) Persist a pending booking so we have an id to attach to the lock. // (b) Persist a pending booking so we have an id to attach to the lock.
const calendarEventUid = randomUUID() const calendarEventUid = randomUUID()
const manageToken = randomBytes(24).toString('hex') const manageToken = randomBytes(24).toString('hex')
const location = this.resolveLocation(ctx) const location = await this.resolveLocation(ctx)
const booking = await this.bookingModel.create({ const booking = await this.bookingModel.create({
tenantId: tenant._id, tenantId: tenant._id,
eventTypeId: eventType._id, eventTypeId: eventType._id,
@@ -373,10 +383,20 @@ export class BookingsService {
} }
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
private resolveLocation(ctx: BookingContext): { type: BookingDocument['locationType']; url?: string } { private async resolveLocation(
ctx: BookingContext,
): Promise<{ type: BookingDocument['locationType']; url?: string }> {
const et = ctx.eventType const et = ctx.eventType
if (et.locationType === 'jitsi') { if (et.locationType === 'jitsi') {
return { type: 'jitsi', url: `${this.meetBaseUrl}/${ctx.tenant.slug}-${randomBytes(6).toString('hex')}` } // Room name is stable per booking (one room per attendee booking) and
// doubles as the JWT `room` claim when JWT auth is enabled.
const room = `${ctx.tenant.slug}-${randomBytes(6).toString('hex')}`
// When JWT is configured we build the URL via the dezky Meet helper
// (https://<domain>/<room>?jwt=…); otherwise keep the plain public URL.
const url = meetJwtEnabled(this.meetJwt)
? await buildMeetUrl(this.meetJwt, room)
: `${this.meetBaseUrl}/${room}`
return { type: 'jitsi', url }
} }
return { type: et.locationType, url: et.locationDetails } return { type: et.locationType, url: et.locationDetails }
} }
@@ -0,0 +1,54 @@
import { SignJWT } from 'jose'
// dezky Meet (Jitsi-backed) room URL builder.
//
// Every booking with a video location gets a room name that is stable for the
// life of that booking (callers pass in a per-booking room id). When a JWT
// secret is configured we mint a short-lived HS256 token that authorizes the
// caller to join *that specific room*, which lets a locked-down dezky Meet
// deployment reject anonymous joins. Without a secret we fall back to a plain
// public room URL — exactly the previous behaviour — so this is purely additive.
export interface MeetJwtConfig {
// Shared HS256 secret (Jitsi `app_secret`). When absent, no token is minted.
jwtSecret?: string
// Jitsi `app_id` — the JWT `iss`/`aud`. Required for a valid token.
appId?: string
// Meet hostname, e.g. "meet.dezky.local". Defaults applied by the caller.
domain: string
}
// How long a minted join token stays valid. Generous so a confirmation email
// link still works a while after booking; the room itself is still gated.
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7 // 7 days
// True when we have everything needed to mint an authorizing token.
export function meetJwtEnabled(cfg: MeetJwtConfig): boolean {
return !!cfg.jwtSecret && !!cfg.appId
}
// Builds the dezky Meet room URL for a booking. When JWT is enabled the URL
// carries a `?jwt=` token authorizing exactly `room`; otherwise a plain URL.
// `room` MUST be stable per booking so the link keeps working.
export async function buildMeetUrl(cfg: MeetJwtConfig, room: string): Promise<string> {
const base = `https://${cfg.domain}/${room}`
if (!meetJwtEnabled(cfg)) return base
const token = await mintMeetJwt(cfg, room)
return `${base}?jwt=${token}`
}
// Mints a Jitsi-compatible HS256 JWT scoped to a single room. The `room` claim
// restricts the token to that room only; `*` would authorize any room, which we
// deliberately avoid.
async function mintMeetJwt(cfg: MeetJwtConfig, room: string): Promise<string> {
const key = new TextEncoder().encode(cfg.jwtSecret!)
const nowSeconds = Math.floor(Date.now() / 1000)
return new SignJWT({ room })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuer(cfg.appId!)
.setAudience(cfg.appId!)
.setSubject(cfg.domain)
.setIssuedAt(nowSeconds)
.setExpirationTime(nowSeconds + TOKEN_TTL_SECONDS)
.sign(key)
}