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 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user