feat(videochat): implement LiveKit integration for video chat functionality

- Add LiveKit client setup and token generation for room access.
- Implement room management functions including CreateRoom, OpenRoom, ListRooms, and DeleteRoom.
- Introduce participant management with ListParticipants and RemoveParticipant functions.
- Create tests for room and participant management, ensuring proper authentication and authorization.
- Add error handling for LiveKit API interactions.
- Define data structures for room metadata and token responses.
- Implement status endpoint for service availability check.
This commit is contained in:
fabio
2026-09-29 18:45:21 +02:00
parent a051eef991
commit 0fe1bc2292
21 changed files with 1904 additions and 12 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View File

@@ -18,6 +18,7 @@ go.work
go.work.sum
# Environment files
/videochat/setup-livekit.txt
.env
.env.development.local
.env.test.local

View File

@@ -48,3 +48,25 @@ http://localhost:9400/
```
Le API locali sono esposte dall'ambiente Encore locale. Consulta il Service Catalog nel dashboard per endpoint e schema aggiornati.
## Videochat
Il servizio `videochat` integra LiveKit per gestire stanze, partecipanti e token di accesso.
Configurazione e API: [videochat/README.md](videochat/README.md).
### Setup personale
`GET /profiles/personal-setup` restituisce il setup dell'utente autenticato:
`user_id`, `can_broadcast` (bool) e `room_name` (string).
In assenza di setup, restituisce `can_broadcast: false` e `room_name: ""`.
Gli amministratori possono impostarlo con `PUT /profiles/personal-setup/:userID`:
```json
{"can_broadcast": true, "room_name": "stanza-utente"}
```
Il nome stanza è obbligatorio quando il broadcasting è abilitato e viene cancellato
quando è disabilitato. Il setup è salvato nel database `profiles` dalla migrazione 11.
Queste API gestiscono i dati del setup; l'applicazione del permesso ai token LiveKit
e la creazione della stanza non sono automatiche.

143
extrahome/data.go Normal file
View File

@@ -0,0 +1,143 @@
package extrahome
import "time"
type SiteConfig struct {
ExtraSite ExtraSite
StartAt time.Time
Description string
}
type ExtraSite struct {
NavItems []NavItem
HomePageData HomePageData
BroadcasterPageData BroadcasterPageData
ContactPageData ContactPageData
Carusel Carusel
}
type NavItem struct {
Label string
Color string
URL string
}
type HomePageData struct {
Title string
Lang string
HeroTitle string
Profiles []Profile
Promo Promo
ListOfBroadcasters ListOfBroadcaster
Carusel Carusel
Year int
}
type BroadcasterPageData struct {
Title string
Lang string
Year int
PromoRight1 Promo
PromoRight2 Promo
Profile BroadcasterProfile
}
type ContactPageData struct {
Title string
Lang string
HeroTitle string
Email string
Phone string
Address string
Hours string
Message string
Year int
}
type Carusel struct {
Items []CaruselItem
}
type CaruselItem struct {
Title string
Description string
ImageURL string
URL string
}
type Promo struct {
Left PromoItem
Right1 PromoItem
Right2 PromoItem
}
type PromoItem struct {
Title string
Description string
Content []PromoContent
}
type PromoContent struct {
Width int
Height int
MobileOnly bool
MobileImageURL string
NewWindow bool
Title string
Description string
ImageURL string
URL string
}
type ListOfBroadcaster struct {
Title string
Items []ListOfBroadcasterItem
}
type ListOfBroadcasterItem struct {
Title string
Category string
Description string
ImageURL string
URL string
}
type Profile struct {
Name string
ProfilePhoto ProfilePhoto
Category string
Description string
URL string
}
type ProfilePhoto struct {
ImageURL string
Alt string
}
type ProfileHours struct {
Day string
Hours string
}
type ProfileAttribute struct {
Label string
Value string
}
type BroadcasterProfile struct {
ID int64
Name string
Category string
CategoryColor string
Certified bool
Introduction []string
Photos []ProfilePhoto
Hours []ProfileHours
Availability string
Phone string
PhoneNumber string
Attributes []ProfileAttribute
Languages []string
Services []string
}

23
extrahome/extrahome.go Normal file
View File

@@ -0,0 +1,23 @@
// Service extrahome provides extrahome APIs.
package extrahome
import (
"context"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("extrahome", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
type StatusResponse struct {
Status string `json:"status"`
}
// Status reports whether the extrahome service is available.
//
//encore:api public method=GET path=/extrahome/status
func Status(ctx context.Context) (*StatusResponse, error) {
return &StatusResponse{Status: "ok"}, nil
}

View File

@@ -0,0 +1,7 @@
CREATE TABLE siteconfig (
id BIGSERIAL PRIMARY KEY,
extrsite JSONB NOT NULL,
start_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
description TEXT NOT NULL DEFAULT '',
CONSTRAINT siteconfig_extrsite_object CHECK (jsonb_typeof(extrsite) = 'object')
);

79
go.mod
View File

@@ -5,13 +5,86 @@ go 1.26
require (
encore.dev v1.57.5
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.42.0
github.com/livekit/protocol v1.49.0
github.com/livekit/server-sdk-go/v2 v2.18.1
github.com/twitchtv/twirp v8.1.3+incompatible
golang.org/x/crypto v0.52.0
google.golang.org/protobuf v1.36.11
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
buf.build/go/protovalidate v1.2.0 // indirect
buf.build/go/protoyaml v0.7.0 // indirect
cel.dev/expr v0.25.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gammazero/deque v1.2.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/cel-go v0.28.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e // indirect
github.com/livekit/psrpc v0.7.2 // indirect
github.com/magefile/mage v1.17.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/ice/v4 v4.2.7 // indirect
github.com/pion/interceptor v0.1.45 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.2 // indirect
github.com/pion/sctp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.19 // indirect
github.com/pion/srtp/v3 v3.0.11 // indirect
github.com/pion/stun/v3 v3.1.5 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/turn/v5 v5.0.9 // indirect
github.com/pion/webrtc/v4 v4.2.15 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
github.com/redis/go-redis/v9 v9.20.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

232
go.sum
View File

@@ -1,8 +1,73 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
encore.dev v1.57.5 h1:gpcE2XF4Qvh0IzD+seLf9DPV9q8Mzr7bMaNJfpF2xuc=
encore.dev v1.57.5/go.mod h1:lK8vSJG6uhYeUwT87/FEpcLdiN98QUcotd3gxRX0xDw=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -11,20 +76,169 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e h1:SkgQRcG2VYEhh80Qb/zYZo8rWKJzNfJcfUQnXe6su2M=
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.49.0 h1:Q5nthDO1v7c0JHiWjMhgUQTlsKmCsBL/KCKxdHVaz00=
github.com/livekit/protocol v1.49.0/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs=
github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc=
github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw=
github.com/livekit/server-sdk-go/v2 v2.18.1 h1:/u0JVII+ErGCivHnAGr6di0wl0NYsfcRvDzEtTAFovo=
github.com/livekit/server-sdk-go/v2 v2.18.1/go.mod h1:su0IvJNWTFCHVwmqpsFvxHfXWs9pn26ms+cKBR1jILU=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME=
github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM=
github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0=
github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,9 @@
CREATE TABLE personal_setup (
user_id UUID PRIMARY KEY REFERENCES user_profiles (user_id) ON DELETE CASCADE,
can_broadcast BOOLEAN NOT NULL DEFAULT FALSE,
room_name TEXT NOT NULL DEFAULT '',
CONSTRAINT personal_setup_broadcast_room_check CHECK (
(NOT can_broadcast AND room_name = '') OR
(can_broadcast AND octet_length(room_name) BETWEEN 1 AND 128 AND btrim(room_name) <> '')
)
);

116
profiles/personal_setup.go Normal file
View File

@@ -0,0 +1,116 @@
package profiles
import (
"context"
"errors"
"strings"
"unicode"
authsvc "encore.app/auth"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
)
// PersonalSetup holds the broadcasting configuration linked to a user profile.
type PersonalSetup struct {
UserID uuid.UUID `json:"user_id"`
CanBroadcast bool `json:"can_broadcast"`
RoomName string `json:"room_name"`
}
type PersonalSetupParams struct {
CanBroadcast bool `json:"can_broadcast"`
RoomName string `json:"room_name"`
}
func (p *PersonalSetupParams) Validate() error {
if p == nil {
return &errs.Error{Code: errs.InvalidArgument, Message: "personal setup params are required"}
}
if !p.CanBroadcast {
p.RoomName = ""
return nil
}
p.RoomName = strings.TrimSpace(p.RoomName)
if p.RoomName == "" || len(p.RoomName) > 128 || strings.ContainsAny(p.RoomName, "/\\") || strings.ContainsFunc(p.RoomName, unicode.IsControl) {
return &errs.Error{Code: errs.InvalidArgument, Message: "broadcasting requires a room name of 1-128 bytes without slashes or control characters"}
}
return nil
}
// GetPersonalSetup returns the caller's setup, defaulting to broadcasting disabled.
//
//encore:api auth method=GET path=/profiles/personal-setup
func GetPersonalSetup(ctx context.Context) (*PersonalSetup, error) {
userID, err := authedUserID()
if err != nil {
return nil, err
}
return getPersonalSetup(ctx, userID)
}
// GetUserPersonalSetup lets administrators read the selected user's setup.
//
//encore:api auth method=GET path=/profiles/personal-setup/:userID
func GetUserPersonalSetup(ctx context.Context, userID uuid.UUID) (*PersonalSetup, error) {
if _, ok := auth.UserID(); !ok {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "authentication required"}
}
data, _ := auth.Data().(*authsvc.AuthData)
if data == nil || !data.Role.IsAdmin() {
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"}
}
return getPersonalSetup(ctx, userID)
}
func getPersonalSetup(ctx context.Context, userID uuid.UUID) (*PersonalSetup, error) {
setup := PersonalSetup{UserID: userID}
err := db.QueryRow(ctx, `
SELECT COALESCE(s.can_broadcast, FALSE), COALESCE(s.room_name, '')
FROM user_profiles p
LEFT JOIN personal_setup s ON s.user_id = p.user_id
WHERE p.user_id = $1
`, userID).Scan(&setup.CanBroadcast, &setup.RoomName)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch personal setup")
}
return &setup, nil
}
// UpsertPersonalSetup lets administrators assign or revoke a user's broadcasting setup.
// This stores configuration only; it does not create or delete a LiveKit room.
//
//encore:api auth method=PUT path=/profiles/personal-setup/:userID
func UpsertPersonalSetup(ctx context.Context, userID uuid.UUID, p *PersonalSetupParams) (*PersonalSetup, error) {
if _, ok := auth.UserID(); !ok {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "authentication required"}
}
data, _ := auth.Data().(*authsvc.AuthData)
if data == nil || !data.Role.IsAdmin() {
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"}
}
if err := p.Validate(); err != nil {
return nil, err
}
setup := PersonalSetup{UserID: userID}
err := db.QueryRow(ctx, `
INSERT INTO personal_setup (user_id, can_broadcast, room_name)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET can_broadcast = EXCLUDED.can_broadcast,
room_name = EXCLUDED.room_name
RETURNING can_broadcast, room_name
`, userID, p.CanBroadcast, p.RoomName).Scan(&setup.CanBroadcast, &setup.RoomName)
if isForeignKeyViolation(err) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to save personal setup")
}
return &setup, nil
}

View File

@@ -0,0 +1,117 @@
package profiles
import (
"context"
"strings"
"testing"
authsvc "encore.app/auth"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/et"
"encore.dev/types/uuid"
)
func TestPersonalSetupValidation(t *testing.T) {
for _, p := range []*PersonalSetupParams{
nil,
{CanBroadcast: true},
{CanBroadcast: true, RoomName: " "},
{CanBroadcast: true, RoomName: "a/b"},
{CanBroadcast: true, RoomName: "a\x00b"},
{CanBroadcast: true, RoomName: strings.Repeat("a", 129)},
} {
if err := p.Validate(); errs.Code(err) != errs.InvalidArgument {
t.Errorf("expected invalid setup, got %v", err)
}
}
p := &PersonalSetupParams{CanBroadcast: true, RoomName: " demo "}
if err := p.Validate(); err != nil || p.RoomName != "demo" {
t.Fatalf("expected normalized room name: %+v, %v", p, err)
}
p.CanBroadcast = false
if err := p.Validate(); err != nil || p.RoomName != "" {
t.Fatalf("revoking broadcasting must clear the room: %+v, %v", p, err)
}
}
func TestPersonalSetupPermissions(t *testing.T) {
defer et.OverrideAuthInfo("", nil)
ctx := context.Background()
et.OverrideAuthInfo("", nil)
if _, err := GetPersonalSetup(ctx); errs.Code(err) != errs.Unauthenticated {
t.Fatalf("expected unauthenticated, got %v", err)
}
if _, err := UpsertPersonalSetup(ctx, uuid.Nil, &PersonalSetupParams{}); errs.Code(err) != errs.Unauthenticated {
t.Fatalf("expected unauthenticated, got %v", err)
}
if _, err := GetUserPersonalSetup(ctx, uuid.Nil); errs.Code(err) != errs.Unauthenticated {
t.Fatalf("anonymous admin read: %v", err)
}
et.OverrideAuthInfo("user", &authsvc.AuthData{Role: "user"})
if _, err := GetUserPersonalSetup(ctx, uuid.Nil); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-admin read: %v", err)
}
if _, err := UpsertPersonalSetup(ctx, uuid.Nil, &PersonalSetupParams{CanBroadcast: true, RoomName: "demo"}); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("users must not grant themselves broadcasting: %v", err)
}
}
func TestPersonalSetupPersistence(t *testing.T) {
ctx := context.Background()
testDB, err := et.NewTestDatabase(ctx, "profiles")
if err != nil {
t.Fatal(err)
}
originalDB := db
db = testDB
defer func() { db = originalDB; et.OverrideAuthInfo("", nil) }()
userID, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email) VALUES ($1, 'Setup Test', 'setup@example.invalid')`, userID)
if err != nil {
t.Fatal(err)
}
et.OverrideAuthInfo(auth.UID(userID.String()), &authsvc.AuthData{Role: "user"})
setup, err := GetPersonalSetup(ctx)
if err != nil || setup.CanBroadcast || setup.RoomName != "" {
t.Fatalf("expected disabled default setup: %+v, %v", setup, err)
}
et.OverrideAuthInfo("admin", &authsvc.AuthData{Role: "admin"})
if _, err := UpsertPersonalSetup(ctx, userID, &PersonalSetupParams{CanBroadcast: true, RoomName: "demo"}); err != nil {
t.Fatal(err)
}
setup, err = GetUserPersonalSetup(ctx, userID)
if err != nil || setup.UserID != userID || !setup.CanBroadcast || setup.RoomName != "demo" {
t.Fatalf("admin must read selected user's settings: %+v, %v", setup, err)
}
if _, err := GetUserPersonalSetup(ctx, uuid.Nil); errs.Code(err) != errs.NotFound {
t.Fatalf("missing profile read: %v", err)
}
et.OverrideAuthInfo(auth.UID(userID.String()), &authsvc.AuthData{Role: "user"})
setup, err = GetPersonalSetup(ctx)
if err != nil || !setup.CanBroadcast || setup.RoomName != "demo" || setup.UserID != userID {
t.Fatalf("expected persisted setup: %+v, %v", setup, err)
}
et.OverrideAuthInfo("admin", &authsvc.AuthData{Role: "admin"})
setup, err = UpsertPersonalSetup(ctx, userID, &PersonalSetupParams{CanBroadcast: false, RoomName: "demo"})
if err != nil || setup.CanBroadcast || setup.RoomName != "" {
t.Fatalf("expected revoked setup: %+v, %v", setup, err)
}
if _, err := UpsertPersonalSetup(ctx, uuid.Nil, &PersonalSetupParams{}); errs.Code(err) != errs.NotFound {
t.Fatalf("expected missing profile: %v", err)
}
if _, err := db.Exec(ctx, `DELETE FROM user_profiles WHERE user_id = $1`, userID); err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRow(ctx, `SELECT COUNT(*) FROM personal_setup WHERE user_id = $1`, userID).Scan(&count); err != nil || count != 0 {
t.Fatalf("setup must be deleted with profile: count=%d, err=%v", count, err)
}
et.OverrideAuthInfo(auth.UID(userID.String()), &authsvc.AuthData{Role: "user"})
if _, err := GetPersonalSetup(ctx); errs.Code(err) != errs.NotFound {
t.Fatalf("expected missing profile: %v", err)
}
}

61
videochat/README.md Normal file
View File

@@ -0,0 +1,61 @@
# Broadcast LiveKit (uno a molti)
Configurare i secret Encore `LiveKitURL`, `LiveKitAPIKey` e `LiveKitAPISecret` sul server.
Il frontend usa solo il token partecipante, mai la API secret.
## Accesso
- Solo gli amministratori possono creare stanze (`POST /videochat/rooms`).
- Il creatore è l'unico proprietario; anche un altro amministratore entra come spettatore.
- Tutti gli utenti autenticati possono elencare i broadcast e richiedere un token.
- Il proprietario pubblica audio, videocamera e schermo. Gli spettatori hanno
`canPublish=false`, `canPublishData=false`, `canSubscribe=true` nel JWT firmato.
- Nessun token frontend ha permessi amministrativi o modifica metadati.
- Elenco partecipanti, disconnessione partecipanti e chiusura richiedono il proprietario.
## API
| Metodo | Percorso | Accesso |
| --- | --- | --- |
| GET | `/videochat/status` | Pubblico |
| POST | `/videochat/rooms` | Admin (diventa proprietario) |
| GET | `/videochat/rooms` | Autenticato |
| POST | `/videochat/token` | Autenticato |
| DELETE | `/videochat/rooms/:roomName` | Proprietario |
| GET | `/videochat/rooms/:roomName/participants` | Proprietario |
| DELETE | `/videochat/rooms/:roomName/participants/:identity` | Proprietario |
La lista include i broadcast presenti su LiveKit (`active: true`) e le stanze
di `personal_setup` con `can_broadcast: true` non ancora presenti (`active: false`).
Il confronto usa il nome tecnico oppure proprietario e nome visualizzato.
Le stanze inattive riportano il nome configurato, il proprietario e `mode: "broadcast"`;
SID, partecipanti e data di creazione restano vuoti o zero. Non vengono create
su LiveKit e non possono ricevere un token finché non sono avviate.
Le richieste usano `Authorization: Bearer <session-token>` o il cookie `session`.
Creazione: `{"name":"Titolo della diretta","max_participants":0,"empty_timeout":300}`.
Zero nei campi numerici usa i valori predefiniti di LiveKit; il massimo comprende
anche il proprietario. Il timeout è in secondi.
La risposta stanza aggiunge `display_name`, `owner_id`, `mode: "broadcast"`.
`name` è un identificativo univoco generato dal server: usarlo nei percorsi API e
in `POST /videochat/token` con `{"room_name":"<name restituito>"}`.
La risposta token include `role: "owner" | "viewer"`, `owner_id`, `server_url`,
`participant_token`, `identity`, `room_name`, `expires_in`.
## Ciclo di vita
Le stanze sono effimere. Titolo e proprietario sono conservati nei metadati
LiveKit per la durata della stanza, anche se Encore viene riavviato. Quando
LiveKit elimina una stanza vuota, occorre crearne una nuova; non è un archivio
persistente delle trasmissioni. I nomi interni non vengono riutilizzati.
Le vecchie stanze senza metadati broadcast non compaiono nella lista e non
ricevono nuovi token tramite questa API; non vengono eliminate automaticamente.
I vecchi token meeting già emessi mantengono i permessi fino alla loro scadenza.
La disconnessione di uno spettatore non è un ban: può rientrare. La chiusura
elimina la stanza e disconnette tutti. Su LiveKit self-hosted i JWT già emessi
non sono revocabili da queste API: fino alla scadenza (10 minuti) possono ancora
consentire accesso o ricreazione della vecchia stanza interna. Tale stanza non
avrà metadati broadcast, non apparirà nella lista e non riceverà nuovi token.
Per un blocco irrevocabile servono controllo dell'ammissione/revoca aggiuntivi.

145
videochat/livekit.go Normal file
View File

@@ -0,0 +1,145 @@
package videochat
import (
"context"
"encoding/json"
"errors"
"net/url"
"strings"
"time"
"unicode"
authsvc "encore.app/auth"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
lkauth "github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/twitchtv/twirp"
)
var secrets struct {
LiveKitURL string
LiveKitAPIKey string
LiveKitAPISecret string
}
const tokenTTL = 10 * time.Minute
const apiTimeout = 10 * time.Second
func liveKitClient() (*lksdk.RoomServiceClient, string, error) {
serverURL, err := websocketURL(secrets.LiveKitURL)
if err != nil || strings.TrimSpace(secrets.LiveKitAPIKey) == "" || strings.TrimSpace(secrets.LiveKitAPISecret) == "" {
return nil, "", &errs.Error{Code: errs.FailedPrecondition, Message: "LiveKit is not configured"}
}
return lksdk.NewRoomServiceClient(serverURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret), serverURL, nil
}
func websocketURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("invalid LiveKit URL")
}
switch u.Scheme {
case "http", "ws":
u.Scheme = "ws"
case "https", "wss":
u.Scheme = "wss"
default:
return "", errors.New("invalid LiveKit URL scheme")
}
return strings.TrimRight(u.String(), "/"), nil
}
func requireAdmin() error {
if _, ok := auth.UserID(); !ok {
return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
data, _ := auth.Data().(*authsvc.AuthData)
if data == nil || !data.Role.IsAdmin() {
return &errs.Error{Code: errs.PermissionDenied, Message: "admin role required"}
}
return nil
}
func validateRoomName(name string) error {
if strings.TrimSpace(name) == "" || name != strings.TrimSpace(name) || len(name) > 128 || strings.ContainsAny(name, "/\\") || strings.ContainsFunc(name, unicode.IsControl) {
return &errs.Error{Code: errs.InvalidArgument, Message: "room name must contain 1-128 bytes, without slashes, surrounding whitespace or control characters"}
}
return nil
}
func liveKitError(err error) error {
if err == nil {
return nil
}
code := errs.Unavailable
var rpcErr twirp.Error
if errors.As(err, &rpcErr) {
switch rpcErr.Code() {
case twirp.NotFound:
code = errs.NotFound
case twirp.InvalidArgument:
code = errs.InvalidArgument
case twirp.AlreadyExists:
code = errs.AlreadyExists
}
}
return errs.WrapCode(err, code, "LiveKit request failed")
}
// Ownership is written only by this server when creating a fresh LiveKit room.
type broadcastMetadata struct {
Mode string `json:"mode"`
OwnerID string `json:"owner_id"`
DisplayName string `json:"display_name"`
}
func readBroadcast(room *livekit.Room) (broadcastMetadata, bool) {
var metadata broadcastMetadata
err := json.Unmarshal([]byte(room.Metadata), &metadata)
return metadata, err == nil && metadata.Mode == "broadcast" && metadata.OwnerID != "" && metadata.DisplayName != ""
}
func findBroadcast(ctx context.Context, client *lksdk.RoomServiceClient, name string) (*livekit.Room, broadcastMetadata, error) {
result, err := client.ListRooms(ctx, &livekit.ListRoomsRequest{Names: []string{name}})
if err != nil {
return nil, broadcastMetadata{}, liveKitError(err)
}
for _, room := range result.Rooms {
if room.Name == name {
if metadata, ok := readBroadcast(room); ok {
return room, metadata, nil
}
}
}
return nil, broadcastMetadata{}, &errs.Error{Code: errs.NotFound, Message: "broadcast room not found"}
}
func requireRoomOwner(ctx context.Context, client *lksdk.RoomServiceClient, name string) error {
uid, ok := auth.UserID()
if !ok {
return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
_, metadata, err := findBroadcast(ctx, client, name)
if err != nil {
return err
}
if metadata.OwnerID != string(uid) {
return &errs.Error{Code: errs.PermissionDenied, Message: "room owner required"}
}
return nil
}
func signJoinToken(key, secret, identity, room string, owner bool) (string, error) {
grant := &lkauth.VideoGrant{RoomJoin: true, Room: room}
grant.SetCanPublish(owner)
grant.SetCanSubscribe(true)
grant.SetCanPublishData(false)
grant.SetCanUpdateOwnMetadata(false)
return lkauth.NewAccessToken(key, secret).
SetIdentity(identity).
SetValidFor(tokenTTL).
SetVideoGrant(grant).
ToJWT()
}

325
videochat/livekit_test.go Normal file
View File

@@ -0,0 +1,325 @@
package videochat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
authsvc "encore.app/auth"
"encore.dev/beta/errs"
"encore.dev/et"
lkauth "github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/twitchtv/twirp"
"google.golang.org/protobuf/proto"
)
func TestJoinTokenPermissions(t *testing.T) {
token, err := signJoinToken("test-key", "test-secret", "user-123", "demo", true)
if err != nil {
t.Fatal(err)
}
verifier, err := lkauth.ParseAPIToken(token)
if err != nil {
t.Fatal(err)
}
claims, grants, err := verifier.Verify("test-secret")
if err != nil {
t.Fatal(err)
}
if verifier.APIKey() != "test-key" || grants.Identity != "user-123" {
t.Fatal("unexpected token issuer or identity")
}
g := grants.Video
if g == nil || g.Room != "demo" || !g.RoomJoin || !g.GetCanPublish() || !g.GetCanSubscribe() || g.GetCanPublishData() || g.GetCanUpdateOwnMetadata() {
t.Fatalf("unexpected participant grants: %+v", g)
}
if g.RoomAdmin || g.RoomCreate || g.RoomList || g.RoomRecord {
t.Fatal("participant must not receive administrative grants")
}
if remaining := time.Until(claims.ExpiresAt.Time); remaining < tokenTTL-5*time.Second || remaining > tokenTTL {
t.Fatalf("unexpected token lifetime: %v", remaining)
}
if _, _, err := verifier.Verify("wrong-secret"); err == nil {
t.Fatal("token must reject an incorrect signing secret")
}
}
func TestValidation(t *testing.T) {
for _, name := range []string{"", " ", " demo", "demo ", "a/b", "a\\b", "a\x00b", strings.Repeat("a", 129)} {
if err := validateRoomName(name); errs.Code(err) != errs.InvalidArgument {
t.Errorf("expected invalid room name %q, got %v", name, err)
}
}
if err := validateRoomName("demo-123"); err != nil {
t.Fatal(err)
}
for _, raw := range []string{"", "example.com", "ftp://example.com", "https://user:pass@example.com", "https://example.com?key=secret"} {
if _, err := websocketURL(raw); err == nil {
t.Errorf("accepted invalid URL %q", raw)
}
}
for raw, want := range map[string]string{"https://example.com/": "wss://example.com", "http://localhost:7880": "ws://localhost:7880", "wss://example.com": "wss://example.com"} {
if got, err := websocketURL(raw); err != nil || got != want {
t.Errorf("websocketURL(%q) = %q, %v; want %q", raw, got, err, want)
}
}
}
func TestManagementRequiresAuthentication(t *testing.T) {
defer et.OverrideAuthInfo("", nil)
et.OverrideAuthInfo("", nil)
ctx := context.Background()
_, createErr := CreateRoom(ctx, &CreateRoomParams{Name: "demo"})
_, listErr := ListRooms(ctx)
deleteErr := DeleteRoom(ctx, "demo")
_, participantsErr := ListParticipants(ctx, "demo")
removeErr := RemoveParticipant(ctx, "demo", "user-456")
for _, err := range []error{createErr, listErr, deleteErr, participantsErr, removeErr} {
if errs.Code(err) != errs.Unauthenticated {
t.Fatalf("expected unauthenticated, got %v", err)
}
}
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
if _, err := CreateRoom(ctx, &CreateRoomParams{Name: "demo"}); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("only administrators can create rooms: %v", err)
}
}
func TestViewerTokenCannotPublish(t *testing.T) {
token, err := signJoinToken("test-key", "test-secret", "viewer", "demo", false)
if err != nil {
t.Fatal(err)
}
verifier, err := lkauth.ParseAPIToken(token)
if err != nil {
t.Fatal(err)
}
_, grants, err := verifier.Verify("test-secret")
if err != nil {
t.Fatal(err)
}
g := grants.Video
if g.GetCanPublish() || g.GetCanPublishData() || g.GetCanUpdateOwnMetadata() || !g.GetCanSubscribe() || g.RoomAdmin || g.RoomCreate {
t.Fatalf("unsafe viewer permissions: %+v", g)
}
}
func TestCreateTokenChecksRoomAndIdentity(t *testing.T) {
original := secrets
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
roomExists := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/twirp/livekit.RoomService/ListRooms" {
t.Errorf("unexpected LiveKit operation: %s", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
var request livekit.ListRoomsRequest
if err != nil || proto.Unmarshal(body, &request) != nil || len(request.Names) != 1 || request.Names[0] != "demo" {
t.Error("room lookup must filter by the requested name")
}
verifier, err := lkauth.ParseAPIToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if err != nil {
t.Error(err)
w.WriteHeader(http.StatusUnauthorized)
return
}
_, grants, err := verifier.Verify("test-secret")
if err != nil || grants.Video == nil || !grants.Video.RoomList {
t.Error("missing signed room-list permission")
}
w.Header().Set("Content-Type", "application/protobuf")
result := &livekit.ListRoomsResponse{}
if roomExists {
result.Rooms = []*livekit.Room{{Name: "demo", Metadata: `{"mode":"broadcast","owner_id":"owner-123","display_name":"Demo"}`}}
}
payload, err := proto.Marshal(result)
if err != nil {
t.Error(err)
}
_, _ = w.Write(payload)
}))
defer server.Close()
secrets.LiveKitURL = server.URL
secrets.LiveKitAPIKey = "test-key"
secrets.LiveKitAPISecret = "test-secret"
ctx := context.Background()
et.OverrideAuthInfo("", nil)
if _, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}); errs.Code(err) != errs.Unauthenticated {
t.Fatalf("expected unauthenticated, got %v", err)
}
et.OverrideAuthInfo("user-123", &authsvc.AuthData{Role: "user"})
response, err := CreateToken(ctx, &TokenParams{RoomName: "demo"})
if err != nil {
t.Fatal(err)
}
if response.Role != "viewer" || response.OwnerID != "owner-123" || response.Identity != "user-123" || response.ExpiresIn != 600 || !strings.HasPrefix(response.ServerURL, "ws://") {
t.Fatalf("unexpected token response: %+v", response)
}
verifier, err := lkauth.ParseAPIToken(response.ParticipantToken)
if err != nil {
t.Fatal(err)
}
_, grants, err := verifier.Verify("test-secret")
if err != nil || grants.Identity != "user-123" || grants.Video.Room != "demo" || grants.Video.GetCanPublish() {
t.Fatal("token must be bound to the authenticated user and requested room")
}
for _, role := range []string{"user", "admin"} {
if role == "admin" {
et.OverrideAuthInfo("other-admin", &authsvc.AuthData{Role: "admin"})
}
if err := requireRoomOwner(ctx, mustTestClient(t), "demo"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner must not manage rooms: %v", err)
}
if err := DeleteRoom(ctx, "demo"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner delete: %v", err)
}
if _, err := ListParticipants(ctx, "demo"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner participants: %v", err)
}
if err := RemoveParticipant(ctx, "demo", "viewer"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner removal: %v", err)
}
}
et.OverrideAuthInfo("owner-123", &authsvc.AuthData{Role: "admin"})
if err := requireRoomOwner(ctx, mustTestClient(t), "demo"); err != nil {
t.Fatal(err)
}
ownerResponse, err := CreateToken(ctx, &TokenParams{RoomName: "demo"})
if err != nil || ownerResponse.Role != "owner" {
t.Fatalf("owner token: %v", err)
}
ownerVerifier, _ := lkauth.ParseAPIToken(ownerResponse.ParticipantToken)
_, ownerGrants, err := ownerVerifier.Verify("test-secret")
if err != nil || !ownerGrants.Video.GetCanPublish() {
t.Fatal("owner must be able to publish")
}
roomExists = false
if _, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}); errs.Code(err) != errs.NotFound {
t.Fatalf("expected missing room, got %v", err)
}
}
func TestLiveKitErrorMapping(t *testing.T) {
for rpc, want := range map[twirp.ErrorCode]errs.ErrCode{twirp.NotFound: errs.NotFound, twirp.InvalidArgument: errs.InvalidArgument, twirp.Unavailable: errs.Unavailable} {
if err := liveKitError(twirp.NewError(rpc, "upstream error")); errs.Code(err) != want {
t.Errorf("expected %v, got %v", want, err)
}
}
}
func mustTestClient(t *testing.T) *lksdk.RoomServiceClient {
t.Helper()
client, _, err := liveKitClient()
if err != nil {
t.Fatal(err)
}
return client
}
func TestLegacyRoomsHaveNoInferredOwner(t *testing.T) {
for _, metadata := range []string{"", "{}", `{"mode":"broadcast"}`, `{"mode":"meeting","owner_id":"admin","display_name":"demo"}`} {
if _, ok := readBroadcast(&livekit.Room{Metadata: metadata}); ok {
t.Fatalf("accepted unmanaged room: %s", metadata)
}
}
}
// Exercise creation, listing, ownership and moderation against the real SDK's HTTP transport.
func TestBroadcastLifecycle(t *testing.T) {
useTestProfilesDB(t)
original := secrets
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
var stored *livekit.Room
removed := ""
deleted := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := io.ReadAll(r.Body)
var response proto.Message
switch r.URL.Path {
case "/twirp/livekit.RoomService/CreateRoom":
var request livekit.CreateRoomRequest
if err := proto.Unmarshal(data, &request); err != nil {
t.Error(err)
}
stored = &livekit.Room{Sid: "sid-1", Name: request.Name, Metadata: request.Metadata, MaxParticipants: request.MaxParticipants}
response = stored
case "/twirp/livekit.RoomService/ListRooms":
result := &livekit.ListRoomsResponse{}
if stored != nil {
result.Rooms = []*livekit.Room{stored, {Name: "legacy-room"}}
}
response = result
case "/twirp/livekit.RoomService/ListParticipants":
response = &livekit.ListParticipantsResponse{Participants: []*livekit.ParticipantInfo{{Sid: "viewer-sid", Identity: "viewer"}}}
case "/twirp/livekit.RoomService/RemoveParticipant":
var request livekit.RoomParticipantIdentity
if err := proto.Unmarshal(data, &request); err != nil {
t.Error(err)
}
removed = request.Identity
response = &livekit.RemoveParticipantResponse{}
case "/twirp/livekit.RoomService/DeleteRoom":
stored = nil
deleted = true
response = &livekit.DeleteRoomResponse{}
default:
t.Errorf("unexpected operation: %s", r.URL.Path)
w.WriteHeader(404)
return
}
w.Header().Set("Content-Type", "application/protobuf")
payload, _ := proto.Marshal(response)
_, _ = w.Write(payload)
}))
defer server.Close()
secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret"
ctx := context.Background()
et.OverrideAuthInfo("owner", &authsvc.AuthData{Role: "admin"})
created, err := CreateRoom(ctx, &CreateRoomParams{Name: "My broadcast", MaxParticipants: 20})
if err != nil {
t.Fatal(err)
}
if created.OwnerID != "owner" || created.DisplayName != "My broadcast" || created.Mode != "broadcast" || !strings.HasPrefix(created.Name, "broadcast-") || created.MaxParticipants != 20 {
t.Fatalf("invalid created room: %+v", created)
}
firstName := created.Name
created, err = CreateRoom(ctx, &CreateRoomParams{Name: "My broadcast"})
if err != nil || created.Name == firstName {
t.Fatal("duplicate titles must have distinct room identities")
}
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
listed, err := ListRooms(ctx)
if err != nil || len(listed.Rooms) != 1 || listed.Rooms[0].OwnerID != "owner" {
t.Fatalf("viewer must see broadcasts only: %+v, %v", listed, err)
}
if err := DeleteRoom(ctx, created.Name); errs.Code(err) != errs.PermissionDenied || deleted {
t.Fatal("viewer deleted the room")
}
if err := RemoveParticipant(ctx, created.Name, "owner"); errs.Code(err) != errs.PermissionDenied || removed != "" {
t.Fatal("viewer removed the owner")
}
et.OverrideAuthInfo("owner", &authsvc.AuthData{Role: "admin"})
members, err := ListParticipants(ctx, created.Name)
if err != nil || len(members.Participants) != 1 {
t.Fatalf("owner list participants: %v", err)
}
if err := RemoveParticipant(ctx, created.Name, "owner"); errs.Code(err) != errs.InvalidArgument {
t.Fatal("owner self-removal must be rejected")
}
if err := RemoveParticipant(ctx, created.Name, "viewer"); err != nil || removed != "viewer" {
t.Fatalf("owner remove viewer: %v", err)
}
if err := DeleteRoom(ctx, created.Name); err != nil || !deleted {
t.Fatalf("owner close: %v", err)
}
if _, err := CreateToken(ctx, &TokenParams{RoomName: created.Name}); errs.Code(err) != errs.NotFound {
t.Fatal("closed room must not receive new tokens")
}
}

79
videochat/open_room.go Normal file
View File

@@ -0,0 +1,79 @@
package videochat
import (
"context"
"encoding/json"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"github.com/livekit/protocol/livekit"
)
// OpenRoom returns an existing broadcast or provisions an enabled personal setup.
// The owner comes exclusively from administrator-managed settings, never the caller.
//
//encore:api auth method=POST path=/videochat/rooms/:roomName/open
func OpenRoom(ctx context.Context, roomName string) (*Room, error) {
if _, ok := auth.UserID(); !ok {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
if err := validateRoomName(roomName); err != nil {
return nil, err
}
client, _, err := liveKitClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
// Serialize lookup and creation across application instances for this name.
tx, err := profilesDB.Begin(ctx)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to open room")
}
defer tx.Rollback()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "videochat/open/"+roomName); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to lock room creation")
}
result, err := client.ListRooms(ctx, &livekit.ListRoomsRequest{})
if err != nil {
return nil, liveKitError(err)
}
for _, room := range result.Rooms {
if room.Name == roomName {
if _, ok := readBroadcast(room); !ok {
return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "room name is already used by an unmanaged room"}
}
return roomResponse(room), nil
}
}
// Multiple enabled owners with the same name are ambiguous: do not pick one.
var owner string
var count int
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MIN(user_id::text), '') FROM personal_setup WHERE can_broadcast = TRUE AND room_name = $1`, roomName).Scan(&count, &owner)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch configured room")
}
if count == 0 {
return nil, &errs.Error{Code: errs.NotFound, Message: "no enabled broadcast setup for this room"}
}
if count != 1 {
return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "room name is configured for more than one owner"}
}
for _, room := range result.Rooms {
if metadata, ok := readBroadcast(room); ok && metadata.OwnerID == owner && metadata.DisplayName == roomName {
return roomResponse(room), nil
}
}
metadata, err := json.Marshal(broadcastMetadata{Mode: "broadcast", OwnerID: owner, DisplayName: roomName})
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to encode room metadata")
}
room, err := client.CreateRoom(ctx, &livekit.CreateRoomRequest{Name: roomName, Metadata: string(metadata)})
if err != nil {
return nil, liveKitError(err)
}
return roomResponse(room), nil
}

129
videochat/open_room_test.go Normal file
View File

@@ -0,0 +1,129 @@
package videochat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
authsvc "encore.app/auth"
"encore.dev/beta/errs"
"encore.dev/et"
"github.com/livekit/protocol/livekit"
"google.golang.org/protobuf/proto"
)
func TestOpenRoomCreatesAndReusesConfiguredRoom(t *testing.T) {
useTestProfilesDB(t)
ctx := context.Background()
const owner = "00000000-0000-4000-8000-000000000001"
if _, err := profilesDB.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email) VALUES ($1, 'Owner', 'open@example.invalid')`, owner); err != nil {
t.Fatal(err)
}
if _, err := profilesDB.Exec(ctx, `INSERT INTO personal_setup (user_id, can_broadcast, room_name) VALUES ($1, TRUE, 'Configured room')`, owner); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var rooms []*livekit.Room
creates := 0
failList := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
var response proto.Message
switch r.URL.Path {
case "/twirp/livekit.RoomService/ListRooms":
if failList {
w.WriteHeader(503)
return
}
response = &livekit.ListRoomsResponse{Rooms: rooms}
case "/twirp/livekit.RoomService/CreateRoom":
body, _ := io.ReadAll(r.Body)
var request livekit.CreateRoomRequest
if err := proto.Unmarshal(body, &request); err != nil {
t.Error(err)
}
creates++
room := &livekit.Room{Sid: "created-sid", Name: request.Name, Metadata: request.Metadata}
rooms = append(rooms, room)
response = room
default:
t.Errorf("unexpected operation: %s", r.URL.Path)
w.WriteHeader(404)
return
}
payload, _ := proto.Marshal(response)
w.Header().Set("Content-Type", "application/protobuf")
_, _ = w.Write(payload)
}))
defer server.Close()
original := secrets
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret"
et.OverrideAuthInfo("", nil)
if _, err := OpenRoom(ctx, "Configured room"); errs.Code(err) != errs.Unauthenticated {
t.Fatalf("anonymous open: %v", err)
}
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
if _, err := OpenRoom(ctx, "unknown"); errs.Code(err) != errs.NotFound {
t.Fatalf("unconfigured room: %v", err)
}
if _, err := OpenRoom(ctx, "bad/name"); errs.Code(err) != errs.InvalidArgument {
t.Fatalf("invalid name: %v", err)
}
// Concurrent viewers may provision the configured room, but never become its owner.
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
room, err := OpenRoom(ctx, "Configured room")
if err != nil {
t.Error(err)
return
}
if !room.Active || room.OwnerID != owner || room.Name != "Configured room" || room.SID != "created-sid" {
t.Errorf("invalid room: %+v", room)
}
}()
}
wg.Wait()
if creates != 1 {
t.Fatalf("expected a single creation, got %d", creates)
}
token, err := CreateToken(ctx, &TokenParams{RoomName: "Configured room"})
if err != nil || token.Role != "viewer" || token.OwnerID != owner {
t.Fatalf("opener must remain viewer: %+v, %v", token, err)
}
mu.Lock()
failList = true
mu.Unlock()
if _, err := OpenRoom(ctx, "Configured room"); err == nil {
t.Fatal("upstream errors must not be treated as missing rooms")
}
if creates != 1 {
t.Fatal("upstream failure created a room")
}
mu.Lock()
failList = false
rooms = []*livekit.Room{{Name: "Configured room"}}
mu.Unlock()
if _, err := OpenRoom(ctx, "Configured room"); errs.Code(err) != errs.FailedPrecondition {
t.Fatalf("unmanaged collision: %v", err)
}
mu.Lock()
rooms = nil
mu.Unlock()
if _, err := profilesDB.Exec(ctx, `UPDATE personal_setup SET can_broadcast = FALSE, room_name = '' WHERE user_id = $1`, owner); err != nil {
t.Fatal(err)
}
if _, err := OpenRoom(ctx, "Configured room"); errs.Code(err) != errs.NotFound {
t.Fatalf("disabled room was created: %v", err)
}
}

84
videochat/participants.go Normal file
View File

@@ -0,0 +1,84 @@
package videochat
import (
"context"
"strings"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"github.com/livekit/protocol/livekit"
)
type Participant struct {
SID string `json:"sid"`
Identity string `json:"identity"`
Name string `json:"name"`
State string `json:"state"`
}
type ListParticipantsResponse struct {
Participants []*Participant `json:"participants"`
}
// ListParticipants lists participants in a room. Requires the room owner.
//
//encore:api auth method=GET path=/videochat/rooms/:roomName/participants
func ListParticipants(ctx context.Context, roomName string) (*ListParticipantsResponse, error) {
if _, ok := auth.UserID(); !ok {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
if err := validateRoomName(roomName); err != nil {
return nil, err
}
client, _, err := liveKitClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
if err := requireRoomOwner(ctx, client, roomName); err != nil {
return nil, err
}
result, err := client.ListParticipants(ctx, &livekit.ListParticipantsRequest{Room: roomName})
if err != nil {
return nil, liveKitError(err)
}
response := &ListParticipantsResponse{Participants: make([]*Participant, 0, len(result.Participants))}
for _, p := range result.Participants {
response.Participants = append(response.Participants, &Participant{
SID: p.Sid, Identity: p.Identity, Name: p.Name, State: p.State.String(),
})
}
return response, nil
}
// RemoveParticipant disconnects a participant. Requires the room owner.
// It does not ban the user from requesting another token.
//
//encore:api auth method=DELETE path=/videochat/rooms/:roomName/participants/:identity
func RemoveParticipant(ctx context.Context, roomName, identity string) error {
if _, ok := auth.UserID(); !ok {
return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
if err := validateRoomName(roomName); err != nil {
return err
}
if strings.TrimSpace(identity) == "" {
return &errs.Error{Code: errs.InvalidArgument, Message: "participant identity is required"}
}
client, _, err := liveKitClient()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
if err := requireRoomOwner(ctx, client, roomName); err != nil {
return err
}
uid, _ := auth.UserID()
if identity == string(uid) {
return &errs.Error{Code: errs.InvalidArgument, Message: "owner cannot remove themselves; leave or close the room instead"}
}
_, err = client.RemoveParticipant(ctx, &livekit.RoomParticipantIdentity{Room: roomName, Identity: identity})
return liveKitError(err)
}

173
videochat/rooms.go Normal file
View File

@@ -0,0 +1,173 @@
package videochat
import (
"context"
"encoding/json"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"github.com/livekit/protocol/livekit"
)
var profilesDB = sqldb.Named("profiles")
type CreateRoomParams struct {
Name string `json:"name"`
MaxParticipants uint32 `json:"max_participants"`
EmptyTimeout uint32 `json:"empty_timeout"`
}
type Room struct {
Active bool `json:"active"`
OwnerID string `json:"owner_id"`
DisplayName string `json:"display_name"`
Mode string `json:"mode"`
SID string `json:"sid"`
Name string `json:"name"`
NumParticipants uint32 `json:"num_participants"`
MaxParticipants uint32 `json:"max_participants"`
CreationTime int64 `json:"creation_time"`
}
type ListRoomsResponse struct {
Rooms []*Room `json:"rooms"`
}
func roomResponse(room *livekit.Room) *Room {
metadata, _ := readBroadcast(room)
return &Room{Active: true, OwnerID: metadata.OwnerID, DisplayName: metadata.DisplayName, Mode: metadata.Mode, SID: room.Sid, Name: room.Name, NumParticipants: room.NumParticipants,
MaxParticipants: room.MaxParticipants, CreationTime: room.CreationTime}
}
// CreateRoom creates a LiveKit room. Requires an administrator.
//
//encore:api auth method=POST path=/videochat/rooms
func CreateRoom(ctx context.Context, p *CreateRoomParams) (*Room, error) {
if err := requireAdmin(); err != nil {
return nil, err
}
if err := validateRoomName(p.Name); err != nil {
return nil, err
}
client, _, err := liveKitClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
uid, _ := auth.UserID()
id, err := uuid.NewV4()
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to create room identifier")
}
metadata, err := json.Marshal(broadcastMetadata{Mode: "broadcast", OwnerID: string(uid), DisplayName: p.Name})
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to encode room metadata")
}
room, err := client.CreateRoom(ctx, &livekit.CreateRoomRequest{
Name: "broadcast-" + id.String(), Metadata: string(metadata), MaxParticipants: p.MaxParticipants, EmptyTimeout: p.EmptyTimeout,
})
if err != nil {
return nil, liveKitError(err)
}
return roomResponse(room), nil
}
// ListRooms lists live broadcasts and enabled personal setups for authenticated viewers.
// Configured rooms absent from LiveKit are returned with active=false.
//
//encore:api auth method=GET path=/videochat/rooms
func ListRooms(ctx context.Context) (*ListRoomsResponse, error) {
if _, ok := auth.UserID(); !ok {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
client, _, err := liveKitClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
result, err := client.ListRooms(ctx, &livekit.ListRoomsRequest{})
if err != nil {
return nil, liveKitError(err)
}
response := &ListRoomsResponse{Rooms: make([]*Room, 0, len(result.Rooms))}
for _, room := range result.Rooms {
if _, ok := readBroadcast(room); ok {
response.Rooms = append(response.Rooms, roomResponse(room))
}
}
configured, err := configuredBroadcastRooms(ctx)
if err != nil {
return nil, err
}
response.Rooms = mergeBroadcastRooms(response.Rooms, configured)
return response, nil
}
func configuredBroadcastRooms(ctx context.Context) ([]*Room, error) {
rows, err := profilesDB.Query(ctx, `
SELECT user_id::text, room_name
FROM personal_setup
WHERE can_broadcast = TRUE AND room_name <> ''
ORDER BY room_name, user_id
`)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms")
}
defer rows.Close()
rooms := make([]*Room, 0)
for rows.Next() {
room := &Room{Mode: "broadcast"}
if err := rows.Scan(&room.OwnerID, &room.Name); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to read configured broadcast room")
}
room.DisplayName = room.Name
rooms = append(rooms, room)
}
if err := rows.Err(); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to list configured broadcast rooms")
}
return rooms, nil
}
func mergeBroadcastRooms(active, configured []*Room) []*Room {
type ownerTitle struct{ owner, title string }
names := make(map[string]bool, len(active))
titles := make(map[ownerTitle]bool, len(active))
for _, room := range active {
names[room.Name] = true
titles[ownerTitle{room.OwnerID, room.DisplayName}] = true
}
for _, room := range configured {
if !names[room.Name] && !titles[ownerTitle{room.OwnerID, room.Name}] {
active = append(active, room)
}
}
return active
}
// DeleteRoom closes a room and disconnects its participants. Requires the room owner.
//
//encore:api auth method=DELETE path=/videochat/rooms/:roomName
func DeleteRoom(ctx context.Context, roomName string) error {
if _, ok := auth.UserID(); !ok {
return &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
if err := validateRoomName(roomName); err != nil {
return err
}
client, _, err := liveKitClient()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
if err := requireRoomOwner(ctx, client, roomName); err != nil {
return err
}
_, err = client.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: roomName})
return liveKitError(err)
}

99
videochat/rooms_test.go Normal file
View File

@@ -0,0 +1,99 @@
package videochat
import (
"context"
"net/http"
"net/http/httptest"
"testing"
authsvc "encore.app/auth"
"encore.dev/et"
"github.com/livekit/protocol/livekit"
"google.golang.org/protobuf/proto"
)
func useTestProfilesDB(t *testing.T) {
t.Helper()
testDB, err := et.NewTestDatabase(context.Background(), "profiles")
if err != nil {
t.Fatal(err)
}
original := profilesDB
profilesDB = testDB
t.Cleanup(func() { profilesDB = original })
}
func TestListRoomsIncludesInactiveSetups(t *testing.T) {
useTestProfilesDB(t)
ctx := context.Background()
const owner = "00000000-0000-4000-8000-000000000001"
const offline = "00000000-0000-4000-8000-000000000002"
const disabled = "00000000-0000-4000-8000-000000000003"
const sameTitle = "00000000-0000-4000-8000-000000000004"
for _, id := range []string{owner, offline, disabled, sameTitle} {
if _, err := profilesDB.Exec(ctx, `INSERT INTO user_profiles (user_id, display_name, email) VALUES ($1, 'Test', $2)`, id, id+"@example.invalid"); err != nil {
t.Fatal(err)
}
}
for _, setup := range []struct {
id, name string
enabled bool
}{
{owner, "Live show", true}, {offline, "Offline show", true},
{disabled, "", false}, {sameTitle, "Live show", true},
} {
if _, err := profilesDB.Exec(ctx, `INSERT INTO personal_setup (user_id, can_broadcast, room_name) VALUES ($1, $2, $3)`, setup.id, setup.enabled, setup.name); err != nil {
t.Fatal(err)
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/twirp/livekit.RoomService/ListRooms" {
t.Errorf("unexpected operation: %s", r.URL.Path)
}
payload, err := proto.Marshal(&livekit.ListRoomsResponse{Rooms: []*livekit.Room{
{Sid: "sid-live", Name: "broadcast-generated", NumParticipants: 3,
Metadata: `{"mode":"broadcast","owner_id":"` + owner + `","display_name":"Live show"}`},
{Name: "unmanaged"},
}})
if err != nil {
t.Error(err)
}
w.Header().Set("Content-Type", "application/protobuf")
_, _ = w.Write(payload)
}))
defer server.Close()
original := secrets
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret"
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
result, err := ListRooms(ctx)
if err != nil {
t.Fatal(err)
}
if len(result.Rooms) != 3 {
t.Fatalf("expected one live and two inactive rooms: %+v", result.Rooms)
}
byOwner := make(map[string]*Room)
for _, room := range result.Rooms {
byOwner[room.OwnerID] = room
}
if room := byOwner[owner]; room == nil || !room.Active || room.Name != "broadcast-generated" || room.SID != "sid-live" || room.NumParticipants != 3 {
t.Fatalf("live room must retain its data without duplication: %+v", room)
}
for _, id := range []string{offline, sameTitle} {
if room := byOwner[id]; room == nil || room.Active || room.SID != "" || room.Mode != "broadcast" || room.Name != room.DisplayName {
t.Fatalf("expected inactive configured room: %+v", room)
}
}
}
func TestMergeBroadcastRoomsMatchesTechnicalName(t *testing.T) {
live := &Room{Active: true, Name: "broadcast-123", OwnerID: "owner", DisplayName: "Title"}
result := mergeBroadcastRooms([]*Room{live}, []*Room{{Name: "broadcast-123", OwnerID: "owner"}})
if len(result) != 1 || result[0] != live {
t.Fatal("technical room name must not produce a duplicate")
}
if got := mergeBroadcastRooms([]*Room{}, nil); got == nil || len(got) != 0 {
t.Fatal("empty list must stay an empty JSON array")
}
}

57
videochat/tokens.go Normal file
View File

@@ -0,0 +1,57 @@
package videochat
import (
"context"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
)
type TokenParams struct {
RoomName string `json:"room_name"`
}
type TokenResponse struct {
OwnerID string `json:"owner_id"`
Role string `json:"role"`
ServerURL string `json:"server_url"`
ParticipantToken string `json:"participant_token" encore:"sensitive"`
RoomName string `json:"room_name"`
Identity string `json:"identity"`
ExpiresIn int64 `json:"expires_in"`
}
// CreateToken grants the authenticated user access to an existing room by name.
//
//encore:api auth method=POST path=/videochat/token
func CreateToken(ctx context.Context, p *TokenParams) (*TokenResponse, error) {
uid, ok := auth.UserID()
if !ok {
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "must be logged in"}
}
if err := validateRoomName(p.RoomName); err != nil {
return nil, err
}
client, serverURL, err := liveKitClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
_, metadata, err := findBroadcast(ctx, client, p.RoomName)
if err != nil {
return nil, err
}
token, err := signJoinToken(secrets.LiveKitAPIKey, secrets.LiveKitAPISecret, string(uid), p.RoomName, metadata.OwnerID == string(uid))
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to generate LiveKit token")
}
role := "viewer"
if metadata.OwnerID == string(uid) {
role = "owner"
}
return &TokenResponse{OwnerID: metadata.OwnerID, Role: role,
ServerURL: serverURL, ParticipantToken: token, RoomName: p.RoomName,
Identity: string(uid), ExpiresIn: int64(tokenTTL.Seconds()),
}, nil
}

15
videochat/videochat.go Normal file
View File

@@ -0,0 +1,15 @@
// Service videochat provides video chat APIs.
package videochat
import "context"
type StatusResponse struct {
Status string `json:"status"`
}
// Status reports whether the videochat service is available.
//
//encore:api public method=GET path=/videochat/status
func Status(ctx context.Context) (*StatusResponse, error) {
return &StatusResponse{Status: "ok"}, nil
}