diff --git a/services/platform-api/src/scheduling/bookings/bookings.service.ts b/services/platform-api/src/scheduling/bookings/bookings.service.ts index 2e30538..2414766 100644 --- a/services/platform-api/src/scheduling/bookings/bookings.service.ts +++ b/services/platform-api/src/scheduling/bookings/bookings.service.ts @@ -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, @@ -68,6 +71,13 @@ export class BookingsService { ) { this.bookingPublicUrl = (config.get('BOOKING_PUBLIC_URL') ?? 'https://booking.dezky.local').replace(/\/$/, '') this.meetBaseUrl = (config.get('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('JITSI_DOMAIN') ?? 'meet.dezky.local' + this.meetJwt = { + jwtSecret: config.get('JITSI_JWT_SECRET'), + appId: config.get('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:///?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 } } diff --git a/services/platform-api/src/scheduling/bookings/meet-room.ts b/services/platform-api/src/scheduling/bookings/meet-room.ts new file mode 100644 index 0000000..4306f2c --- /dev/null +++ b/services/platform-api/src/scheduling/bookings/meet-room.ts @@ -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 { + 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 { + 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) +}