feat(scheduling): optional JWT-authed dezky Meet rooms
This commit is contained in:
@@ -22,6 +22,7 @@ import { SlotService } from '../slots/slot.service.js'
|
||||
import type { HostCalendarAccess } from '../stalwart-calendar/calendar-gateway.types.js'
|
||||
import { CredentialProvisioner } from '../stalwart-calendar/credential-provisioner.service.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
|
||||
|
||||
@@ -53,6 +54,8 @@ export class BookingsService {
|
||||
private readonly logger = new Logger(BookingsService.name)
|
||||
private readonly bookingPublicUrl: string
|
||||
private readonly meetBaseUrl: string
|
||||
private readonly meetDomain: string
|
||||
private readonly meetJwt: MeetJwtConfig
|
||||
|
||||
constructor(
|
||||
@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.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) ───────────────────────────
|
||||
@@ -119,7 +129,7 @@ export class BookingsService {
|
||||
// (b) Persist a pending booking so we have an id to attach to the lock.
|
||||
const calendarEventUid = randomUUID()
|
||||
const manageToken = randomBytes(24).toString('hex')
|
||||
const location = this.resolveLocation(ctx)
|
||||
const location = await this.resolveLocation(ctx)
|
||||
const booking = await this.bookingModel.create({
|
||||
tenantId: tenant._id,
|
||||
eventTypeId: eventType._id,
|
||||
@@ -373,10 +383,20 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
// ── 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
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user