Author SHA1 Message Date
talksik 590edcf00f brainstorm client components 2026-03-14 09:34:11 -07:00
496 changed files with 6464 additions and 51385 deletions
-85
View File
@@ -1,85 +0,0 @@
name: Deploy (reusable)
on:
workflow_call:
inputs:
module:
description: "Skaffold module: orion, worker, pusher, emailnotifierjob, memberreconciler, or migrations"
required: true
type: string
environment:
description: "Target environment: dev or prod"
required: true
type: string
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
defaults:
run:
working-directory: go
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go/go.mod
cache-dependency-path: go/go.sum
- name: Resolve environment settings
id: env
run: |
case "${{ inputs.environment }}" in
dev)
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
;;
prod)
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
;;
*)
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
;;
esac
echo "region=us-west2" >> "$GITHUB_OUTPUT"
- name: Run tests
run: go test ./...
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- name: Install gke-gcloud-auth-plugin
run: gcloud components install gke-gcloud-auth-plugin --quiet
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
- name: Get GKE credentials
run: |
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
--region "${{ steps.env.outputs.region }}" \
--project "${{ steps.env.outputs.project }}"
- name: Install skaffold
run: |
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
sudo install skaffold /usr/local/bin/
skaffold version
- name: Deploy
env:
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
run: |
if [ "${{ inputs.module }}" = "migrations" ]; then
kubectl delete job migrations --ignore-not-found
skaffold run -p migrations --tail
else
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
fi
-80
View File
@@ -1,80 +0,0 @@
name: Build Windows
on:
workflow_dispatch:
permissions:
# Azure Trusted Signing uses OIDC federated credentials from GitHub.
id-token: write
contents: read
jobs:
build:
runs-on: windows-latest
defaults:
run:
working-directory: js/desktop
env:
APP_ENV: prod
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
shell: pwsh
working-directory: .
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
cache-dependency-path: js/desktop/yarn.lock
- name: Install dependencies
run: yarn install --frozen-lockfile --network-timeout 600000
- name: Azure login (OIDC)
uses: azure/login@v2
with:
tenant-id: ${{ vars.AZURE_TENANT_ID }}
client-id: ${{ vars.AZURE_CLIENT_ID }}
allow-no-subscriptions: true
- name: Install Trusted Signing client dlib
shell: pwsh
working-directory: .
run: |
nuget install Microsoft.Trusted.Signing.Client -Version 1.0.60 -OutputDirectory $env:RUNNER_TEMP\trusted-signing -ExcludeVersion
$dlib = Join-Path $env:RUNNER_TEMP "trusted-signing\Microsoft.Trusted.Signing.Client\bin\x64\Azure.CodeSigning.Dlib.dll"
if (-not (Test-Path $dlib)) { throw "Dlib not found at $dlib" }
"AZURE_DLIB_PATH=$dlib" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"AZURE_METADATA_JSON_PATH=$env:GITHUB_WORKSPACE\js\desktop\signing-metadata.json" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Locate signtool.exe
shell: pwsh
working-directory: .
run: |
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending |
Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found in Windows Kits" }
"SIGNTOOL_PATH=$($signtool.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.PROD_GKE_SERVICE_ACCOUNT_KEY }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Publish signed installer (x64)
run: yarn publish:win
- name: Invalidate RELEASES cache
shell: pwsh
working-directory: .
run: gsutil setmeta -h "Cache-Control:no-cache, no-store, must-revalidate" gs://flowy-releases/llink/win32/x64/RELEASES
@@ -1,22 +0,0 @@
name: Deploy emailnotifierjob
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-emailnotifierjob-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: emailnotifierjob
environment: ${{ inputs.environment }}
secrets: inherit
-72
View File
@@ -1,72 +0,0 @@
name: Deploy llink-web
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-llink-web-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
defaults:
run:
working-directory: js/desktop
steps:
- uses: actions/checkout@v4
- name: Resolve environment settings
id: env
run: |
case "${{ inputs.environment }}" in
dev)
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
;;
prod)
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
;;
*)
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
;;
esac
echo "region=us-west2" >> "$GITHUB_OUTPUT"
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- name: Install gke-gcloud-auth-plugin
run: gcloud components install gke-gcloud-auth-plugin --quiet
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
- name: Get GKE credentials
run: |
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
--region "${{ steps.env.outputs.region }}" \
--project "${{ steps.env.outputs.project }}"
- name: Install skaffold
run: |
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
sudo install skaffold /usr/local/bin/
skaffold version
- name: Deploy
env:
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
run: skaffold run -p ${{ inputs.environment }}
@@ -1,22 +0,0 @@
name: Deploy memberreconciler
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-memberreconciler-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: memberreconciler
environment: ${{ inputs.environment }}
secrets: inherit
-22
View File
@@ -1,22 +0,0 @@
name: Deploy migrations
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-migrations-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: migrations
environment: ${{ inputs.environment }}
secrets: inherit
-22
View File
@@ -1,22 +0,0 @@
name: Deploy orion
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-orion-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: orion
environment: ${{ inputs.environment }}
secrets: inherit
@@ -1,22 +0,0 @@
name: Deploy worker (particleprocessorworker)
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-worker-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: particleprocessor
environment: ${{ inputs.environment }}
secrets: inherit
-22
View File
@@ -1,22 +0,0 @@
name: Deploy pusher
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-pusher-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
uses: ./.github/workflows/_deploy.yml
with:
module: pusher
environment: ${{ inputs.environment }}
secrets: inherit
@@ -1,36 +0,0 @@
name: Golang format and test
on:
pull_request:
paths:
- "go/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
verify:
name: Verify
runs-on: ubuntu-latest
defaults:
run:
working-directory: go
steps:
- name: Check out repository code
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go/go.mod
cache-dependency-path: go/go.sum
- name: Verify gofmt
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "The following files are not formatted correctly:"
gofmt -l .
exit 1
fi
- name: Run tests
run: go test ./...
-71
View File
@@ -1,71 +0,0 @@
name: PR Quality Gate (client applications)
on:
pull_request:
paths:
- "js/mobile/**"
- "js/desktop/**"
branches: [ main ]
types: [ opened, synchronize, reopened ]
jobs:
mobile:
name: Lint & format check (mobile)
runs-on: ubuntu-latest
defaults:
run:
working-directory: js/mobile
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Run Code Linter
run: yarn lint
- name: Run Format Check
run: yarn format:check
desktop:
name: Lint & format check (desktop)
runs-on: ubuntu-latest
defaults:
run:
working-directory: js/desktop
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Typecheck
run: yarn typecheck
- name: Run Code Linter
run: yarn lint
- name: Run Format Check
run: yarn format:check
-1
View File
@@ -4,4 +4,3 @@ build/
.cache/
compile_commands.json
CMakeLists.txt.user
tags
+1 -3
View File
@@ -11,9 +11,7 @@ Whenever implementing anything, make sure to take into account best practices wi
### Quality
We care about overall architectural quality and keeping consistent patterns according to best practices.
Another tradeoff we make is simpler, maintainable code over clever behavior.
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
As an example, we have as high of a bar as a product team like Linear, which outputs high quality software. Let's avoid slop at all costs.
### Package Manager
- Use **yarn** (not npm) for all dependency management
-24
View File
@@ -1,24 +0,0 @@
# Deployment Runbooks
## Desktop + web
1. Create a new branch
2. Bump version in `package.json`
3. Create a pull request and merge
4. Manually trigger github action to deploy llink-web, windows desktop
5. Deploy macOS desktop app from a local mac computer
6. Create a pull request in the `research-site` repo to update the download links
## Mobile
1. Create a new branch
2. Bump version in `package.json`
3. Create a pull request and merge
4. Switch to main and pull latest locally (on any machine with expo installed)
5. Manually run `yarn publish:ios`
## Golang services
1. Create a new branch
2. Make changes
3. Create a pull request
4. Deploy the appropriate github action for the service you're touching (input the branch name and dev)
5. Test with the dev deployment
6. Merge, then deploy the corresponding prod service
-20
View File
@@ -1,20 +0,0 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/emailnotifierjob
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
WORKDIR /app
COPY --from=first-stage /app/cmd/emailnotifierjob .
RUN echo "copied over binary to production stage"
CMD ["./main"]
-20
View File
@@ -1,20 +0,0 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/memberreconciler
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
WORKDIR /app
COPY --from=first-stage /app/cmd/memberreconciler .
RUN echo "copied over binary to production stage"
CMD ["./main"]
-22
View File
@@ -1,22 +0,0 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/particleprocessorworker
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
RUN apk add --no-cache ffmpeg ca-certificates
WORKDIR /app
COPY --from=first-stage /app/cmd/particleprocessorworker .
RUN echo "copied over binary to production stage"
CMD ["./main"]
-22
View File
@@ -1,22 +0,0 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/pusherservice
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
# for health check
RUN apk --no-cache add curl
WORKDIR /app
COPY --from=first-stage /app/cmd/pusherservice .
RUN echo "copied over binary to production stage"
CMD ["./main"]
-22
View File
@@ -1,22 +0,0 @@
# golang two stage build
FROM golang:1.25 AS first-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
WORKDIR /app/cmd/transcodebackfill
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
RUN ls
FROM alpine:latest AS second-stage
RUN apk add --no-cache ffmpeg ca-certificates
WORKDIR /app
COPY --from=first-stage /app/cmd/transcodebackfill .
RUN echo "copied over binary to production stage"
CMD ["./main"]
+24 -32
View File
@@ -8,13 +8,19 @@ generate:
test:
go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
.PHONY: migrate-dev-db
migrate-dev-db:
./migrate_dev.sh
# .PHONY: migrate-prod-db
# migrate-prod-db:
# ./migrate_prod.sh
# ex: make create-migration ARG="create_users_table"
create-migration:
echo "Creating migration with name $(ARG)"
docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations $(ARG)
# ---- Database access ----
dev-database:
kubectl run postgres-client \
--rm -it --image=postgres:latest \
@@ -22,37 +28,23 @@ dev-database:
--context dev \
--command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
prod-database:
kubectl run postgres-client \
--rm -it --image=postgres:latest \
--env="LLINK_POSTGRES_CONNECTION_URL=$$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
--context prod \
--command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
# ---- Migrations ----
# NOTE: if you run into issues with 'dirty' migrations: https://github.com/golang-migrate/migrate/issues/282#issuecomment-530743258
DEV_REPO := us-west2-docker.pkg.dev/flowy-dev-440017/deployments
PROD_REPO := us-west2-docker.pkg.dev/flowy-prod-440017/deployments
.PHONY: migrate-dev
migrate-dev:
kubectl delete job migrations --ignore-not-found --context=dev
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev --tail
.PHONY: migrate-prod
migrate-prod:
kubectl delete job migrations --ignore-not-found --context=prod
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail
# ---- Deploy ----
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
# make deploy-dev MODULE=orion
# prod-database:
# kubectl run postgres-client \
# --rm -it --image=postgres:latest \
# --env="LLINK_POSTGRES_CONNECTION_URL=$$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
# --context prod \
# --command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
.PHONY: deploy-dev
deploy-dev: generate test
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p dev $(if $(MODULE),-m $(MODULE)) --kube-context dev --port-forward --tail
./deploy_dev.sh
.PHONY: deploy-prod
deploy-prod: generate test
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p prod $(if $(MODULE),-m $(MODULE)) --kube-context prod --port-forward --tail
# .PHONY: release
# deploy-prod: generate test
# # Check for any changes (both staged and unstaged)
# @if [ -n "$(git status --porcelain)" ]; then \
# echo "Error: You have uncommitted changes in your Git repository."; \
# exit 1; \
# fi
# ./deploy_prod.sh
#
-34
View File
@@ -1,34 +0,0 @@
# Orion
API server, jobs, and worker services for llink.
## Deploy
```bash
# Deploy all services
make deploy-dev
make deploy-prod
# Deploy a single service
make deploy-dev MODULE=orion
make deploy-dev MODULE=particleprocessor
make deploy-dev MODULE=pusher
make deploy-dev MODULE=emailnotifierjob
```
## Migrations
```bash
make migrate-dev
make migrate-prod
# Create a new migration
make create-migration ARG="create_users_table"
```
## Database Access
```bash
make dev-database
make prod-database
```
-99
View File
@@ -1,99 +0,0 @@
version: '3'
vars:
DEV_REPO: us-west2-docker.pkg.dev/flowy-dev-440017/deployments
PROD_REPO: us-west2-docker.pkg.dev/flowy-prod-440017/deployments
tasks:
generate:
desc: Generate protobuf code and run go generate
cmds:
- task: genproto
- go generate ./...
format:
desc: Format go files
cmds:
- go fmt ./...
genproto:
desc: Generate Go code from .proto files via docker
vars:
PROTO_DIR: '{{.PROTO_DIR | default "./protocol"}}'
OUT_DIR: '{{.OUT_DIR | default "./genproto"}}'
cmds:
- 'echo "Proto directory: {{.PROTO_DIR}}"'
- 'echo "Output directory: {{.OUT_DIR}}"'
- rm -rf {{.OUT_DIR}}
- mkdir -p {{.OUT_DIR}}
- |
docker run --rm \
-v "{{.PROTO_DIR}}/":/protocol \
-v "{{.OUT_DIR}}":/genproto \
--workdir / \
talksik/golang-protoc:latest \
sh -c 'protoc --proto_path=./protocol \
--go_out=./genproto \
--go_opt=paths=source_relative \
--go-grpc_out=./genproto \
--go-grpc_opt=paths=source_relative \
$(find ./protocol -name "*.proto")'
test:
desc: Run tests with pretty output via gotestfmt
cmds:
- go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
create-migration:
desc: 'Create new migration (usage: task create-migration ARG=create_users_table)'
cmds:
- 'echo "Creating migration with name {{.ARG}}"'
- docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations {{.ARG}}
dev-database:
desc: Open psql shell against the dev database
interactive: true
cmds:
- |
kubectl run postgres-client \
--rm -it --image=postgres:latest \
--env="LLINK_POSTGRES_CONNECTION_URL=$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context dev | base64 --decode)" \
--context dev \
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
prod-database:
desc: Open psql shell against the prod database
interactive: true
cmds:
- |
kubectl run postgres-client \
--rm -it --image=postgres:latest \
--env="LLINK_POSTGRES_CONNECTION_URL=$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
--context prod \
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
migrate-dev:
desc: Run migrations against dev cluster
cmds:
- kubectl delete job migrations --ignore-not-found --context=dev
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p migrations --kube-context dev --tail
migrate-prod:
desc: Run migrations against prod cluster
cmds:
- kubectl delete job migrations --ignore-not-found --context=prod
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p migrations --kube-context prod --tail
deploy-dev:
desc: 'Deploy to dev (optionally MODULE=orion to deploy a single service)'
cmds:
- task: generate
- task: test
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p dev {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context dev --port-forward --tail
deploy-prod:
desc: 'Deploy to prod (optionally MODULE=orion to deploy a single service)'
cmds:
- task: generate
- task: test
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p prod {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context prod --port-forward --tail
-247
View File
@@ -1,247 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pbaero "github.com/flowy-live/llink/genproto/aero"
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
)
const (
maxActivityAge = 24 * time.Hour // ignore streams idle longer than this
unreadThreshold = 10 * time.Minute // grace window before a message is "unread"
emailCooldown = 12 * time.Hour // min gap between emails to the same user
)
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
gcpProject := utils.MustGetEnv("GCP_PROJECT")
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer firestoreClient.Close()
aeroAddr := utils.MustGetEnv("AERO_ADDR")
aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
flog.Error("failed to connect to aero", "error", err)
os.Exit(1)
}
defer aeroConn.Close()
aeroSvc := pbaero.NewPrimaryClient(aeroConn)
pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR")
pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
flog.Error("failed to connect to pusher", "error", err)
os.Exit(1)
}
defer pusherConn.Close()
pusherSvc := pbpusher.NewPusherServiceClient(pusherConn)
humanSvc := human.NewService(db.Pool())
networkSvc := network.NewReader(db.Pool())
flog.Info("starting email notification cycle")
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
flog.Error("notification cycle failed", "error", err)
os.Exit(1)
}
flog.Info("email notification cycle complete")
}
func runNotificationCycle(
ctx context.Context,
fsClient *firestore.Client,
aeroSvc pbaero.PrimaryClient,
pusherSvc pbpusher.PusherServiceClient,
humanSvc human.Service,
networkReader network.Reader,
) error {
now := time.Now()
networks, err := networkReader.ListAll(ctx)
if err != nil {
return fmt.Errorf("listing networks: %w", err)
}
allHumans, err := humanSvc.ListAll(ctx)
if err != nil {
return fmt.Errorf("listing humans: %w", err)
}
humansById := make(map[string]*human.Human, len(allHumans))
for _, h := range allHumans {
humansById[h.ID] = h
}
behindCounts := map[string]int{}
latestActivity := map[string]time.Time{}
allOnline := map[string]bool{}
onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{})
if err != nil {
return fmt.Errorf("failed to get online humans: %w", err)
}
flog.Info("gathered online presence", "onlineCount", len(onlineResp.HumanIds), "humanIds", onlineResp.HumanIds)
for _, id := range onlineResp.HumanIds {
allOnline[id] = true
}
for _, net := range networks {
streams, err := getOpenStreams(ctx, fsClient, net.ID)
if err != nil {
flog.Error("failed to query streams", "networkId", net.ID, "error", err)
continue
}
// net.MemberHumanIds already includes the admin.
for _, stream := range streams {
if stream.LastChildCreatedAt == nil {
continue
}
if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge {
continue
}
if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold {
continue
}
for _, humanId := range network.ResolveVisibility(stream.VisibleTo, net.MemberHumanIds) {
marker, hasMarker := stream.PlaybackMarkers[humanId]
if hasMarker && !marker.Before(*stream.LastChildCreatedAt) {
continue
}
behindCounts[humanId]++
if stream.LastChildCreatedAt.After(latestActivity[humanId]) {
latestActivity[humanId] = *stream.LastChildCreatedAt
}
}
}
}
sentCount := 0
for humanId, count := range behindCounts {
if allOnline[humanId] {
flog.Info("human online...skipping email", "humanId", humanId)
continue
}
h, ok := humansById[humanId]
if !ok {
continue
}
if !h.EmailNotificationsEnabled {
continue
}
// Skip if nothing new since the previous email.
if h.LastEmailNotificationSentAt != nil && !latestActivity[humanId].After(*h.LastEmailNotificationSentAt) {
continue
}
if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown {
continue
}
if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil {
flog.Error("failed to send email", "humanId", humanId, "error", err)
continue
}
if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil {
flog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err)
}
sentCount++
}
flog.Info("notification cycle summary",
"networks", len(networks),
"humansBehind", len(behindCounts),
"emailsSent", sentCount,
)
return nil
}
func getOpenStreams(ctx context.Context, client *firestore.Client, networkId string) ([]particle.FirestoreStreamParticle, error) {
collPath := fmt.Sprintf("networks/%s/children", networkId)
docs, err := client.Collection(collPath).
Where("type", "==", "stream").
Where("status", "==", "open").
Documents(ctx).
GetAll()
if err != nil {
return nil, err
}
streams := make([]particle.FirestoreStreamParticle, 0, len(docs))
for _, doc := range docs {
var s particle.FirestoreStreamParticle
if err := doc.DataTo(&s); err != nil {
flog.Warn("failed to unmarshal stream particle", "docId", doc.Ref.ID, "error", err)
continue
}
streams = append(streams, s)
}
return streams, nil
}
func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error {
streamsWord := "stream"
if streamCount != 1 {
streamsWord = "streams"
}
subject := fmt.Sprintf("You have unseen messages in %d %s", streamCount, streamsWord)
html := buildEmailHTML(h.EmailPrefix, streamCount, streamsWord)
_, err := aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{h.Email},
Subject: subject,
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
SimpleHtmlData: &pbaero.SimpleHtmlData{
Html: html,
},
},
})
return err
}
func buildEmailHTML(name string, count int, streamsWord string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
<p>Hi %s,</p>
<p>You have unread messages in <strong>%d %s</strong> on Flowy.llink.</p>
<p>Open the app to catch up with your team.</p>
<p style="margin: 24px 0;">
<a href="https://llink.flowy.live" style="display: inline-block; background: #1a1a1a; color: #ffffff; text-decoration: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 500;">Open Flowy.llink</a>
</p>
<p style="color: #666; font-size: 13px; margin-top: 32px;">
Best,<br>Flowy Team <br>Note: disable email notifications from in-app settings.
</p>
</body>
</html>`, name, count, streamsWord)
}
-135
View File
@@ -1,135 +0,0 @@
// memberreconciler reconciles the Firestore membership mirror
// (humans/{humanId}.networks) against the authoritative Postgres
// network_members table. Safe to run on a cron — only humans whose mirrored
// set differs from Postgres are written, so a steady-state run is nearly free.
package main
import (
"context"
"os"
"slices"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"google.golang.org/api/iterator"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/utils"
)
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
gcpProject := utils.MustGetEnv("GCP_PROJECT")
fs, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer fs.Close()
humanSvc := human.NewService(db.Pool())
networkSvc := network.NewReader(db.Pool())
started := time.Now()
written, scanned, err := reconcile(ctx, fs, humanSvc, networkSvc)
if err != nil {
flog.Error("reconciliation failed", "error", err, "elapsed", time.Since(started))
os.Exit(1)
}
flog.Info("reconciliation complete",
"humans_scanned", scanned,
"humans_written", written,
"elapsed", time.Since(started),
)
}
func reconcile(
ctx context.Context,
fs *firestore.Client,
humanSvc human.Service,
networkReader network.Reader,
) (written, scanned int, err error) {
humans, err := humanSvc.ListAll(ctx)
if err != nil {
return 0, 0, err
}
memberships, err := networkReader.ListAllMemberships(ctx)
if err != nil {
return 0, 0, err
}
current, err := snapshotMirror(ctx, fs)
if err != nil {
return 0, 0, err
}
bulk := fs.BulkWriter(ctx)
for _, h := range humans {
scanned++
desired := memberships[h.ID]
if desired == nil {
desired = []string{}
}
if sameSet(current[h.ID], desired) {
continue
}
if _, err := bulk.Set(fs.Collection("humans").Doc(h.ID), map[string]any{
"networks": desired,
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll); err != nil {
return written, scanned, err
}
written++
}
bulk.End()
return written, scanned, nil
}
// One iterator, N billed reads — returns humanId → mirrored networks.
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
out := map[string][]string{}
iter := fs.Collection("humans").Documents(ctx)
defer iter.Stop()
for {
doc, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, err
}
var data struct {
Networks []string `firestore:"networks"`
}
if err := doc.DataTo(&data); err != nil {
flog.Warn("skipping malformed mirror doc", "id", doc.Ref.ID, "error", err)
continue
}
out[doc.Ref.ID] = data.Networks
}
return out, nil
}
// Set equality (order- and duplicate-insensitive); Firestore array ops don't
// preserve order.
func sameSet(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
ac := slices.Clone(a)
bc := slices.Clone(b)
slices.Sort(ac)
slices.Sort(bc)
ac = slices.Compact(ac)
bc = slices.Compact(bc)
return slices.Equal(ac, bc)
}
+47 -108
View File
@@ -3,118 +3,83 @@ package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
firebase "firebase.google.com/go/v4"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/handler"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/livekit"
"github.com/flowy-live/llink/internal/livestore"
"github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
"github.com/flowy-live/llink/internal/waitlist"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
// FIX: Use separate redis instance. We start with higher number because use this same instance in helios.
REDIS_DATABASE_FOR_AUTH int = 4
)
func redisForAuth() *redis.Client {
return internal.ConnectAndTestRedis(db.RedisDBAuth)
return internal.ConnectAndTestRedis(REDIS_DATABASE_FOR_AUTH)
}
func main() {
port := utils.MustGetEnv("PORT")
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
// Initialize database
db.Init()
defer db.Cleanup()
// Initialize Redis for auth
redisClient := redisForAuth()
// Initialize GCS client
ctx := context.Background()
storageClient, err := storage.NewClient(ctx)
if err != nil {
flog.Error("failed to create GCS client", "error", err)
slog.Error("failed to create GCS client", "error", err)
os.Exit(1)
}
defer storageClient.Close()
aeroAddr := utils.MustGetEnv("AERO_ADDR")
if aeroAddr == "" {
flog.Error("must provide AERO_ADDR")
slog.Error("must provide AERO_ADDR")
os.Exit(1)
}
aeroServer, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
flog.Error("connection to aero server invalid", "error", err)
slog.Error("connection to aero server invalid", "error", err)
os.Exit(1)
}
defer aeroServer.Close()
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
gcpProject := utils.MustGetEnv("GCP_PROJECT")
fbApp, err := firebase.NewApp(ctx, &firebase.Config{ProjectID: gcpProject})
if err != nil {
flog.Error("failed to init Firebase Admin app", "error", err)
os.Exit(1)
}
fbAuth, err := fbApp.Auth(ctx)
if err != nil {
flog.Error("failed to create Firebase auth client", "error", err)
os.Exit(1)
}
authSvc := auth.NewAuthService(redisClient, aeroSvc, fbAuth)
// Initialize services
authSvc := auth.NewAuthService(redisClient, aeroSvc)
humanSvc := human.NewService(db.Pool())
billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{
SecretKey: utils.MustGetEnv("STRIPE_SECRET_KEY"),
WebhookSecret: utils.MustGetEnv("STRIPE_WEBHOOK_SECRET"),
PriceMonthlyID: utils.MustGetEnv("STRIPE_PRICE_PRO_MONTHLY"),
PriceAnnualID: utils.MustGetEnv("STRIPE_PRICE_PRO_ANNUAL"),
SuccessURL: utils.MustGetEnv("BILLING_SUCCESS_URL"),
CancelURL: utils.MustGetEnv("BILLING_CANCEL_URL"),
})
if err != nil {
flog.Error("failed to initialize billing service", "error", err)
os.Exit(1)
}
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer firestoreClient.Close()
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient), humanSvc)
networkSvc := network.NewService(db.Pool())
particleSvc := particle.NewService(db.Pool(), networkSvc)
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
BucketName: gcsBucket,
})
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
pushTokenSvc := pushnotify.NewService(db.Pool())
livekitClient := livekit.NewClient()
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient)
// Initialize handler
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc)
// Helper to wrap handlers with auth middleware
withAuth := func(hf http.HandlerFunc) http.Handler {
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
}
@@ -132,9 +97,6 @@ func main() {
})
mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode)
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook)
mux.HandleFunc("POST /webhooks/stripe", h.HandleStripeWebhook)
// ==========================================================================
// Protected routes (auth required)
@@ -143,70 +105,47 @@ func main() {
// Auth
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken))
// Settings
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
// Push notification tokens (per-device)
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken))
// Bootstrap startup data
mux.Handle("GET /startup", withAuth(h.StartupData))
// Networks
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
mux.Handle("GET /networks", withAuth(h.ListNetworks))
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
// TODO: what about members who are part of streams visibility within this network?
mux.Handle("DELETE /networks/{id}/members/{email}", withAuth(h.RemoveMemberFromNetwork))
mux.Handle("PUT /networks/{id}/capacity", withAuth(h.SetOpenStreamCapacity))
// Billing (network admin only; admin check happens inside each handler)
mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession))
mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession))
// Freemium usage (any network member)
mux.Handle("GET /networks/{id}/usage", withAuth(h.GetNetworkUsage))
// Network Invitations
mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork))
mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation))
mux.Handle("GET /invitations", withAuth(h.ListMyInvitations))
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
// Streams
mux.Handle("POST /networks/{network_id}/streams", withAuth(h.CreateStream))
mux.Handle("GET /streams/{id}", withAuth(h.GetStream))
mux.Handle("PATCH /streams/{id}", withAuth(h.UpdateStream))
mux.Handle("POST /streams/{id}/particles", withAuth(h.CreateStreamParticle))
mux.Handle("POST /streams/{id}/open", withAuth(h.OpenStream))
mux.Handle("POST /streams/{id}/close", withAuth(h.CloseStream))
mux.Handle("POST /streams/{id}/members", withAuth(h.AddMembers))
mux.Handle("DELETE /streams/{id}/members", withAuth(h.RemoveMembers))
// Particles
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
// Link metadata
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
mux.Handle("GET /networks/{network_id}/particles", withAuth(h.ListParticles))
mux.Handle("GET /particles/{id}", withAuth(h.GetParticle))
mux.Handle("PATCH /particles/{id}", withAuth(h.UpdateParticle))
mux.Handle("DELETE /particles/{id}", withAuth(h.DeleteParticle))
mux.Handle("POST /particles/{id}/seen", withAuth(h.MarkSeen))
mux.Handle("POST /particles/{id}/ack", withAuth(h.AckParticle))
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticle))
mux.Handle("POST /particles/seen", withAuth(h.MarkSeenBatch))
// Depot
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
// LiveKit
mux.Handle("POST /livekit/token", withAuth(h.GetLivekitToken))
// Waitlist (admin-only)
mux.Handle("GET /waitlist", withAuth(h.GetWaitlist))
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
// CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client.
// Empty / unset = allow all
var allowedOrigins []string
if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" {
for _, o := range strings.Split(raw, ",") {
if o = strings.TrimSpace(o); o != "" {
allowedOrigins = append(allowedOrigins, o)
}
}
}
muxWithCors := middleware.CORS(allowedOrigins)(mux)
addr := fmt.Sprintf("0.0.0.0:%s", port)
flog.Info("running server", "addr", addr)
if err := http.ListenAndServe(addr, muxWithCors); err != nil {
flog.Error("server failed", "error", err)
slog.Info("running server", "addr", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}
-446
View File
@@ -1,446 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/speech"
"github.com/flowy-live/llink/internal/utils"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
)
func createClient(ctx context.Context) *firestore.Client {
projectId := utils.MustGetEnv("GCP_PROJECT")
client, err := firestore.NewClient(ctx, projectId)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
return client
}
// Listens for new particles and runs per-particle side effects: transcripts,
// transcode, parent stream's last_child_created_at, freemium usage, and push
// notifications for offline recipients.
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
processingRepo := particle.NewProcessingRepository(db.Pool())
billingSvc := billing.NewServiceForWorker(db.Pool())
storageClient, err := storage.NewClient(ctx)
if err != nil {
flog.Error("failed to create GCS client", "error", err)
os.Exit(1)
}
defer storageClient.Close()
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
BucketName: gcsBucket,
})
speechSvc := speech.NewSpeechService(ctx)
humanSvc := human.NewService(db.Pool())
networkReader := network.NewReader(db.Pool())
pushTokenSvc := pushnotify.NewService(db.Pool())
// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
// project (otherwise anyone holding one of our push tokens could spam users).
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient)
client := createClient(ctx)
defer client.Close()
cutoff := time.Now().Add(-5 * time.Minute)
it := client.CollectionGroup("children").
Where("created_at", ">", cutoff).
Snapshots(ctx)
for {
snap, err := it.Next()
if e := status.Code(err); e == codes.DeadlineExceeded || e == codes.Canceled {
panic(fmt.Errorf("error: %w", err))
}
if err != nil {
flog.Error("error in processing snapshot", "error", err)
continue
}
if snap == nil {
continue
}
for _, change := range snap.Changes {
if change.Kind != firestore.DocumentAdded {
continue
}
particleID := change.Doc.Ref.ID
processed, err := processingRepo.IsProcessed(ctx, particleID)
if err != nil {
flog.Error("failed to check processing status", "particleID", particleID, "error", err)
continue
}
if processed {
flog.Debug("skipping already processed particle", "particleID", particleID)
continue
}
flog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
// Side effects below are best-effort — failures don't prevent
// marking the particle as processed.
parentDoc := loadParentParticle(ctx, change.Doc)
updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
particle.Transcode(ctx, depotSvc, change.Doc)
recordFreemiumUsage(ctx, billingSvc, change.Doc)
notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
flog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
}
}
}
}
// Writes the structured transcript to Firestore and returns the raw text;
// returns "" for non-media particles or on any error (logged internally).
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string {
var mediaParticle particle.FirestoreMediaParticle
err := doc.DataTo(&mediaParticle)
if err != nil {
flog.Error("unable to marshal particle data", "error", err)
return ""
}
particleType, err := particle.ParseParticleType(mediaParticle.Type)
if err != nil {
flog.Error("invalid particle type", "error", err)
return ""
}
if particleType != particle.TypeMedia {
flog.Info("received a particle of type", "particle type", particleType)
return ""
}
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
if err != nil {
flog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
return ""
}
result, err := speechSvc.Transcribe(ctx, downloadURL)
if err != nil {
flog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
return ""
}
transcript := toFirestoreTranscript(result)
_, err = doc.Ref.Set(ctx, map[string]interface{}{
"properties": map[string]interface{}{
"transcript": transcript,
},
}, firestore.MergeAll)
if err != nil {
flog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
return ""
}
flog.Info("transcribed media particle", "particleID", doc.Ref.ID)
return transcript.Transcript
}
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
words := make([]particle.FirestoreTranscriptWord, len(result.Words))
for i, w := range result.Words {
words[i] = particle.FirestoreTranscriptWord{
Word: w.Word,
Start: w.Start,
End: w.End,
}
}
paragraphs := make([]particle.FirestoreTranscriptParagraph, len(result.Paragraphs))
for i, p := range result.Paragraphs {
sentences := make([]particle.FirestoreTranscriptSentence, len(p.Sentences))
for j, s := range p.Sentences {
sentences[j] = particle.FirestoreTranscriptSentence{
Text: s.Text,
Start: s.Start,
End: s.End,
}
}
paragraphs[i] = particle.FirestoreTranscriptParagraph{
Sentences: sentences,
Start: p.Start,
End: p.End,
}
}
return particle.FirestoreTranscript{
Transcript: result.Transcript,
Words: words,
Paragraphs: paragraphs,
}
}
// Bumps the network's daily message counter for non-container particles.
// The surrounding processed_particles guard keeps this idempotent across
// crashes/restarts.
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
rawType, err := doc.DataAt("type")
if err != nil {
flog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID)
return
}
typeStr, ok := rawType.(string)
if !ok {
flog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType)
return
}
particleType, err := particle.ParseParticleType(typeStr)
if err != nil {
flog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
return
}
// Containers don't count toward the daily message cap.
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
return
}
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
flog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path)
return
}
if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil {
flog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID)
}
}
// Returns nil (and logs) if the path has no parent or the read fails.
func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
parentChildrenCollectionRef := doc.Ref.Parent
if parentChildrenCollectionRef == nil {
return nil
}
parentParticleDocRef := parentChildrenCollectionRef.Parent
if parentParticleDocRef == nil {
flog.Error("particle has no parent document", "particleID", doc.Ref.ID)
return nil
}
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
if err != nil {
flog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID)
return nil
}
return parentParticleDoc
}
// Sets last_child_created_at to the child's created_at so it stays directly
// comparable with playback markers (which also store child created_at values).
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) {
if parent == nil {
return
}
var streamParticle particle.FirestoreStreamParticle
if err := parent.DataTo(&streamParticle); err != nil {
flog.Error("failed to parse stream particle", "error", err)
return
}
particleType, err := particle.ParseParticleType(streamParticle.Type)
if err != nil {
flog.Error("invalid particle type", "error", err)
return
}
if particleType != particle.TypeStream {
return
}
childCreatedAt, err := doc.DataAt("created_at")
if err != nil {
flog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
return
}
_, err = parent.Ref.Update(ctx, []firestore.Update{
{
Path: "last_child_created_at",
Value: childCreatedAt,
},
})
if err != nil {
flog.Error("unable to update parent particle `last_child_created_at`", "error", err)
}
}
// Skips containers and particles whose parent isn't a stream — notifications
// are scoped to stream messages today. The transcript arg becomes the preview
// body for media particles when available.
func notifyForParticle(
ctx context.Context,
notifier *pushnotify.Notifier,
humanSvc human.Service,
doc *firestore.DocumentSnapshot,
parent *firestore.DocumentSnapshot,
transcript string,
) {
if parent == nil {
flog.Info("notify: skip — no parent", "particleID", doc.Ref.ID)
return
}
typeStr, _ := doc.DataAt("type")
typeName, _ := typeStr.(string)
pType, err := particle.ParseParticleType(typeName)
if err != nil {
flog.Info("notify: skip — unparseable particle type",
"particleID", doc.Ref.ID, "type", typeName, "error", err)
return
}
if pType == particle.TypeStream || pType == particle.TypeFolder {
flog.Info("notify: skip — container particle",
"particleID", doc.Ref.ID, "type", pType)
return
}
var parentStream particle.FirestoreStreamParticle
if err := parent.DataTo(&parentStream); err != nil {
flog.Error("notify: failed to parse parent stream", "error", err)
return
}
parentType, err := particle.ParseParticleType(parentStream.Type)
if err != nil || parentType != particle.TypeStream {
flog.Info("notify: skip — parent isn't a stream",
"particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err)
return
}
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
flog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path)
return
}
senderHumanID := parentStream.CreatedByHumanId
if v, err := doc.DataAt("created_by_human_id"); err == nil {
if s, ok := v.(string); ok && s != "" {
senderHumanID = s
}
}
streamName := ""
if v, err := parent.DataAt("properties.name"); err == nil {
if s, ok := v.(string); ok {
streamName = s
}
}
senderEmailPrefix := ""
if senderHumanID != "" {
if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil {
senderEmailPrefix = sender.EmailPrefix
} else {
flog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID)
}
}
if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{
NetworkID: networkID,
SenderHumanID: senderHumanID,
SenderEmailPrefix: senderEmailPrefix,
ParticleID: doc.Ref.ID,
ParticleKind: string(pType),
StreamID: parent.Ref.ID,
StreamName: streamName,
StreamVisibleTo: parentStream.VisibleTo,
Body: previewForParticle(pType, doc, transcript),
}); err != nil {
flog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID)
}
}
// Builds the notification body. Kept short — lockscreens truncate aggressively.
// Media prefers transcript text and falls back to a generic "Sent a …" line.
func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string {
switch pType {
case particle.TypeText:
if v, err := doc.DataAt("properties.content"); err == nil {
if s, ok := v.(string); ok {
return truncatePreview(s, 140)
}
}
return "Sent a message"
case particle.TypeMedia:
if t := strings.TrimSpace(transcript); t != "" {
return truncatePreview(t, 140)
}
mime := ""
if v, err := doc.DataAt("properties.mime_type"); err == nil {
if s, ok := v.(string); ok {
mime = s
}
}
if strings.HasPrefix(mime, "video/") {
return "Sent a video"
}
return "Sent a voice message"
case particle.TypeFile:
return "Sent a file"
case particle.TypeQuest:
if v, err := doc.DataAt("properties.title"); err == nil {
if s, ok := v.(string); ok && s != "" {
return "Quest: " + truncatePreview(s, 120)
}
}
return "Added a quest"
case particle.TypePaper:
if v, err := doc.DataAt("properties.title"); err == nil {
if s, ok := v.(string); ok && s != "" {
return "Paper: " + truncatePreview(s, 120)
}
}
return "Added a paper"
default:
return "New activity"
}
}
func truncatePreview(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n] + "…"
}
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/pusher"
"github.com/flowy-live/llink/internal/utils"
"google.golang.org/grpc"
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
)
func main() {
port := utils.MustGetEnv("PORT")
grpcPort := utils.MustGetEnv("GRPC_PORT")
db.Init()
defer db.Cleanup()
authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) // shared with orion
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // presence + pub/sub
sessionReader := auth.NewSessionReader(authRedis)
networkReader := network.NewReader(db.Pool())
// Hostname is the k8s pod name.
podID, err := os.Hostname()
if err != nil {
podID = fmt.Sprintf("pod-%d", os.Getpid())
}
bridge := pusher.NewRedisBridge(pusherRedis, podID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
authorizer := pusher.NewAuthorizer(networkReader)
hub := pusher.NewHub(bridge, authorizer)
bridge.SetHub(hub)
server := pusher.NewServer(ctx, hub, bridge, sessionReader)
go hub.Run(ctx)
go bridge.Listen(ctx)
go bridge.Heartbeat(ctx)
// --- gRPC server (internal presence queries) ---
grpcListener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", grpcPort))
if err != nil {
flog.Error("failed to listen for gRPC", "port", grpcPort, "error", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
pbpusher.RegisterPusherServiceServer(grpcServer, server)
go func() {
flog.Info("gRPC server listening", "port", grpcPort)
if err := grpcServer.Serve(grpcListener); err != nil {
flog.Error("gRPC server failed", "error", err)
}
}()
// --- HTTP server (WebSocket + health) ---
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /ws", server.HandleWebSocket)
httpAddr := fmt.Sprintf("0.0.0.0:%s", port)
httpServer := &http.Server{Addr: httpAddr, Handler: mux}
go func() {
flog.Info("HTTP server listening", "addr", httpAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
flog.Error("HTTP server failed", "error", err)
os.Exit(1)
}
}()
// --- Graceful shutdown ---
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
flog.Info("shutting down...")
cancel()
grpcServer.GracefulStop()
httpServer.Shutdown(context.Background())
flog.Info("shutdown complete")
}
-141
View File
@@ -1,141 +0,0 @@
// transcodebackfill walks every doc under the "children" collection group and
// re-runs transcode for media particles missing a transcoded variant.
// Idempotent: particle.Transcode short-circuits on transcoded_object_id != "".
package main
import (
"context"
"os"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
"google.golang.org/api/iterator"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
)
func main() {
ctx := context.Background()
db.Init()
defer db.Cleanup()
storageClient, err := storage.NewClient(ctx)
if err != nil {
flog.Error("failed to create GCS client", "error", err)
os.Exit(1)
}
defer storageClient.Close()
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
BucketName: utils.MustGetEnv("GCS_BUCKET"),
})
gcpProject := utils.MustGetEnv("GCP_PROJECT")
fs, err := firestore.NewClient(ctx, gcpProject)
if err != nil {
flog.Error("failed to create Firestore client", "error", err)
os.Exit(1)
}
defer fs.Close()
started := time.Now()
stats, err := run(ctx, fs, depotSvc)
elapsed := time.Since(started)
flog.Info("transcode_backfill_summary",
"scanned", stats.scanned,
"media", stats.media,
"transcoded", stats.transcoded,
"skippedAlreadyDone", stats.skippedAlreadyDone,
"skippedIOSPlayable", stats.skippedIOSPlayable,
"skippedNonMedia", stats.skippedNonMedia,
"failures", stats.failures,
"elapsed_seconds", elapsed.Seconds(),
)
if err != nil {
flog.Error("transcode backfill aborted", "error", err)
os.Exit(1)
}
if stats.failures > 0 {
os.Exit(1)
}
}
type stats struct {
scanned int
media int
transcoded int
skippedAlreadyDone int
skippedIOSPlayable int
skippedNonMedia int
failures int
}
func run(ctx context.Context, fs *firestore.Client, depotSvc depot.Service) (stats, error) {
var s stats
it := fs.CollectionGroup("children").
OrderBy(firestore.DocumentID, firestore.Asc).
Documents(ctx)
defer it.Stop()
for {
doc, err := it.Next()
if err == iterator.Done {
return s, nil
}
if err != nil {
return s, err
}
s.scanned++
// DataAt avoids unmarshalling the full doc; most children aren't media.
rawType, err := doc.DataAt("type")
if err != nil {
s.skippedNonMedia++
continue
}
typeStr, ok := rawType.(string)
if !ok || typeStr != string(particle.TypeMedia) {
s.skippedNonMedia++
continue
}
s.media++
result := particle.Transcode(ctx, depotSvc, doc)
switch {
case result.Err != nil:
s.failures++
flog.Error("transcode_backfill_failure",
"particleID", doc.Ref.ID,
"path", doc.Ref.Path,
"error", result.Err,
)
case result.Skipped && result.SkipReason == particle.SkipReasonAlreadyTranscoded:
s.skippedAlreadyDone++
case result.Skipped && result.SkipReason == particle.SkipReasonIOSPlayable:
s.skippedIOSPlayable++
case result.Skipped:
s.skippedNonMedia++
default:
s.transcoded++
flog.Info("transcode_backfill_progress",
"particleID", doc.Ref.ID,
"transcodedObjectID", result.TranscodedObjectID,
"outputMime", result.OutputMimeType,
"idx", s.transcoded,
)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -e
export KUBE_CONTEXT=dev
export SKAFFOLD_DEFAULT_REPO=us-west2-docker.pkg.dev/flowy-dev-440017/deployments
skaffold run -p dev --kube-context dev --port-forward --tail
+311 -3
View File
@@ -51,6 +51,54 @@ Returns the authenticated user.
---
## Startup
### Get Startup Data
`GET /startup` (Protected)
Bootstrap endpoint for initial app load. Returns all networks the user belongs to, with all streams and their particles fully enriched.
**Response:**
```json
{
"networks": [
{
"id": "net-456",
"name": "My Team",
"admin_human": { "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." },
"humans": [{ "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." }],
"open_stream_count": 2,
"open_stream_capacity": 5,
"created_at": "2025-01-15T10:30:00Z",
"streams": [
{
"id": "p-001",
"name": "Sprint Planning",
"description": "Weekly sync",
"status": "open",
"members": ["[email protected]"],
"particles": [
{
"id": "p-002",
"type": "text",
"data": { "content": "Hello" },
"created_by_email": "[email protected]",
"seen": true,
"acks": [],
"updated_at": "...",
"created_at": "..."
}
],
"unseen_count": 0
}
]
}
]
}
```
---
## Networks
### Create Network
@@ -88,15 +136,183 @@ Returns a specific network by ID.
Removes a member by email from the network.
### Set Open Stream Capacity
`PUT /networks/{id}/capacity` (Protected, Admin only)
**Request Body:**
```json
{
"capacity": 10
}
```
---
### Download Particle Object
## Streams
Streams are top-level particles of type `stream`. They have dedicated endpoints for creation and management, and contain child particles.
### Create Stream
`POST /networks/{network_id}/streams` (Protected)
**Request Body:**
```json
{
"name": "Sprint Planning",
"description": "Weekly sync",
"visibility": "custom",
"members": ["[email protected]"]
}
```
- `visibility`: `network_all` (default) or `custom`
- `members` is required when visibility is `custom`
**Response:** `201 Created` — returns a [Stream](#stream-1) object.
### Get Stream
`GET /streams/{id}` (Protected)
Returns a stream with all its child particles, enriched with seen/ack state.
**Response:** returns a [Stream](#stream-1) object.
### Update Stream
`PATCH /streams/{id}` (Protected)
Updates a stream's name and/or description. Status is not affected (use the open/close endpoints instead). Only provided fields are updated.
**Request Body:**
```json
{
"name": "New Name",
"description": "New description"
}
```
- Both fields are optional — omit a field to leave it unchanged
- `name` cannot be empty if provided
**Response:** returns the updated [Stream](#stream-1) object.
### Create Stream Particle
`POST /streams/{id}/particles` (Protected)
Creates a child particle inside a stream. Child particles inherit visibility from the stream.
**Request Body:**
```json
{
"type": "text|media|file|quest|paper",
"data": {}
}
```
- Cannot create `stream` or `folder` types as children
- For `media` and `file` types, `data` must include a valid `object_id` from depot
**Response:** `201 Created` — returns a [StreamParticle](#streamparticle) object.
### Open Stream
`POST /streams/{id}/open` (Protected)
Opens a closed stream. Fails with `409` if capacity would be exceeded.
### Close Stream
`POST /streams/{id}/close` (Protected)
Closes an open stream.
### Add Members to Stream
`POST /streams/{id}/members` (Protected)
**Request Body:**
```json
{
"emails": ["[email protected]"]
}
```
### Remove Members from Stream
`DELETE /streams/{id}/members` (Protected)
**Request Body:**
```json
{
"emails": ["[email protected]"]
}
```
---
## Particles
### List Particles
`GET /networks/{network_id}/particles` (Protected)
**Query Parameters:**
- `parent_id` (optional): Filter by parent particle
- `cursor` (optional): Pagination cursor
- `direction` (optional): `after` or `before` (default: `after`)
- `type` (optional, repeatable): Filter by particle type
**Response enrichment:**
Each particle in the response includes:
- `seen` (boolean): Whether the requester has marked this particle as seen
- `acks` (array): List of acknowledgments `[{email, acked_at}]`
- `unseen_count` (integer, streams only): Count of unseen child particles
### Get Particle
`GET /particles/{id}` (Protected)
### Update Particle
`PATCH /particles/{id}` (Protected)
**Request Body:**
```json
{
"data": {}
}
```
### Delete Particle
`DELETE /particles/{id}` (Protected)
Deletes the particle and all children. If it references a depot object, that is also deleted.
### Download Particle
`GET /particles/{id}/download` (Protected)
For now, the `{id}` should be an object id. Not the particle id.
Returns a `302` redirect to a signed download URL. Only works for `media` and `file` particles.
### Mark Seen
`POST /particles/{id}/seen` (Protected)
Marks a particle as seen by the requester. This is private state, only visible to the requester.
Returns `204 No Content` on success.
### Mark Seen (Batch)
`POST /particles/seen` (Protected)
Marks multiple particles as seen by the requester.
**Request Body:**
```json
{
"particle_ids": ["particle_uuid1", "particle_uuid2"]
}
```
Returns `204 No Content` on success.
### Acknowledge Particle
`POST /particles/{id}/ack` (Protected)
Acknowledges a particle. Acknowledgments are public and permanent, visible to all users with access. Also marks the particle as seen.
Returns `204 No Content` on success.
---
## Depot (File Storage)
@@ -179,6 +395,8 @@ Confirms that an upload has been completed.
| `name` | `string` | Display name of the network. |
| `admin_human` | `Human` | The network administrator. |
| `humans` | `Human[]` | All members of the network (including admin). |
| `open_stream_count` | `integer` | Number of currently open streams. |
| `open_stream_capacity` | `integer` | Maximum number of concurrent open streams (default: 5). |
| `created_at` | `string` | ISO 8601 timestamp. |
```json
@@ -189,10 +407,86 @@ Confirms that an upload has been completed.
"humans": [
{ "id": "abc-123", "email": "[email protected]", "email_prefix": "alice", "created_at": "..." }
],
"open_stream_count": 2,
"open_stream_capacity": 5,
"created_at": "2025-01-15T10:30:00Z"
}
```
### Stream
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier (this is a particle ID). |
| `name` | `string` | Stream name. |
| `description` | `string` | Stream description. |
| `status` | `string` | `"open"`, `"closed"`, or `"unspecified"`. |
| `members` | `string[]` | Emails of stream members. Omitted for `network_all` visibility. |
| `particles` | `StreamParticle[]` | Child particles in the stream. |
| `unseen_count` | `integer` | Number of unseen child particles for the requester. |
```json
{
"id": "p-001",
"name": "Sprint Planning",
"description": "Weekly sync",
"status": "open",
"members": ["[email protected]"],
"particles": [],
"unseen_count": 0
}
```
### StreamParticle
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier. |
| `type` | `string` | One of: `media`, `file`, `text`, `quest`, `paper`. |
| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
| `created_by_email` | `string` | Email of the creator. |
| `seen` | `boolean` | Whether the requester has seen this particle. |
| `acks` | `AckInfo[]` | Acknowledgments from users. |
| `updated_at` | `string` | ISO 8601 timestamp. |
| `created_at` | `string` | ISO 8601 timestamp. |
### Particle
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier. |
| `type` | `string` | One of: `stream`, `folder`, `media`, `file`, `text`, `quest`, `paper`. |
| `network_id` | `string` | The network this particle belongs to. |
| `parent_id` | `string \| null` | Parent particle ID, if nested. |
| `created_by_email` | `string` | Email of the creator. |
| `visibility` | `string` | `"network_all"`, `"custom"`, or `"inherited"`. |
| `stream_status` | `string \| null` | Only on `stream` type: `"open"` or `"closed"`. |
| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
| `download_url` | `string \| null` | Signed download URL. Only on `media`/`file` particles. |
| `seen` | `boolean \| null` | Whether the requester has seen this particle. Only in list responses. |
| `acks` | `AckInfo[]` | Acknowledgments. Only in list responses. |
| `unseen_count` | `integer \| null` | Unseen child count. Only on `stream` particles in list responses. |
| `updated_at` | `string` | ISO 8601 timestamp. |
| `created_at` | `string` | ISO 8601 timestamp. |
### AckInfo
| Field | Type | Description |
|-------|------|-------------|
| `email` | `string` | Email of the user who acknowledged. |
| `acked_at` | `string` | ISO 8601 timestamp of the acknowledgment. |
### ParticleList
Returned by `GET /networks/{network_id}/particles`.
| Field | Type | Description |
|-------|------|-------------|
| `particles` | `Particle[]` | Array of enriched particle objects. |
| `has_more` | `boolean` | Whether more results exist beyond this page. |
| `next_cursor` | `string \| null` | Cursor to fetch the next page. |
| `prev_cursor` | `string \| null` | Cursor to fetch the previous page. |
### DepotObject
Returned by `POST /depot/objects/{id}/confirm`.
@@ -297,6 +591,20 @@ The `data` field on a Particle is a JSON object whose schema depends on the part
---
## Visibility
Particles support three visibility modes:
| Mode | Description |
|------|-------------|
| `network_all` | Visible to all network members. |
| `custom` | Visible only to specified members (requires `members` list). |
| `inherited` | Inherits visibility from parent particle. Used for child particles in streams. |
Root-level particles (streams, folders) use `network_all` or `custom`. Child particles created via `POST /streams/{id}/particles` automatically use `inherited`.
---
## Error Responses
All endpoints return standard HTTP status codes with plain text error bodies:
-462
View File
@@ -1,462 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.28.3
// source: llink/pusher/pusher.proto
package pbpusher
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetOnlineHumanIdsRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *GetOnlineHumanIdsRequest) Reset() {
*x = GetOnlineHumanIdsRequest{}
mi := &file_llink_pusher_pusher_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetOnlineHumanIdsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetOnlineHumanIdsRequest) ProtoMessage() {}
func (x *GetOnlineHumanIdsRequest) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetOnlineHumanIdsRequest.ProtoReflect.Descriptor instead.
func (*GetOnlineHumanIdsRequest) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{0}
}
type GetOnlineHumanIdsResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
}
func (x *GetOnlineHumanIdsResponse) Reset() {
*x = GetOnlineHumanIdsResponse{}
mi := &file_llink_pusher_pusher_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetOnlineHumanIdsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetOnlineHumanIdsResponse) ProtoMessage() {}
func (x *GetOnlineHumanIdsResponse) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetOnlineHumanIdsResponse.ProtoReflect.Descriptor instead.
func (*GetOnlineHumanIdsResponse) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{1}
}
func (x *GetOnlineHumanIdsResponse) GetHumanIds() []string {
if x != nil {
return x.HumanIds
}
return nil
}
type IsOnlineRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
}
func (x *IsOnlineRequest) Reset() {
*x = IsOnlineRequest{}
mi := &file_llink_pusher_pusher_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *IsOnlineRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IsOnlineRequest) ProtoMessage() {}
func (x *IsOnlineRequest) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IsOnlineRequest.ProtoReflect.Descriptor instead.
func (*IsOnlineRequest) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{2}
}
func (x *IsOnlineRequest) GetHumanIds() []string {
if x != nil {
return x.HumanIds
}
return nil
}
type IsOnlineResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Online map[string]bool `protobuf:"bytes,1,rep,name=online,proto3" json:"online,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"`
}
func (x *IsOnlineResponse) Reset() {
*x = IsOnlineResponse{}
mi := &file_llink_pusher_pusher_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *IsOnlineResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IsOnlineResponse) ProtoMessage() {}
func (x *IsOnlineResponse) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IsOnlineResponse.ProtoReflect.Descriptor instead.
func (*IsOnlineResponse) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{3}
}
func (x *IsOnlineResponse) GetOnline() map[string]bool {
if x != nil {
return x.Online
}
return nil
}
type GetChannelPresenceRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ChannelIds []string `protobuf:"bytes,1,rep,name=channel_ids,json=channelIds,proto3" json:"channel_ids,omitempty"`
}
func (x *GetChannelPresenceRequest) Reset() {
*x = GetChannelPresenceRequest{}
mi := &file_llink_pusher_pusher_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetChannelPresenceRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetChannelPresenceRequest) ProtoMessage() {}
func (x *GetChannelPresenceRequest) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetChannelPresenceRequest.ProtoReflect.Descriptor instead.
func (*GetChannelPresenceRequest) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{4}
}
func (x *GetChannelPresenceRequest) GetChannelIds() []string {
if x != nil {
return x.ChannelIds
}
return nil
}
type GetChannelPresenceResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Presences map[string]*ChannelPresence `protobuf:"bytes,1,rep,name=presences,proto3" json:"presences,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}
func (x *GetChannelPresenceResponse) Reset() {
*x = GetChannelPresenceResponse{}
mi := &file_llink_pusher_pusher_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetChannelPresenceResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetChannelPresenceResponse) ProtoMessage() {}
func (x *GetChannelPresenceResponse) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetChannelPresenceResponse.ProtoReflect.Descriptor instead.
func (*GetChannelPresenceResponse) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{5}
}
func (x *GetChannelPresenceResponse) GetPresences() map[string]*ChannelPresence {
if x != nil {
return x.Presences
}
return nil
}
type ChannelPresence struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
}
func (x *ChannelPresence) Reset() {
*x = ChannelPresence{}
mi := &file_llink_pusher_pusher_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChannelPresence) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChannelPresence) ProtoMessage() {}
func (x *ChannelPresence) ProtoReflect() protoreflect.Message {
mi := &file_llink_pusher_pusher_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChannelPresence.ProtoReflect.Descriptor instead.
func (*ChannelPresence) Descriptor() ([]byte, []int) {
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{6}
}
func (x *ChannelPresence) GetHumanIds() []string {
if x != nil {
return x.HumanIds
}
return nil
}
var File_llink_pusher_pusher_proto protoreflect.FileDescriptor
var file_llink_pusher_pusher_proto_rawDesc = []byte{
0x0a, 0x19, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2f, 0x70,
0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x6c, 0x6c, 0x69,
0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x22, 0x1a, 0x0a, 0x18, 0x47, 0x65, 0x74,
0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x38, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69,
0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x22,
0x2e, 0x0a, 0x0f, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x22,
0x91, 0x01, 0x0a, 0x10, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42, 0x0a, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x01,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73,
0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79,
0x52, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x1a, 0x39, 0x0a, 0x0b, 0x4f, 0x6e, 0x6c, 0x69,
0x6e, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
0x02, 0x38, 0x01, 0x22, 0x3c, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x64,
0x73, 0x22, 0xd0, 0x01, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x55, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68,
0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65,
0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x72,
0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x70, 0x72,
0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x1a, 0x5b, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x73, 0x65,
0x6e, 0x63, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6c, 0x6c, 0x69,
0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x0f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50,
0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e,
0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61,
0x6e, 0x49, 0x64, 0x73, 0x32, 0xa9, 0x02, 0x0a, 0x0d, 0x50, 0x75, 0x73, 0x68, 0x65, 0x72, 0x53,
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x64, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c,
0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x26, 0x2e, 0x6c, 0x6c,
0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e,
0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68,
0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61,
0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x08,
0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x1d, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b,
0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e,
0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x43, 0x68,
0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x27, 0x2e,
0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74,
0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70,
0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x42, 0x3c, 0x5a, 0x3a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66,
0x6c, 0x6f, 0x77, 0x79, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2f, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f,
0x67, 0x65, 0x6e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x70,
0x75, 0x73, 0x68, 0x65, 0x72, 0x3b, 0x70, 0x62, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_llink_pusher_pusher_proto_rawDescOnce sync.Once
file_llink_pusher_pusher_proto_rawDescData = file_llink_pusher_pusher_proto_rawDesc
)
func file_llink_pusher_pusher_proto_rawDescGZIP() []byte {
file_llink_pusher_pusher_proto_rawDescOnce.Do(func() {
file_llink_pusher_pusher_proto_rawDescData = protoimpl.X.CompressGZIP(file_llink_pusher_pusher_proto_rawDescData)
})
return file_llink_pusher_pusher_proto_rawDescData
}
var file_llink_pusher_pusher_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_llink_pusher_pusher_proto_goTypes = []any{
(*GetOnlineHumanIdsRequest)(nil), // 0: llink.pusher.GetOnlineHumanIdsRequest
(*GetOnlineHumanIdsResponse)(nil), // 1: llink.pusher.GetOnlineHumanIdsResponse
(*IsOnlineRequest)(nil), // 2: llink.pusher.IsOnlineRequest
(*IsOnlineResponse)(nil), // 3: llink.pusher.IsOnlineResponse
(*GetChannelPresenceRequest)(nil), // 4: llink.pusher.GetChannelPresenceRequest
(*GetChannelPresenceResponse)(nil), // 5: llink.pusher.GetChannelPresenceResponse
(*ChannelPresence)(nil), // 6: llink.pusher.ChannelPresence
nil, // 7: llink.pusher.IsOnlineResponse.OnlineEntry
nil, // 8: llink.pusher.GetChannelPresenceResponse.PresencesEntry
}
var file_llink_pusher_pusher_proto_depIdxs = []int32{
7, // 0: llink.pusher.IsOnlineResponse.online:type_name -> llink.pusher.IsOnlineResponse.OnlineEntry
8, // 1: llink.pusher.GetChannelPresenceResponse.presences:type_name -> llink.pusher.GetChannelPresenceResponse.PresencesEntry
6, // 2: llink.pusher.GetChannelPresenceResponse.PresencesEntry.value:type_name -> llink.pusher.ChannelPresence
0, // 3: llink.pusher.PusherService.GetOnlineHumanIds:input_type -> llink.pusher.GetOnlineHumanIdsRequest
2, // 4: llink.pusher.PusherService.IsOnline:input_type -> llink.pusher.IsOnlineRequest
4, // 5: llink.pusher.PusherService.GetChannelPresence:input_type -> llink.pusher.GetChannelPresenceRequest
1, // 6: llink.pusher.PusherService.GetOnlineHumanIds:output_type -> llink.pusher.GetOnlineHumanIdsResponse
3, // 7: llink.pusher.PusherService.IsOnline:output_type -> llink.pusher.IsOnlineResponse
5, // 8: llink.pusher.PusherService.GetChannelPresence:output_type -> llink.pusher.GetChannelPresenceResponse
6, // [6:9] is the sub-list for method output_type
3, // [3:6] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_llink_pusher_pusher_proto_init() }
func file_llink_pusher_pusher_proto_init() {
if File_llink_pusher_pusher_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_llink_pusher_pusher_proto_rawDesc,
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_llink_pusher_pusher_proto_goTypes,
DependencyIndexes: file_llink_pusher_pusher_proto_depIdxs,
MessageInfos: file_llink_pusher_pusher_proto_msgTypes,
}.Build()
File_llink_pusher_pusher_proto = out.File
file_llink_pusher_pusher_proto_rawDesc = nil
file_llink_pusher_pusher_proto_goTypes = nil
file_llink_pusher_pusher_proto_depIdxs = nil
}
-203
View File
@@ -1,203 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.28.3
// source: llink/pusher/pusher.proto
package pbpusher
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
PusherService_GetOnlineHumanIds_FullMethodName = "/llink.pusher.PusherService/GetOnlineHumanIds"
PusherService_IsOnline_FullMethodName = "/llink.pusher.PusherService/IsOnline"
PusherService_GetChannelPresence_FullMethodName = "/llink.pusher.PusherService/GetChannelPresence"
)
// PusherServiceClient is the client API for PusherService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type PusherServiceClient interface {
// Returns all currently connected human IDs.
GetOnlineHumanIds(ctx context.Context, in *GetOnlineHumanIdsRequest, opts ...grpc.CallOption) (*GetOnlineHumanIdsResponse, error)
// Returns whether specific humans are currently online.
IsOnline(ctx context.Context, in *IsOnlineRequest, opts ...grpc.CallOption) (*IsOnlineResponse, error)
// Returns presence (human IDs) for specific channels.
GetChannelPresence(ctx context.Context, in *GetChannelPresenceRequest, opts ...grpc.CallOption) (*GetChannelPresenceResponse, error)
}
type pusherServiceClient struct {
cc grpc.ClientConnInterface
}
func NewPusherServiceClient(cc grpc.ClientConnInterface) PusherServiceClient {
return &pusherServiceClient{cc}
}
func (c *pusherServiceClient) GetOnlineHumanIds(ctx context.Context, in *GetOnlineHumanIdsRequest, opts ...grpc.CallOption) (*GetOnlineHumanIdsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetOnlineHumanIdsResponse)
err := c.cc.Invoke(ctx, PusherService_GetOnlineHumanIds_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *pusherServiceClient) IsOnline(ctx context.Context, in *IsOnlineRequest, opts ...grpc.CallOption) (*IsOnlineResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IsOnlineResponse)
err := c.cc.Invoke(ctx, PusherService_IsOnline_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *pusherServiceClient) GetChannelPresence(ctx context.Context, in *GetChannelPresenceRequest, opts ...grpc.CallOption) (*GetChannelPresenceResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetChannelPresenceResponse)
err := c.cc.Invoke(ctx, PusherService_GetChannelPresence_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// PusherServiceServer is the server API for PusherService service.
// All implementations must embed UnimplementedPusherServiceServer
// for forward compatibility.
type PusherServiceServer interface {
// Returns all currently connected human IDs.
GetOnlineHumanIds(context.Context, *GetOnlineHumanIdsRequest) (*GetOnlineHumanIdsResponse, error)
// Returns whether specific humans are currently online.
IsOnline(context.Context, *IsOnlineRequest) (*IsOnlineResponse, error)
// Returns presence (human IDs) for specific channels.
GetChannelPresence(context.Context, *GetChannelPresenceRequest) (*GetChannelPresenceResponse, error)
mustEmbedUnimplementedPusherServiceServer()
}
// UnimplementedPusherServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedPusherServiceServer struct{}
func (UnimplementedPusherServiceServer) GetOnlineHumanIds(context.Context, *GetOnlineHumanIdsRequest) (*GetOnlineHumanIdsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetOnlineHumanIds not implemented")
}
func (UnimplementedPusherServiceServer) IsOnline(context.Context, *IsOnlineRequest) (*IsOnlineResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IsOnline not implemented")
}
func (UnimplementedPusherServiceServer) GetChannelPresence(context.Context, *GetChannelPresenceRequest) (*GetChannelPresenceResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetChannelPresence not implemented")
}
func (UnimplementedPusherServiceServer) mustEmbedUnimplementedPusherServiceServer() {}
func (UnimplementedPusherServiceServer) testEmbeddedByValue() {}
// UnsafePusherServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to PusherServiceServer will
// result in compilation errors.
type UnsafePusherServiceServer interface {
mustEmbedUnimplementedPusherServiceServer()
}
func RegisterPusherServiceServer(s grpc.ServiceRegistrar, srv PusherServiceServer) {
// If the following call pancis, it indicates UnimplementedPusherServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&PusherService_ServiceDesc, srv)
}
func _PusherService_GetOnlineHumanIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetOnlineHumanIdsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PusherServiceServer).GetOnlineHumanIds(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: PusherService_GetOnlineHumanIds_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(PusherServiceServer).GetOnlineHumanIds(ctx, req.(*GetOnlineHumanIdsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _PusherService_IsOnline_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IsOnlineRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PusherServiceServer).IsOnline(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: PusherService_IsOnline_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(PusherServiceServer).IsOnline(ctx, req.(*IsOnlineRequest))
}
return interceptor(ctx, in, info, handler)
}
func _PusherService_GetChannelPresence_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetChannelPresenceRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PusherServiceServer).GetChannelPresence(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: PusherService_GetChannelPresence_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(PusherServiceServer).GetChannelPresence(ctx, req.(*GetChannelPresenceRequest))
}
return interceptor(ctx, in, info, handler)
}
// PusherService_ServiceDesc is the grpc.ServiceDesc for PusherService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var PusherService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "llink.pusher.PusherService",
HandlerType: (*PusherServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetOnlineHumanIds",
Handler: _PusherService_GetOnlineHumanIds_Handler,
},
{
MethodName: "IsOnline",
Handler: _PusherService_IsOnline_Handler,
},
{
MethodName: "GetChannelPresence",
Handler: _PusherService_GetChannelPresence_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "llink/pusher/pusher.proto",
}
+23 -104
View File
@@ -1,189 +1,108 @@
module github.com/flowy-live/llink
go 1.25.0
go 1.25
require (
cloud.google.com/go/firestore v1.21.0
cloud.google.com/go/storage v1.59.1
firebase.google.com/go/v4 v4.19.0
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/livekit/protocol v1.45.1
github.com/livekit/server-sdk-go/v2 v2.16.1
github.com/redis/go-redis/v9 v9.17.2
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v85 v85.0.1
github.com/testcontainers/testcontainers-go v0.40.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
go.jetify.com/typeid v1.3.0
go.uber.org/mock v0.6.0
google.golang.org/api v0.256.0
google.golang.org/grpc v1.79.1
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
nhooyr.io/websocket v1.8.17
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect
buf.build/go/protovalidate v1.1.2 // indirect
buf.build/go/protoyaml v0.6.0 // indirect
cel.dev/expr v0.25.1 // indirect
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/longrunning v0.7.0 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/Microsoft/go-winio v0.6.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/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v1.0.0-rc.2 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dvonthenen/websocket v1.5.1-dyv.2 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gammazero/deque v1.2.1 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gofrs/uuid/v5 v5.2.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/schema v1.3.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // 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
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lib/pq v1.10.9 // 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-20251128105421-19c7a7b81c22 // indirect
github.com/livekit/psrpc v0.7.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.0 // indirect
github.com/pion/interceptor v0.1.44 // 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.1 // indirect
github.com/pion/sctp v1.9.2 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.6 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/api v0.256.0 // indirect
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
)
tool go.uber.org/mock/mockgen
+54 -296
View File
@@ -1,11 +1,5 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0=
buf.build/go/protovalidate v1.1.2/go.mod h1:Ez3z+w4c+wG+EpW8ovgZaZPnPl2XVF6kaxgcv1NG/QE=
buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
@@ -14,8 +8,6 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
@@ -30,12 +22,10 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
@@ -44,149 +34,88 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
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/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4=
github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/deepgram/deepgram-go-sdk/v3 v3.5.0 h1:ug48j1DVNRKrkXti18/aFT3NP5HV2Q2CN3QMwTvHmy4=
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0/go.mod h1:wVr0PDvlJFWVLUmf65u+K80SJVf/PUWvkFFubGPW/As=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
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/cli v29.0.0+incompatible h1:KgsN2RUFMNM8wChxryicn4p46BdQWpXOA1XLGBGPGAw=
github.com/docker/cli v29.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
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/dvonthenen/websocket v1.5.1-dyv.2 h1:OXlWJJkeHt8k4+MEI0Y8SQjY2ihHYD2z/tI7sZZfsnA=
github.com/dvonthenen/websocket v1.5.1-dyv.2/go.mod h1:q2GbopbpFJvBP4iqVvqwwahVmvu2HnCfdqCWDoQVKMM=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM=
github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
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.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/schema v1.3.0 h1:rbciOzXAx3IB8stEFnfTwO3sYa6EWlQk79XdyustPDA=
github.com/gorilla/schema v1.3.0/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
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/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
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=
@@ -195,51 +124,24 @@ github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
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/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.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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-20251128105421-19c7a7b81c22 h1:dzCBxOGLLWVtQhL7OYK2EGN+5Q+23Mq/jfz4vQisirA=
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22/go.mod h1:mSNtYzSf6iY9xM3UX42VEI+STHvMgHmrYzEHPcdhB8A=
github.com/livekit/protocol v1.45.1 h1:4cbynsPZW32gS2z6nUWfAfr4YaTUwZSKUiLpSpjX+lQ=
github.com/livekit/protocol v1.45.1/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0=
github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw=
github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk=
github.com/livekit/server-sdk-go/v2 v2.16.1 h1:ZkIA9OdVvQ6Up1uW/RtQ0YJUgYMJ6+ywOmDg0jX7bTg=
github.com/livekit/server-sdk-go/v2 v2.16.1/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
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/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8=
github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -250,60 +152,14 @@ github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
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/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4=
github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
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.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
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.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
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.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.6 h1:e9H/du7PbYA2qMJkqKp9Ou2z5Igb/6qbKSeEeUCVv0M=
github.com/pion/webrtc/v4 v4.2.6/go.mod h1:+GAy0jwidoZAHsgjsx77sH09spnV0YWjpB3ROAXmz5A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
@@ -313,26 +169,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
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.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
@@ -344,8 +186,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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=
github.com/stripe/stripe-go/v85 v85.0.1 h1:vlIo5VHrR9GkYneH5D9YGOPwNDRD6LW/THhtx9zNs6M=
github.com/stripe/stripe-go/v85 v85.0.1/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
@@ -354,149 +194,71 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
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/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY=
go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk=
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/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
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.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
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/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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
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=
@@ -509,7 +271,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
-44
View File
@@ -1,44 +0,0 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
)
type SessionReader interface {
// GetSession returns ErrSessionNotFound if no valid session.
GetSession(ctx context.Context, sessionToken string) (*Session, error)
}
type sessionReaderImpl struct {
redisClient *redis.Client
}
// Exposes the concrete type so authServiceImpl can embed it without
// hiding redisClient behind the SessionReader interface.
func newSessionReader(redisClient *redis.Client) *sessionReaderImpl {
return &sessionReaderImpl{redisClient: redisClient}
}
func NewSessionReader(redisClient *redis.Client) SessionReader {
return newSessionReader(redisClient)
}
func (r *sessionReaderImpl) GetSession(ctx context.Context, token string) (*Session, error) {
sessionInfo, err := r.redisClient.Get(ctx, token).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, ErrSessionNotFound
}
return nil, fmt.Errorf("error getting session: %w", err)
}
var session Session
err = json.Unmarshal([]byte(sessionInfo), &session)
return &session, nil
}
+36 -54
View File
@@ -2,20 +2,16 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
firebaseauth "firebase.google.com/go/v4/auth"
"github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/utils"
"github.com/redis/go-redis/v9"
"go.jetify.com/typeid"
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/utils"
)
const (
@@ -41,54 +37,34 @@ func newSessionToken() (sessionToken, error) {
return typeid.New[sessionToken]()
}
type Session struct {
Email string `json:"email"`
HumanId string `json:"human_id"`
}
type AuthService interface {
SessionReader
// RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode.
// RequestSignInCode generates a code and emails it to the provided email.
// To retrieve a session, client must verify with VerifySignInCode.
RequestSignInCode(ctx context.Context, email string) error
// VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates
// a session keyed to (email, humanId).
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
// ExtendSession returns ErrSessionNotFound if no valid session.
// VerifySignInCode returns ErrInvalidCode if incorrect code
VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error)
// GetSession returns ErrSessionNotFound if no valid session
GetSession(ctx context.Context, sessionToken string) (email string, err error)
// ExtendSession returns ErrSessionNotFound if no valid session
ExtendSession(ctx context.Context, sessionToken string) error
SignOut(ctx context.Context, sessionToken string) error
// MintFirebaseCustomToken issues a Firebase custom token with uid=humanId and no claims.
MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
IsSystemAdmin(ctx context.Context, email string) bool
}
type authServiceImpl struct {
*sessionReaderImpl
aeroSvc pbaero.PrimaryClient
fbAuth *firebaseauth.Client
redisClient *redis.Client
aeroSvc pbaero.PrimaryClient
}
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService {
return &authServiceImpl{
sessionReaderImpl: newSessionReader(redisClient),
aeroSvc: aeroSvc,
fbAuth: fbAuth,
}
}
func (a *authServiceImpl) MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) {
if humanId == "" {
return "", errors.New("humanId is required")
}
return a.fbAuth.CustomToken(ctx, humanId)
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService {
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc}
}
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
formattedEmail, err := utils.NormalizeEmail(email)
if err != nil {
flog.Error("problem validating email", "error", err)
slog.Error("problem validating email", "error", err)
return false
}
@@ -112,12 +88,12 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err()
if err != nil {
flog.Error("error setting code in redis", "error", err)
slog.Error("error setting code in redis", "error", err)
return fmt.Errorf("error storing sign-in code: %w", err)
}
message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code)
subject := fmt.Sprint("Sign In - Your One-Time Code for Flowy.llink")
subject := fmt.Sprintf("Sign In - Your One-Time Code for Flowy.llink")
_, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{formattedEmail},
Subject: subject,
@@ -131,11 +107,11 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
return fmt.Errorf("an error occurred while sending the email: %w", err)
}
flog.Info("sent sign in code", "email", formattedEmail)
slog.Info("sent sign in code", "email", formattedEmail)
return nil
}
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, humanId string) (string, error) {
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code string) (string, error) {
formattedEmail, err := utils.NormalizeEmail(email)
if err != nil {
return "", fmt.Errorf("invalid email: %w", err)
@@ -146,7 +122,7 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, hum
if errors.Is(err, redis.Nil) {
return "", ErrInvalidCode
}
flog.Error("error getting code from redis", "error", err)
slog.Error("error getting code from redis", "error", err)
return "", fmt.Errorf("error verifying code: %w", err)
}
@@ -155,10 +131,10 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, hum
}
if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil {
flog.Error("error deleting code from redis", "error", err)
slog.Error("error deleting code from redis", "error", err)
}
token, err := a.createSession(ctx, formattedEmail, humanId)
token, err := a.createSession(ctx, formattedEmail)
if err != nil {
return "", err
}
@@ -166,6 +142,18 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, hum
return token, nil
}
func (a *authServiceImpl) GetSession(ctx context.Context, token string) (string, error) {
email, err := a.redisClient.Get(ctx, token).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return "", ErrSessionNotFound
}
return "", fmt.Errorf("error getting session: %w", err)
}
return email, nil
}
func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error {
ttl, err := a.redisClient.TTL(ctx, token).Result()
if err != nil {
@@ -192,7 +180,7 @@ func (a *authServiceImpl) SignOut(ctx context.Context, token string) error {
return nil
}
func (a *authServiceImpl) createSession(ctx context.Context, email, humanId string) (string, error) {
func (a *authServiceImpl) createSession(ctx context.Context, email string) (string, error) {
formattedEmail, err := utils.NormalizeEmail(email)
if err != nil {
return "", fmt.Errorf("invalid email: %w", err)
@@ -203,13 +191,7 @@ func (a *authServiceImpl) createSession(ctx context.Context, email, humanId stri
return "", fmt.Errorf("error generating session token: %w", err)
}
session := Session{Email: formattedEmail, HumanId: humanId}
data, err := json.Marshal(session)
if err != nil {
return "", fmt.Errorf("error marshaling session: %w", err)
}
if err := a.redisClient.Set(ctx, token.String(), data, sessionExpiry).Err(); err != nil {
if err := a.redisClient.Set(ctx, token.String(), formattedEmail, sessionExpiry).Err(); err != nil {
return "", fmt.Errorf("error storing session: %w", err)
}
-37
View File
@@ -1,37 +0,0 @@
package billing
import (
"fmt"
"strings"
)
type Config struct {
SecretKey string
WebhookSecret string
PriceMonthlyID string
PriceAnnualID string
SuccessURL string
CancelURL string
}
func (c Config) Validate() error {
required := map[string]string{
"SecretKey": c.SecretKey,
"WebhookSecret": c.WebhookSecret,
"PriceMonthlyID": c.PriceMonthlyID,
"PriceAnnualID": c.PriceAnnualID,
"SuccessURL": c.SuccessURL,
"CancelURL": c.CancelURL,
}
var missing []string
for name, value := range required {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("billing config missing: %s", strings.Join(missing, ", "))
}
return nil
}
-145
View File
@@ -1,145 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./service.go
//
// Generated by this command:
//
// mockgen -source ./service.go -destination ./mocks/service.go
//
// Package mock_billing is a generated GoMock package.
package mock_billing
import (
context "context"
reflect "reflect"
time "time"
billing "github.com/flowy-live/llink/internal/billing"
gomock "go.uber.org/mock/gomock"
)
// MockService is a mock of Service interface.
type MockService struct {
ctrl *gomock.Controller
recorder *MockServiceMockRecorder
isgomock struct{}
}
// MockServiceMockRecorder is the mock recorder for MockService.
type MockServiceMockRecorder struct {
mock *MockService
}
// NewMockService creates a new mock instance.
func NewMockService(ctrl *gomock.Controller) *MockService {
mock := &MockService{ctrl: ctrl}
mock.recorder = &MockServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockService) EXPECT() *MockServiceMockRecorder {
return m.recorder
}
// CreateCheckoutSession mocks base method.
func (m *MockService) CreateCheckoutSession(ctx context.Context, p billing.CheckoutParams) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateCheckoutSession", ctx, p)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreateCheckoutSession indicates an expected call of CreateCheckoutSession.
func (mr *MockServiceMockRecorder) CreateCheckoutSession(ctx, p any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCheckoutSession", reflect.TypeOf((*MockService)(nil).CreateCheckoutSession), ctx, p)
}
// CreatePortalSession mocks base method.
func (m *MockService) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreatePortalSession", ctx, networkID)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CreatePortalSession indicates an expected call of CreatePortalSession.
func (mr *MockServiceMockRecorder) CreatePortalSession(ctx, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePortalSession", reflect.TypeOf((*MockService)(nil).CreatePortalSession), ctx, networkID)
}
// GetStatus mocks base method.
func (m *MockService) GetStatus(ctx context.Context, networkID string) (*billing.Status, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetStatus", ctx, networkID)
ret0, _ := ret[0].(*billing.Status)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetStatus indicates an expected call of GetStatus.
func (mr *MockServiceMockRecorder) GetStatus(ctx, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockService)(nil).GetStatus), ctx, networkID)
}
// GetUsage mocks base method.
func (m *MockService) GetUsage(ctx context.Context, networkID string) (*billing.Usage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUsage", ctx, networkID)
ret0, _ := ret[0].(*billing.Usage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUsage indicates an expected call of GetUsage.
func (mr *MockServiceMockRecorder) GetUsage(ctx, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsage", reflect.TypeOf((*MockService)(nil).GetUsage), ctx, networkID)
}
// HandleWebhook mocks base method.
func (m *MockService) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HandleWebhook", ctx, payload, signature)
ret0, _ := ret[0].(error)
return ret0
}
// HandleWebhook indicates an expected call of HandleWebhook.
func (mr *MockServiceMockRecorder) HandleWebhook(ctx, payload, signature any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleWebhook", reflect.TypeOf((*MockService)(nil).HandleWebhook), ctx, payload, signature)
}
// IncrementDailyUsage mocks base method.
func (m *MockService) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IncrementDailyUsage", ctx, networkID, at)
ret0, _ := ret[0].(error)
return ret0
}
// IncrementDailyUsage indicates an expected call of IncrementDailyUsage.
func (mr *MockServiceMockRecorder) IncrementDailyUsage(ctx, networkID, at any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementDailyUsage", reflect.TypeOf((*MockService)(nil).IncrementDailyUsage), ctx, networkID, at)
}
// SyncSeats mocks base method.
func (m *MockService) SyncSeats(ctx context.Context, networkID string, seats int) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SyncSeats", ctx, networkID, seats)
ret0, _ := ret[0].(error)
return ret0
}
// SyncSeats indicates an expected call of SyncSeats.
func (mr *MockServiceMockRecorder) SyncSeats(ctx, networkID, seats any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncSeats", reflect.TypeOf((*MockService)(nil).SyncSeats), ctx, networkID, seats)
}
-57
View File
@@ -1,57 +0,0 @@
package billing
import "time"
type Cadence string
const (
CadenceMonthly Cadence = "monthly"
CadenceAnnual Cadence = "annual"
)
func (c Cadence) IsValid() bool {
return c == CadenceMonthly || c == CadenceAnnual
}
type Plan string
const (
PlanFree Plan = "free"
PlanPro Plan = "pro"
)
// Subscription is the persisted projection of a Stripe subscription,
// reconciled on every webhook event.
type Subscription struct {
ID string
NetworkID string
StripeCustomerID string
Status string
PriceID string
Cadence Cadence
Quantity int
CancelAtPeriodEnd bool
CurrentPeriodStart time.Time
CurrentPeriodEnd time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type Status struct {
Plan Plan `json:"plan"`
PlanStatus string `json:"plan_status"`
Cadence *Cadence `json:"cadence"`
Seats int `json:"seats"` // 0 on free plan; sub.Quantity on pro
CurrentPeriodEnd *time.Time `json:"current_period_end"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
PriceMonthlyCents int64 `json:"price_monthly_cents"`
PriceAnnualCents int64 `json:"price_annual_cents"`
}
type CheckoutParams struct {
NetworkID string
AdminHumanID string
AdminEmail string
Cadence Cadence
Seats int
}
-112
View File
@@ -1,112 +0,0 @@
package billing
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var errNotFound = errors.New("not found")
type repository interface {
getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error)
upsertSubscription(ctx context.Context, sub *Subscription) error
deleteSubscriptionByID(ctx context.Context, subscriptionID string) error
getStripeCustomerID(ctx context.Context, networkID string) (string, error)
setStripeCustomerID(ctx context.Context, networkID, customerID string) error
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
const subscriptionColumns = `id, network_id, stripe_customer_id, status, price_id, cadence, quantity, cancel_at_period_end, current_period_start, current_period_end, created_at, updated_at`
func scanSubscription(row pgx.Row, s *Subscription) error {
return row.Scan(
&s.ID, &s.NetworkID, &s.StripeCustomerID, &s.Status, &s.PriceID,
&s.Cadence, &s.Quantity, &s.CancelAtPeriodEnd,
&s.CurrentPeriodStart, &s.CurrentPeriodEnd,
&s.CreatedAt, &s.UpdatedAt,
)
}
func (r *repositoryImpl) getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error) {
var s Subscription
err := scanSubscription(
r.pool.QueryRow(ctx,
`SELECT `+subscriptionColumns+` FROM network_subscriptions WHERE network_id = $1`,
networkID,
),
&s,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
}
if err != nil {
return nil, err
}
return &s, nil
}
func (r *repositoryImpl) upsertSubscription(ctx context.Context, sub *Subscription) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_subscriptions (
id, network_id, stripe_customer_id, status, price_id, cadence,
quantity, cancel_at_period_end, current_period_start, current_period_end
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET
stripe_customer_id = EXCLUDED.stripe_customer_id,
status = EXCLUDED.status,
price_id = EXCLUDED.price_id,
cadence = EXCLUDED.cadence,
quantity = EXCLUDED.quantity,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
updated_at = NOW()
`,
sub.ID, sub.NetworkID, sub.StripeCustomerID, sub.Status, sub.PriceID, sub.Cadence,
sub.Quantity, sub.CancelAtPeriodEnd, sub.CurrentPeriodStart, sub.CurrentPeriodEnd,
)
return err
}
func (r *repositoryImpl) deleteSubscriptionByID(ctx context.Context, subscriptionID string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM network_subscriptions WHERE id = $1`,
subscriptionID,
)
return err
}
func (r *repositoryImpl) getStripeCustomerID(ctx context.Context, networkID string) (string, error) {
var customerID string
err := r.pool.QueryRow(ctx,
`SELECT stripe_customer_id FROM network_stripe_customers WHERE network_id = $1`,
networkID,
).Scan(&customerID)
if errors.Is(err, pgx.ErrNoRows) {
return "", errNotFound
}
if err != nil {
return "", err
}
return customerID, nil
}
func (r *repositoryImpl) setStripeCustomerID(ctx context.Context, networkID, customerID string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_stripe_customers (network_id, stripe_customer_id)
VALUES ($1, $2)
ON CONFLICT (network_id) DO NOTHING
`, networkID, customerID)
return err
}
-290
View File
@@ -1,290 +0,0 @@
package billing
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stripe/stripe-go/v85"
billingportalsession "github.com/stripe/stripe-go/v85/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v85/checkout/session"
stripecustomer "github.com/stripe/stripe-go/v85/customer"
stripeprice "github.com/stripe/stripe-go/v85/price"
stripesub "github.com/stripe/stripe-go/v85/subscription"
)
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
type Service interface {
GetStatus(ctx context.Context, networkID string) (*Status, error)
CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error)
CreatePortalSession(ctx context.Context, networkID string) (url string, err error)
// SyncSeats updates the Stripe subscription quantity with proration.
// No-op if the network has no active subscription.
SyncSeats(ctx context.Context, networkID string, seats int) error
HandleWebhook(ctx context.Context, payload []byte, signature string) error
// GetUsage reports today's freemium quota state for a network.
// Pro networks get Limit=nil (unlimited); free networks get Limit=&FreemiumDailyLimit.
GetUsage(ctx context.Context, networkID string) (*Usage, error)
IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error
}
var (
ErrNoActiveSubscription = errors.New("network has no stripe customer yet")
ErrInvalidCadence = errors.New("invalid billing cadence")
)
type serviceImpl struct {
cfg Config
repo repository
usageRepo usageRepository
priceMonthlyCents int64
priceAnnualCents int64
}
func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
stripe.Key = cfg.SecretKey
monthly, err := stripeprice.Get(cfg.PriceMonthlyID, &stripe.PriceParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return nil, fmt.Errorf("fetch stripe monthly price: %w", err)
}
annual, err := stripeprice.Get(cfg.PriceAnnualID, &stripe.PriceParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return nil, fmt.Errorf("fetch stripe annual price: %w", err)
}
return &serviceImpl{
cfg: cfg,
repo: newRepository(pool),
usageRepo: newUsageRepository(pool),
priceMonthlyCents: monthly.UnitAmount,
priceAnnualCents: annual.UnitAmount,
}, nil
}
// NewServiceForWorker skips Stripe client setup since workers only exercise
// the usage-tracking path. No API key required.
func NewServiceForWorker(pool *pgxpool.Pool) Service {
return &serviceImpl{
usageRepo: newUsageRepository(pool),
repo: newRepository(pool),
}
}
func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if err != nil && !errors.Is(err, errNotFound) {
return nil, err
}
status := &Status{
Plan: PlanFree,
PlanStatus: "active",
PriceMonthlyCents: s.priceMonthlyCents,
PriceAnnualCents: s.priceAnnualCents,
}
if sub != nil {
cadence := sub.Cadence
periodEnd := sub.CurrentPeriodEnd
// past_due keeps access: Stripe still considers the subscription live
// during the dunning window.
switch stripe.SubscriptionStatus(sub.Status) {
case stripe.SubscriptionStatusActive,
stripe.SubscriptionStatusTrialing,
stripe.SubscriptionStatusPastDue:
status.Plan = PlanPro
}
status.PlanStatus = sub.Status
status.Cadence = &cadence
status.Seats = sub.Quantity
status.CurrentPeriodEnd = &periodEnd
status.CancelAtPeriodEnd = sub.CancelAtPeriodEnd
}
return status, nil
}
func (s *serviceImpl) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (string, error) {
if !p.Cadence.IsValid() {
return "", ErrInvalidCadence
}
if p.Seats < 1 {
return "", fmt.Errorf("seats must be >= 1")
}
customerID, err := s.ensureStripeCustomer(ctx, p)
if err != nil {
return "", fmt.Errorf("ensure stripe customer: %w", err)
}
priceID := s.cfg.PriceAnnualID
switch p.Cadence {
case CadenceAnnual:
priceID = s.cfg.PriceAnnualID
break
case CadenceMonthly:
priceID = s.cfg.PriceMonthlyID
break
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(customerID),
ClientReferenceID: stripe.String(p.NetworkID),
SuccessURL: stripe.String(s.cfg.SuccessURL),
CancelURL: stripe.String(s.cfg.CancelURL),
LineItems: []*stripe.CheckoutSessionLineItemParams{{
Price: stripe.String(priceID),
Quantity: stripe.Int64(int64(p.Seats)),
}},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
Metadata: map[string]string{
"network_id": p.NetworkID,
"admin_human_id": p.AdminHumanID,
"cadence": string(p.Cadence),
},
},
}
params.Context = ctx
sess, err := checkoutsession.New(params)
if err != nil {
return "", fmt.Errorf("stripe checkout: %w", err)
}
return sess.URL, nil
}
func (s *serviceImpl) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if errors.Is(err, errNotFound) {
return "", ErrNoActiveSubscription
}
if err != nil {
return "", err
}
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(sub.StripeCustomerID),
ReturnURL: stripe.String(s.cfg.SuccessURL),
}
params.Context = ctx
sess, err := billingportalsession.New(params)
if err != nil {
return "", fmt.Errorf("stripe portal: %w", err)
}
return sess.URL, nil
}
func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int) error {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if errors.Is(err, errNotFound) {
return nil
}
if err != nil {
return err
}
if sub.Quantity == seats {
return nil
}
liveSub, err := stripesub.Get(sub.ID, &stripe.SubscriptionParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return fmt.Errorf("fetch stripe subscription: %w", err)
}
if len(liveSub.Items.Data) == 0 {
return fmt.Errorf("stripe subscription %s has no items", sub.ID)
}
params := &stripe.SubscriptionParams{
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
Items: []*stripe.SubscriptionItemsParams{{
ID: stripe.String(liveSub.Items.Data[0].ID),
Quantity: stripe.Int64(int64(seats)),
}},
}
params.Context = ctx
if _, err := stripesub.Update(sub.ID, params); err != nil {
return fmt.Errorf("update stripe subscription quantity: %w", err)
}
// customer.subscription.updated webhook arrives within seconds and
// reconciles quantity in our DB.
return nil
}
func (s *serviceImpl) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
return s.usageRepo.incrementDaily(ctx, networkID, at)
}
func (s *serviceImpl) GetUsage(ctx context.Context, networkID string) (*Usage, error) {
now := time.Now()
used, err := s.usageRepo.getDaily(ctx, networkID, now)
if err != nil {
return nil, fmt.Errorf("read daily usage: %w", err)
}
sub, err := s.GetStatus(ctx, networkID)
if err != nil {
return nil, fmt.Errorf("unable to get network billing status: %w", err)
}
u := &Usage{
Plan: sub.Plan,
Used: used,
ResetAt: nextUTCMidnight(now),
Limit: nil,
}
if sub.Plan == PlanFree {
limit := FreemiumDailyLimit
u.Limit = &limit
}
return u, nil
}
func nextUTCMidnight(now time.Time) time.Time {
utc := now.UTC()
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
}
func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) {
existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID)
if err == nil {
return existing, nil
}
if !errors.Is(err, errNotFound) {
return "", err
}
params := &stripe.CustomerParams{
Email: stripe.String(p.AdminEmail),
Metadata: map[string]string{
"network_id": p.NetworkID,
"admin_human_id": p.AdminHumanID,
},
}
params.Context = ctx
cust, err := stripecustomer.New(params)
if err != nil {
return "", fmt.Errorf("create stripe customer: %w", err)
}
if err := s.repo.setStripeCustomerID(ctx, p.NetworkID, cust.ID); err != nil {
return "", fmt.Errorf("persist stripe customer id: %w", err)
}
return cust.ID, nil
}
-13
View File
@@ -1,13 +0,0 @@
package billing
import "time"
// Per-network daily cap on the free plan. Unit-agnostic.
const FreemiumDailyLimit = 50
type Usage struct {
Plan Plan `json:"plan"`
Used int `json:"used"`
Limit *int `json:"limit"` // nil = unlimited (pro)
ResetAt time.Time `json:"reset_at"`
}
-49
View File
@@ -1,49 +0,0 @@
package billing
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type usageRepository interface {
incrementDaily(ctx context.Context, networkID string, at time.Time) error
getDaily(ctx context.Context, networkID string, at time.Time) (int, error)
}
type usageRepositoryImpl struct {
pool *pgxpool.Pool
}
func newUsageRepository(pool *pgxpool.Pool) usageRepository {
return &usageRepositoryImpl{pool: pool}
}
func (r *usageRepositoryImpl) incrementDaily(ctx context.Context, networkID string, at time.Time) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_message_usage (network_id, usage_date, message_count, updated_at)
VALUES ($1, ($2 AT TIME ZONE 'UTC')::date, 1, NOW())
ON CONFLICT (network_id, usage_date) DO UPDATE
SET message_count = network_message_usage.message_count + 1,
updated_at = NOW()
`, networkID, at)
return err
}
func (r *usageRepositoryImpl) getDaily(ctx context.Context, networkID string, at time.Time) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT message_count FROM network_message_usage
WHERE network_id = $1 AND usage_date = ($2 AT TIME ZONE 'UTC')::date
`, networkID, at).Scan(&count)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return count, nil
}
-116
View File
@@ -1,116 +0,0 @@
package billing
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/stripe/stripe-go/v85"
"github.com/stripe/stripe-go/v85/webhook"
)
func (s *serviceImpl) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret)
if err != nil {
return fmt.Errorf("verify stripe signature: %w", err)
}
flog.Info("stripe webhook", "type", event.Type, "id", event.ID)
switch event.Type {
case "checkout.session.completed":
// subscription.created fires right after with full detail; we handle
// the subscription there.
return nil
case "customer.subscription.created", "customer.subscription.updated":
return s.handleSubscriptionUpsert(ctx, event)
case "customer.subscription.deleted":
return s.handleSubscriptionDeleted(ctx, event)
default:
return nil
}
}
func (s *serviceImpl) handleSubscriptionUpsert(ctx context.Context, event stripe.Event) error {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
networkID := sub.Metadata["network_id"]
if networkID == "" {
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
}
local, err := subscriptionFromStripe(&sub, networkID)
if err != nil {
return err
}
if err := s.repo.upsertSubscription(ctx, local); err != nil {
return fmt.Errorf("upsert subscription: %w", err)
}
return nil
}
func (s *serviceImpl) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
networkID := sub.Metadata["network_id"]
if networkID == "" {
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
}
if err := s.repo.deleteSubscriptionByID(ctx, sub.ID); err != nil {
return fmt.Errorf("delete subscription: %w", err)
}
return nil
}
func subscriptionFromStripe(sub *stripe.Subscription, networkID string) (*Subscription, error) {
if len(sub.Items.Data) == 0 {
return nil, fmt.Errorf("subscription %s has no items", sub.ID)
}
item := sub.Items.Data[0]
cadence, err := cadenceFromInterval(item.Price)
if err != nil {
return nil, fmt.Errorf("subscription %s: %w", sub.ID, err)
}
var customerID string
if sub.Customer != nil {
customerID = sub.Customer.ID
}
return &Subscription{
ID: sub.ID,
NetworkID: networkID,
StripeCustomerID: customerID,
Status: string(sub.Status),
PriceID: item.Price.ID,
Cadence: cadence,
Quantity: int(item.Quantity),
CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
CurrentPeriodStart: time.Unix(item.CurrentPeriodStart, 0).UTC(),
CurrentPeriodEnd: time.Unix(item.CurrentPeriodEnd, 0).UTC(),
}, nil
}
func cadenceFromInterval(price *stripe.Price) (Cadence, error) {
if price == nil || price.Recurring == nil {
return "", fmt.Errorf("price is not recurring")
}
switch price.Recurring.Interval {
case stripe.PriceRecurringIntervalMonth:
return CadenceMonthly, nil
case stripe.PriceRecurringIntervalYear:
return CadenceAnnual, nil
default:
return "", fmt.Errorf("unsupported price interval %q", price.Recurring.Interval)
}
}
-3
View File
@@ -1,3 +0,0 @@
package constants
const FlowyAdminEmail = "[email protected]"
@@ -2,10 +2,9 @@ package db
import (
"context"
"log/slog"
"os"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -22,24 +21,24 @@ func Pool() *pgxpool.Pool {
func Init() {
connString := os.Getenv("LLINK_POSTGRES_CONNECTION_URL")
if connString == "" {
flog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
slog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
os.Exit(1)
}
dbpool, err := pgxpool.New(context.Background(), connString)
if err != nil {
flog.Error("unable to create connection pool", "error", err)
slog.Error("unable to create connection pool", "error", err)
os.Exit(1)
}
var greeting string
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
if err != nil {
flog.Error("queryRow failed", "error", err)
slog.Error("queryRow failed", "error", err)
os.Exit(1)
}
flog.Info("successfully connected to database", "greeting", greeting)
slog.Info("successfully connected to database", "greeting", greeting)
db = dbpool
}
-8
View File
@@ -1,8 +0,0 @@
package db
// FIX: move to a dedicated Redis instance. The high DB numbers exist because
// this instance is shared with helios.
const (
RedisDBAuth = 4 // auth sessions
RedisDBPusher = 5 // presence, pub/sub
)
+5 -8
View File
@@ -2,6 +2,7 @@ package depot
import "time"
// Object represents a stored object in the depot
type Object struct {
ID string
Name string
@@ -13,26 +14,22 @@ type Object struct {
CreatedAt time.Time
}
// PrepareUploadInput represents the input for preparing an upload
type PrepareUploadInput struct {
Prefix string // optional, e.g. network_id
Prefix string // Optional prefix for organizing objects (e.g., network_id)
Name string
ContentType string
ContentLength int64
}
// PrepareUploadResult represents the result of preparing an upload
type PrepareUploadResult struct {
ObjectID string
UploadURL string
UploadHeaders map[string]string
}
// For server-side direct uploads (no presigned URL).
type CreateFromReaderInput struct {
Prefix string // optional, e.g. network_id
Name string
ContentType string
}
// Config holds configuration for the depot service
type Config struct {
GoogleServiceAccountEmail string
BucketName string
+19 -64
View File
@@ -4,11 +4,9 @@ import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/storage"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
@@ -22,7 +20,6 @@ const (
type Service interface {
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error)
GetByID(ctx context.Context, objectID string) (*Object, error)
GetDownloadURL(ctx context.Context, objectID string) (string, error)
Delete(ctx context.Context, objectID string) error
@@ -50,7 +47,7 @@ func NewService(pool *pgxpool.Pool, storageClient *storage.Client, config Config
}
if config.GoogleServiceAccountEmail == "" {
flog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.")
slog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.")
panic("GoogleServiceAccountEmail is required for signed URL generation")
}
@@ -75,10 +72,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
}
// {prefix}/{uuid}/{filename}
// Generate object key: {prefix}/{uuid}/{filename}
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
// Row is written first with contains_content=false; ConfirmUpload flips it.
// Create the database record (contains_content = false initially)
obj := &Object{
Name: input.Name,
ContentType: input.ContentType,
@@ -93,7 +90,8 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
return nil, err
}
// Content-Length is part of the signature, so the client must send it verbatim.
// Generate a signed URL for uploading with Content-Length enforcement
// The Headers field specifies headers that MUST be included in the upload request
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail,
@@ -103,10 +101,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
Headers: []string{contentLengthHeader},
})
if err != nil {
flog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
// Roll back the placeholder row.
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
// Clean up the database record if we can't generate the URL
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
flog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
}
return nil, err
}
@@ -130,19 +128,22 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
return nil, err
}
// Verify the object exists in GCS and check its size matches expected
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return nil, errors.Join(ErrNotFound, errors.New("object not found in storage"))
}
flog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
slog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return nil, err
}
// Verify content length matches what was declared
if attrs.Size != obj.ContentLength {
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
}
// Mark as containing content
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
@@ -150,58 +151,10 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
return nil, err
}
// Fetch and return the updated object
return s.repo.getByID(ctx, objectID)
}
// CreateFromReader streams bytes straight to GCS and writes the row in one
// shot — no signed URL, no client round-trip. For server-side flows that
// already have the bytes (e.g. transcoded variants).
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
if input.Name == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
}
if input.ContentType == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
}
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
w.ContentType = input.ContentType
if _, err := io.Copy(w, body); err != nil {
// Always release the writer; surface the copy error, not Close's.
if cerr := w.Close(); cerr != nil {
flog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
}
flog.Error("failed to stream object to GCS", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
if err := w.Close(); err != nil {
flog.Error("failed to close GCS writer", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
obj := &Object{
Name: input.Name,
ContentType: input.ContentType,
ContentLength: w.Attrs().Size,
BucketName: s.bucketName,
ObjectKey: objectKey,
ContainsContent: true,
}
created, err := s.repo.create(ctx, obj)
if err != nil {
// Best-effort: drop the now-untracked GCS object.
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
flog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
}
return nil, err
}
return created, nil
}
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
obj, err := s.repo.getByID(ctx, objectID)
if err != nil {
@@ -222,13 +175,14 @@ func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (stri
return "", err
}
// Generate a signed URL for downloading
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail,
Method: "GET",
Expires: time.Now().Add(s.downloadURLExpiry),
})
if err != nil {
flog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
slog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return "", err
}
@@ -244,13 +198,14 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
return err
}
// GCS first so we don't strand an object after the row vanishes; missing object is fine.
// Delete from GCS (ignore not found errors)
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
flog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return gcsErr
}
// Delete from database
if err := s.repo.delete(ctx, objectID); err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
+250
View File
@@ -0,0 +1,250 @@
package depot_test
import (
"context"
"os"
"testing"
"time"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/testhelper"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
)
var dbPool *pgxpool.Pool
func TestMain(m *testing.M) {
dbPool = testhelper.SetupTestDB()
defer testhelper.TeardownTestDB()
ret := m.Run()
os.Exit(ret)
}
// TestDepotRepository tests the repository layer directly
// These tests can run without GCS since they only test database operations
func TestDepotRepository_CreateAndGet(t *testing.T) {
ctx := context.Background()
// Create a depot object directly in the database for testing
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_test123", "test-file.txt", "text/plain", int64(1024), "test-bucket", "test-key-123", false,
).Scan(&id)
assert.NoError(t, err)
assert.Equal(t, "dpo_test123", id)
// Query the object back
var obj struct {
ID string
Name string
ContentType string
ContentLength int64
BucketName string
ObjectKey string
ContainsContent bool
CreatedAt time.Time
}
err = dbPool.QueryRow(ctx,
`SELECT id, name, content_type, content_length, bucket_name, object_key, contains_content, created_at
FROM depot_objects WHERE id = $1`,
id,
).Scan(&obj.ID, &obj.Name, &obj.ContentType, &obj.ContentLength,
&obj.BucketName, &obj.ObjectKey, &obj.ContainsContent, &obj.CreatedAt)
assert.NoError(t, err)
assert.Equal(t, "test-file.txt", obj.Name)
assert.Equal(t, "text/plain", obj.ContentType)
assert.Equal(t, int64(1024), obj.ContentLength)
assert.Equal(t, "test-bucket", obj.BucketName)
assert.Equal(t, "test-key-123", obj.ObjectKey)
assert.False(t, obj.ContainsContent)
assert.False(t, obj.CreatedAt.IsZero())
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
}
func TestDepotRepository_ConfirmUpload(t *testing.T) {
ctx := context.Background()
// Create a depot object
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_confirm123", "confirm-file.txt", "text/plain", int64(2048), "test-bucket", "confirm-key", false,
).Scan(&id)
assert.NoError(t, err)
// Verify it starts with contains_content = false
var containsContent bool
err = dbPool.QueryRow(ctx,
`SELECT contains_content FROM depot_objects WHERE id = $1`,
id,
).Scan(&containsContent)
assert.NoError(t, err)
assert.False(t, containsContent)
// Confirm upload
_, err = dbPool.Exec(ctx,
`UPDATE depot_objects SET contains_content = TRUE WHERE id = $1`,
id,
)
assert.NoError(t, err)
// Verify it's now true
err = dbPool.QueryRow(ctx,
`SELECT contains_content FROM depot_objects WHERE id = $1`,
id,
).Scan(&containsContent)
assert.NoError(t, err)
assert.True(t, containsContent)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
}
func TestDepotRepository_Delete(t *testing.T) {
ctx := context.Background()
// Create a depot object
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_delete123", "delete-file.txt", "text/plain", int64(512), "test-bucket", "delete-key", false,
).Scan(&id)
assert.NoError(t, err)
// Delete it
result, err := dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
assert.Equal(t, int64(1), result.RowsAffected())
// Verify it's gone
var count int
err = dbPool.QueryRow(ctx,
`SELECT COUNT(*) FROM depot_objects WHERE id = $1`,
id,
).Scan(&count)
assert.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestDepotRepository_Exists(t *testing.T) {
ctx := context.Background()
// Create a depot object
var id string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_exists123", "exists-file.txt", "text/plain", int64(256), "test-bucket", "exists-key", true,
).Scan(&id)
assert.NoError(t, err)
// Check exists
var exists bool
err = dbPool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
id,
).Scan(&exists)
assert.NoError(t, err)
assert.True(t, exists)
// Check non-existent
err = dbPool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
"dpo_nonexistent",
).Scan(&exists)
assert.NoError(t, err)
assert.False(t, exists)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
assert.NoError(t, err)
}
func TestDepotRepository_OrphanedIndex(t *testing.T) {
ctx := context.Background()
// Create an orphaned object (contains_content = false)
var orphanID string
err := dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_orphan123", "orphan-file.txt", "text/plain", int64(128), "test-bucket", "orphan-key", false,
).Scan(&orphanID)
assert.NoError(t, err)
// Create a confirmed object (contains_content = true)
var confirmedID string
err = dbPool.QueryRow(ctx,
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
"dpo_confirmed123", "confirmed-file.txt", "text/plain", int64(128), "test-bucket", "confirmed-key", true,
).Scan(&confirmedID)
assert.NoError(t, err)
// Query orphaned objects using the index
rows, err := dbPool.Query(ctx,
`SELECT id FROM depot_objects WHERE contains_content = FALSE`)
assert.NoError(t, err)
defer rows.Close()
var orphanedIDs []string
for rows.Next() {
var id string
err := rows.Scan(&id)
assert.NoError(t, err)
orphanedIDs = append(orphanedIDs, id)
}
// Our orphan should be in the list
assert.Contains(t, orphanedIDs, orphanID)
assert.NotContains(t, orphanedIDs, confirmedID)
// Clean up
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id IN ($1, $2)`, orphanID, confirmedID)
assert.NoError(t, err)
}
// TestDepotModels tests the model structures
func TestDepotModels(t *testing.T) {
// Test Config defaults
config := depot.Config{
BucketName: "test-bucket",
}
assert.Equal(t, "test-bucket", config.BucketName)
assert.Equal(t, time.Duration(0), config.UploadURLExpiry)
assert.Equal(t, time.Duration(0), config.DownloadURLExpiry)
// Test with explicit values
config = depot.Config{
BucketName: "custom-bucket",
UploadURLExpiry: 10 * time.Minute,
DownloadURLExpiry: 12 * time.Hour,
}
assert.Equal(t, "custom-bucket", config.BucketName)
assert.Equal(t, 10*time.Minute, config.UploadURLExpiry)
assert.Equal(t, 12*time.Hour, config.DownloadURLExpiry)
}
// TestDepotErrors tests the error definitions
func TestDepotErrors(t *testing.T) {
assert.Error(t, depot.ErrNotFound)
assert.Error(t, depot.ErrInvalidInput)
assert.Equal(t, "object not found", depot.ErrNotFound.Error())
assert.Equal(t, "invalid input", depot.ErrInvalidInput.Error())
}
-209
View File
@@ -1,209 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network"
)
type CreateCheckoutSessionRequest struct {
Cadence string `json:"cadence"`
}
type CheckoutSessionResponse struct {
URL string `json:"url"`
}
type PortalSessionResponse struct {
URL string `json:"url"`
}
// GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset
// time. Open to any network member since the UI surfaces it to every sender.
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return
}
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
if err != nil {
flog.Error("failed to check network membership", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !isMember {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
usage, err := h.billingSvc.GetUsage(r.Context(), networkID)
if err != nil {
flog.Error("failed to get network usage", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, usage)
}
func (h *Handler) GetNetworkBilling(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
status, err := h.billingSvc.GetStatus(r.Context(), net.ID)
if err != nil {
flog.Error("failed to get billing status", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, status)
}
func (h *Handler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
net, adminHumanId, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
var req CreateCheckoutSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
cadence := billing.Cadence(req.Cadence)
if !cadence.IsValid() {
http.Error(w, "invalid cadence", http.StatusBadRequest)
return
}
adminHuman, err := h.humanSvc.GetByID(r.Context(), adminHumanId)
if err != nil {
flog.Error("failed to load admin human", "error", err, "human_id", adminHumanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
seats, err := h.networkSvc.CountSeats(r.Context(), net.ID)
if err != nil {
flog.Error("failed to count seats", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
url, err := h.billingSvc.CreateCheckoutSession(r.Context(), billing.CheckoutParams{
NetworkID: net.ID,
AdminHumanID: adminHumanId,
AdminEmail: adminHuman.Email,
Cadence: cadence,
Seats: seats,
})
if err != nil {
flog.Error("failed to create checkout session", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, CheckoutSessionResponse{URL: url})
}
func (h *Handler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok {
return
}
url, err := h.billingSvc.CreatePortalSession(r.Context(), net.ID)
if errors.Is(err, billing.ErrNoActiveSubscription) {
http.Error(w, "no active subscription", http.StatusConflict)
return
}
if err != nil {
flog.Error("failed to create portal session", "error", err, "network_id", net.ID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
writeJSON(w, PortalSessionResponse{URL: url})
}
const maxStripeWebhookBytes = 1 << 20 // 1 MiB
func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, maxStripeWebhookBytes))
if err != nil {
flog.Warn("stripe webhook: failed to read body", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
signature := r.Header.Get("Stripe-Signature")
if err := h.billingSvc.HandleWebhook(r.Context(), payload, signature); err != nil {
flog.Error("stripe webhook failed", "error", err)
formattedErr := fmt.Errorf("webhook processing failed: %w", err)
http.Error(w, formattedErr.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// Resolves {id}, verifies the caller is admin. On failure writes the HTTP
// error and returns ok=false.
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, "", false
}
networkID := r.PathValue("id")
if networkID == "" {
http.Error(w, "network id is required", http.StatusBadRequest)
return nil, "", false
}
net, err := h.networkSvc.GetByID(r.Context(), networkID)
if errors.Is(err, network.ErrNotFound) {
http.Error(w, "network not found", http.StatusNotFound)
return nil, "", false
}
if err != nil {
flog.Error("failed to load network", "error", err, "network_id", networkID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return nil, "", false
}
if net.AdminHumanId != humanId {
http.Error(w, "forbidden", http.StatusForbidden)
return nil, "", false
}
return net, humanId, true
}
func writeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
flog.Error("failed to write json", "error", err)
}
}
File diff suppressed because it is too large Load Diff
-210
View File
@@ -1,210 +0,0 @@
package handler
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
)
// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`.
// All clients (web and Electron) call this endpoint — the Electron main-process
// fetcher was retired so both clients share one parser and the server-side cache.
type LinkMetadata struct {
URL string `json:"url"`
Title *string `json:"title"`
Description *string `json:"description"`
Image *string `json:"image"`
Favicon *string `json:"favicon"`
Domain string `json:"domain"`
}
const (
metadataFetchTimeout = 5 * time.Second
metadataMaxBytes = 50 * 1024
metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)"
)
// In-process cache, unbounded but only successful results are stored.
// URL space is bounded in practice.
var metadataCache sync.Map // map[string]LinkMetadata
func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("url")
if raw == "" {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
http.Error(w, "invalid url", http.StatusBadRequest)
return
}
if cached, ok := metadataCache.Load(raw); ok {
writeJSON(w, cached)
return
}
if err := guardSSRF(parsed.Hostname()); err != nil {
// Treat unresolvable / private hosts as "no metadata" rather than 4xx — the
// client treats a null body as a progressive-enhancement miss.
writeJSON(w, nil)
return
}
meta, err := fetchLinkMetadata(r.Context(), parsed)
if err != nil {
flog.Warn("link metadata fetch failed", "url", raw, "error", err)
writeJSON(w, nil)
return
}
metadataCache.Store(raw, *meta)
writeJSON(w, meta)
}
// guardSSRF resolves the host and rejects loopback, private, link-local, and
// unspecified addresses so the endpoint can't be turned into an internal-network
// probe.
func guardSSRF(host string) error {
ips, err := net.LookupIP(host)
if err != nil {
return err
}
if len(ips) == 0 {
return errors.New("no addresses for host")
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return errors.New("private or local address")
}
}
return nil
}
func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) {
ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", metadataUserAgent)
req.Header.Set("Accept", "text/html")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errors.New("upstream non-2xx")
}
body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes))
if err != nil {
return nil, err
}
html := string(body)
domain := strings.TrimPrefix(target.Hostname(), "www.")
title := firstNonNil(metaContent(html, "og:title"), parseTitle(html))
description := firstNonNil(
metaContent(html, "og:description"),
metaContent(html, "description"),
)
image := resolveURL(metaContent(html, "og:image"), target)
favicon := parseFavicon(html, target)
return &LinkMetadata{
URL: target.String(),
Title: title,
Description: description,
Image: image,
Favicon: favicon,
Domain: domain,
}, nil
}
var titleRe = regexp.MustCompile(`(?i)<title[^>]*>([^<]*)</title>`)
func parseTitle(html string) *string {
m := titleRe.FindStringSubmatch(html)
if len(m) < 2 {
return nil
}
s := strings.TrimSpace(m[1])
if s == "" {
return nil
}
return &s
}
// metaContent finds <meta property|name="<prop>" content="..."> in either attribute order.
func metaContent(html, prop string) *string {
escaped := regexp.QuoteMeta(prop)
re := regexp.MustCompile(
`(?i)<meta[^>]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`,
)
m := re.FindStringSubmatch(html)
if len(m) == 0 {
return nil
}
for _, g := range m[1:] {
if g != "" {
return &g
}
}
return nil
}
var (
faviconRe1 = regexp.MustCompile(`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`)
faviconRe2 = regexp.MustCompile(`(?i)<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`)
)
func parseFavicon(html string, base *url.URL) *string {
for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} {
if m := re.FindStringSubmatch(html); len(m) >= 2 {
return resolveURL(&m[1], base)
}
}
// Fall back to /favicon.ico
fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String()
return &fallback
}
func resolveURL(src *string, base *url.URL) *string {
if src == nil || *src == "" {
return nil
}
parsed, err := url.Parse(*src)
if err != nil {
return src
}
resolved := base.ResolveReference(parsed).String()
return &resolved
}
func firstNonNil(values ...*string) *string {
for _, v := range values {
if v != nil && *v != "" {
return v
}
}
return nil
}
-84
View File
@@ -1,84 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/middleware"
)
type RegisterPushTokenRequest struct {
Token string `json:"token"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
}
type UnregisterPushTokenRequest struct {
Token string `json:"token"`
}
// RegisterPushToken upserts an Expo token; ON CONFLICT transparently re-binds
// a token to a new human after a device-level account switch.
func (h *Handler) RegisterPushToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req RegisterPushTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
err := h.pushTokenSvc.Register(r.Context(), humanId, pushnotify.RegisterInput{
Token: req.Token,
Platform: pushnotify.Platform(req.Platform),
AppVersion: req.AppVersion,
})
if err != nil {
if errors.Is(err, pushnotify.ErrInvalidPlatform) || errors.Is(err, pushnotify.ErrInvalidToken) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
flog.Error("failed to register push token", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UnregisterPushToken returns 204 whether or not the token existed (idempotent).
func (h *Handler) UnregisterPushToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req UnregisterPushTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Token == "" {
http.Error(w, "token is required", http.StatusBadRequest)
return
}
err := h.pushTokenSvc.Unregister(r.Context(), humanId, req.Token)
if err != nil && !errors.Is(err, pushnotify.ErrNotFound) {
flog.Error("failed to unregister push token", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
-131
View File
@@ -1,131 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./service.go
//
// Generated by this command:
//
// mockgen -source ./service.go -destination ./mocks/service.go
//
// Package mock_human is a generated GoMock package.
package mock_human
import (
context "context"
reflect "reflect"
time "time"
human "github.com/flowy-live/llink/internal/human"
gomock "go.uber.org/mock/gomock"
)
// MockService is a mock of Service interface.
type MockService struct {
ctrl *gomock.Controller
recorder *MockServiceMockRecorder
isgomock struct{}
}
// MockServiceMockRecorder is the mock recorder for MockService.
type MockServiceMockRecorder struct {
mock *MockService
}
// NewMockService creates a new mock instance.
func NewMockService(ctrl *gomock.Controller) *MockService {
mock := &MockService{ctrl: ctrl}
mock.recorder = &MockServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockService) EXPECT() *MockServiceMockRecorder {
return m.recorder
}
// GetByEmail mocks base method.
func (m *MockService) GetByEmail(ctx context.Context, email string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByEmail", ctx, email)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByEmail indicates an expected call of GetByEmail.
func (mr *MockServiceMockRecorder) GetByEmail(ctx, email any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByEmail", reflect.TypeOf((*MockService)(nil).GetByEmail), ctx, email)
}
// GetByID mocks base method.
func (m *MockService) GetByID(ctx context.Context, id string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByID", ctx, id)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByID indicates an expected call of GetByID.
func (mr *MockServiceMockRecorder) GetByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockService)(nil).GetByID), ctx, id)
}
// GetOrCreateByEmail mocks base method.
func (m *MockService) GetOrCreateByEmail(ctx context.Context, email string) (*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOrCreateByEmail", ctx, email)
ret0, _ := ret[0].(*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOrCreateByEmail indicates an expected call of GetOrCreateByEmail.
func (mr *MockServiceMockRecorder) GetOrCreateByEmail(ctx, email any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateByEmail", reflect.TypeOf((*MockService)(nil).GetOrCreateByEmail), ctx, email)
}
// ListAll mocks base method.
func (m *MockService) ListAll(ctx context.Context) ([]*human.Human, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAll", ctx)
ret0, _ := ret[0].([]*human.Human)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAll indicates an expected call of ListAll.
func (mr *MockServiceMockRecorder) ListAll(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockService)(nil).ListAll), ctx)
}
// UpdateEmailNotificationsEnabled mocks base method.
func (m *MockService) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateEmailNotificationsEnabled", ctx, id, enabled)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateEmailNotificationsEnabled indicates an expected call of UpdateEmailNotificationsEnabled.
func (mr *MockServiceMockRecorder) UpdateEmailNotificationsEnabled(ctx, id, enabled any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEmailNotificationsEnabled", reflect.TypeOf((*MockService)(nil).UpdateEmailNotificationsEnabled), ctx, id, enabled)
}
// UpdateLastEmailNotificationSentAt mocks base method.
func (m *MockService) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateLastEmailNotificationSentAt", ctx, id, t)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateLastEmailNotificationSentAt indicates an expected call of UpdateLastEmailNotificationSentAt.
func (mr *MockServiceMockRecorder) UpdateLastEmailNotificationSentAt(ctx, id, t any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastEmailNotificationSentAt", reflect.TypeOf((*MockService)(nil).UpdateLastEmailNotificationSentAt), ctx, id, t)
}
+4 -6
View File
@@ -3,10 +3,8 @@ package human
import "time"
type Human struct {
ID string
Email string
EmailPrefix string
EmailNotificationsEnabled bool
LastEmailNotificationSentAt *time.Time
CreatedAt time.Time
ID string
Email string
EmailPrefix string
CreatedAt time.Time
}
-138
View File
@@ -1,138 +0,0 @@
package pushnotify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
expoPushAPIURL = "https://exp.host/--/api/v2/push/send"
// expoMaxBatchSize is the documented per-request cap on push messages.
expoMaxBatchSize = 100
// Ticket error codes returned by Expo Push API. The only one we act on is
// DeviceNotRegistered — others are logged but not retried (per product call).
ExpoErrorDeviceNotRegistered = "DeviceNotRegistered"
)
// Sound defaults to "default" when empty (set in Send).
type Message struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Data map[string]any `json:"data,omitempty"`
Sound string `json:"sound,omitempty"`
}
// Status is "ok" or "error". On error, Details["error"] carries the code
// (e.g. "DeviceNotRegistered", "MessageTooBig", "InvalidCredentials").
type Ticket struct {
Status string `json:"status"`
ID string `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// ExpoClient does NOT poll receipts and does NOT retry — fire-and-forget,
// with DeviceNotRegistered handled out-of-band by the notifier.
type ExpoClient struct {
http *http.Client
accessToken string
}
func NewExpoClient(accessToken string) *ExpoClient {
return &ExpoClient{
http: &http.Client{Timeout: 15 * time.Second},
accessToken: accessToken,
}
}
type expoSendResponse struct {
Data []Ticket `json:"data"`
Errors []map[string]any `json:"errors,omitempty"`
}
// Send batches msgs (cap expoMaxBatchSize) and preserves input order:
// tickets[i] corresponds to msgs[i]. A request-level failure aborts the
// remaining batches; tickets already collected are returned with the error.
func (c *ExpoClient) Send(ctx context.Context, msgs []Message) ([]Ticket, error) {
if len(msgs) == 0 {
return nil, nil
}
for i := range msgs {
if msgs[i].Sound == "" {
msgs[i].Sound = "default"
}
}
tickets := make([]Ticket, 0, len(msgs))
for start := 0; start < len(msgs); start += expoMaxBatchSize {
end := start + expoMaxBatchSize
if end > len(msgs) {
end = len(msgs)
}
batch := msgs[start:end]
batchTickets, err := c.sendBatch(ctx, batch)
tickets = append(tickets, batchTickets...)
if err != nil {
return tickets, fmt.Errorf("expo push batch [%d:%d]: %w", start, end, err)
}
}
return tickets, nil
}
func (c *ExpoClient) sendBatch(ctx context.Context, batch []Message) ([]Ticket, error) {
body, err := json.Marshal(batch)
if err != nil {
return nil, fmt.Errorf("marshal batch: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushAPIURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate")
if c.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("expo push api returned %d: %s", resp.StatusCode, truncate(string(raw), 512))
}
var parsed expoSendResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(parsed.Data) != len(batch) {
return parsed.Data, fmt.Errorf("expo returned %d tickets for %d messages", len(parsed.Data), len(batch))
}
return parsed.Data, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
-181
View File
@@ -1,181 +0,0 @@
package pushnotify
import (
"context"
"errors"
"fmt"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/network"
)
type NotifyInput struct {
NetworkID string
SenderHumanID string
SenderEmailPrefix string
ParticleID string
// One of "text", "media", "file", "quest", "paper". Containers (stream,
// folder) are dropped by the caller before reaching the notifier.
ParticleKind string
// Parent stream context — drives the title and the recipient set.
StreamID string
StreamName string
StreamVisibleTo []string
// Body — already formatted by the caller (e.g. truncated text, "Sent a
// voice message"). Title is derived inside the notifier.
Body string
}
// Notifier fans out one particle to Expo:
// 1. Resolve recipients (visibility ∩ network members, minus sender).
// 2. Send a batched Expo request for every recipient's tokens.
// 3. Prune tokens Expo reports as DeviceNotRegistered.
//
// Online/offline presence is intentionally NOT consulted: a live WebSocket
// is a poor proxy for "user is actively consuming this particle right now"
// (backgrounded apps, idle desktops, etc. all look online), and the resulting
// false-negatives outweigh the duplicate-notification cost on a focused
// device, which the OS handles via Focus modes and per-app settings.
type Notifier struct {
networkR network.Reader
tokens Service
expo *ExpoClient
}
func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
return &Notifier{
networkR: networkR,
tokens: tokens,
expo: expo,
}
}
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
if in.NetworkID == "" || in.ParticleID == "" {
flog.Info("pushnotify: skip — missing ids",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
)
return nil
}
members, err := n.networkR.ListMembers(ctx, in.NetworkID)
if err != nil {
return fmt.Errorf("list network members: %w", err)
}
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
recipientsBeforeSenderFilter := len(recipients)
recipients = filterOut(recipients, in.SenderHumanID)
if len(recipients) == 0 {
flog.Info("pushnotify: skip — no recipients",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
"senderHumanID", in.SenderHumanID,
"members", len(members),
"visibleTo", in.StreamVisibleTo,
"resolved", recipientsBeforeSenderFilter,
)
return nil
}
tokens, err := n.tokens.ListForHumans(ctx, recipients)
if err != nil {
return fmt.Errorf("token lookup: %w", err)
}
if len(tokens) == 0 {
flog.Info("pushnotify: skip — no tokens for recipients",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
"recipients", len(recipients),
"recipientIDs", recipients,
)
return nil
}
msgs := buildMessages(tokens, in)
tickets, sendErr := n.expo.Send(ctx, msgs)
flog.Info("pushnotify: dispatch",
"networkID", in.NetworkID,
"particleID", in.ParticleID,
"recipients", len(recipients),
"tokens", len(tokens),
"sent", len(tickets),
)
n.cleanupDeadTokens(ctx, msgs, tickets)
if sendErr != nil {
return fmt.Errorf("expo send: %w", sendErr)
}
return nil
}
// DeviceNotRegistered is the one feedback signal we honor; other ticket
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
for i, t := range tickets {
if i >= len(msgs) {
break
}
if t.Status != "error" || t.Details == nil {
continue
}
code, _ := t.Details["error"].(string)
if code != ExpoErrorDeviceNotRegistered {
if t.Status == "error" {
flog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To)
}
continue
}
if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) {
flog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To)
} else {
flog.Info("pushnotify: removed unregistered token", "token", msgs[i].To)
}
}
}
func buildMessages(tokens []*PushToken, in NotifyInput) []Message {
title := in.SenderEmailPrefix
if in.StreamName != "" {
title = in.SenderEmailPrefix + " in " + in.StreamName
}
data := map[string]any{
"kind": "particle_created",
"network_id": in.NetworkID,
"stream_id": in.StreamID,
"particle_id": in.ParticleID,
"sender_human_id": in.SenderHumanID,
"particle_kind": in.ParticleKind,
}
msgs := make([]Message, 0, len(tokens))
for _, t := range tokens {
msgs = append(msgs, Message{
To: t.Token,
Title: title,
Body: in.Body,
Data: data,
})
}
return msgs
}
func filterOut(ids []string, exclude string) []string {
if exclude == "" {
return ids
}
out := ids[:0:len(ids)]
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
@@ -1,95 +0,0 @@
package pushnotify
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type repository interface {
upsert(ctx context.Context, t *PushToken) error
deleteForHuman(ctx context.Context, humanID, token string) error
deleteByToken(ctx context.Context, token string) error
listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
func (r *repositoryImpl) upsert(ctx context.Context, t *PushToken) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO push_tokens (token, human_id, platform, app_version)
VALUES ($1, $2, $3, NULLIF($4, ''))
ON CONFLICT (token) DO UPDATE SET
human_id = EXCLUDED.human_id,
platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version,
last_seen_at = NOW()`,
t.Token, t.HumanID, string(t.Platform), t.AppVersion,
)
return err
}
func (r *repositoryImpl) deleteForHuman(ctx context.Context, humanID, token string) error {
result, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE human_id = $1 AND token = $2`,
humanID, token,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *repositoryImpl) deleteByToken(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE token = $1`,
token,
)
return err
}
func (r *repositoryImpl) listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
if len(humanIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx,
`SELECT token, human_id, platform, app_version, created_at, last_seen_at
FROM push_tokens
WHERE human_id = ANY($1)`,
humanIDs,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
defer rows.Close()
var tokens []*PushToken
for rows.Next() {
var t PushToken
var appVersion *string
var platform string
if err := rows.Scan(&t.Token, &t.HumanID, &platform, &appVersion, &t.CreatedAt, &t.LastSeenAt); err != nil {
return nil, err
}
t.Platform = Platform(platform)
if appVersion != nil {
t.AppVersion = *appVersion
}
tokens = append(tokens, &t)
}
return tokens, rows.Err()
}
-66
View File
@@ -1,66 +0,0 @@
package pushnotify
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service stores per-device Expo push tokens and exposes the operations
// needed by both the HTTP handlers and the worker-side notifier.
type Service interface {
// Register returns ErrInvalidToken / ErrInvalidPlatform on bad input.
Register(ctx context.Context, humanID string, in RegisterInput) error
// Unregister is scoped to humanID so a user can't delete another user's
// token. Returns ErrNotFound if the token isn't owned by humanID.
Unregister(ctx context.Context, humanID, token string) error
// ListForHumans returns an empty slice when nothing matches.
ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
// DeleteByToken removes a token regardless of owner — used to prune after
// Expo reports DeviceNotRegistered.
DeleteByToken(ctx context.Context, token string) error
}
type RegisterInput struct {
Token string
Platform Platform
AppVersion string
}
type serviceImpl struct {
repo repository
}
func NewService(pool *pgxpool.Pool) Service {
return &serviceImpl{repo: newRepository(pool)}
}
func (s *serviceImpl) Register(ctx context.Context, humanID string, in RegisterInput) error {
if !in.Platform.Valid() {
return ErrInvalidPlatform
}
if !IsValidExpoToken(in.Token) {
return ErrInvalidToken
}
return s.repo.upsert(ctx, &PushToken{
Token: in.Token,
HumanID: humanID,
Platform: in.Platform,
AppVersion: in.AppVersion,
})
}
func (s *serviceImpl) Unregister(ctx context.Context, humanID, token string) error {
if token == "" {
return ErrInvalidToken
}
return s.repo.deleteForHuman(ctx, humanID, token)
}
func (s *serviceImpl) ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
return s.repo.listForHumans(ctx, humanIDs)
}
func (s *serviceImpl) DeleteByToken(ctx context.Context, token string) error {
return s.repo.deleteByToken(ctx, token)
}
-43
View File
@@ -1,43 +0,0 @@
// Package pushnotify owns mobile push notification delivery: storage of per-device
// Expo push tokens, and the worker-side orchestration of sending notifications
// to offline recipients via the Expo Push API.
package pushnotify
import (
"errors"
"strings"
"time"
)
type Platform string
const (
PlatformIOS Platform = "ios"
PlatformAndroid Platform = "android"
)
func (p Platform) Valid() bool {
return p == PlatformIOS || p == PlatformAndroid
}
type PushToken struct {
Token string
HumanID string
Platform Platform
AppVersion string
CreatedAt time.Time
LastSeenAt time.Time
}
var (
ErrInvalidPlatform = errors.New("invalid platform")
ErrInvalidToken = errors.New("invalid expo push token")
ErrNotFound = errors.New("push token not found")
)
// IsValidExpoToken matches the two prefix formats Expo currently uses.
// We don't validate the inner contents — Expo's server will reject malformed
// tokens with a per-message error and we'll clean those up via DeviceNotRegistered.
func IsValidExpoToken(token string) bool {
return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[")
}
+6 -59
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -34,9 +33,6 @@ type repository interface {
getByID(ctx context.Context, id string) (*Human, error)
create(ctx context.Context, email string) (*Human, error)
exists(ctx context.Context, email string) (bool, error)
listAll(ctx context.Context) ([]*Human, error)
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
}
type repositoryImpl struct {
@@ -50,9 +46,9 @@ func newRepository(pool *pgxpool.Pool) repository {
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
var h Human
err := r.pool.QueryRow(ctx,
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
`SELECT id, email, created_at FROM humans WHERE email = $1`,
email,
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
).Scan(&h.ID, &h.Email, &h.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
@@ -66,9 +62,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
var h Human
err := r.pool.QueryRow(ctx,
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
`SELECT id, email, created_at FROM humans WHERE id = $1`,
id,
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
).Scan(&h.ID, &h.Email, &h.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
@@ -88,9 +84,9 @@ func (r *repositoryImpl) create(ctx context.Context, email string) (*Human, erro
var h Human
err = r.pool.QueryRow(ctx,
`INSERT INTO humans (id, email) VALUES ($1, $2)
RETURNING id, email, email_notifications_enabled, last_email_notification_sent_at, created_at`,
RETURNING id, email, created_at`,
id.String(), email,
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
).Scan(&h.ID, &h.Email, &h.CreatedAt)
if err != nil {
return nil, err
}
@@ -110,52 +106,3 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
}
return exists, nil
}
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var humans []*Human
for rows.Next() {
var h Human
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
return nil, err
}
h.EmailPrefix = emailPrefix(h.Email)
humans = append(humans, &h)
}
return humans, rows.Err()
}
func (r *repositoryImpl) updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
result, err := r.pool.Exec(ctx,
`UPDATE humans SET email_notifications_enabled = $2 WHERE id = $1`,
id, enabled,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
result, err := r.pool.Exec(ctx,
`UPDATE humans SET last_email_notification_sent_at = $2 WHERE id = $1`,
id, t,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
+1 -37
View File
@@ -3,25 +3,17 @@ package human
import (
"context"
"errors"
"time"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
var ErrNotFound = errors.New("human not found")
type Service interface {
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
// GetByEmail returns ErrNotFound if no human found.
// GetByEmail returns ErrNotFound if no human found
GetByEmail(ctx context.Context, email string) (*Human, error)
// GetByID returns ErrNotFound if no human found.
GetByID(ctx context.Context, id string) (*Human, error)
ListAll(ctx context.Context) ([]*Human, error)
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
}
type serviceImpl struct {
@@ -60,31 +52,3 @@ func (s *serviceImpl) GetByEmail(ctx context.Context, email string) (*Human, err
}
return h, err
}
func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Human, error) {
h, err := s.repo.getByID(ctx, id)
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return h, err
}
func (s *serviceImpl) ListAll(ctx context.Context) ([]*Human, error) {
return s.repo.listAll(ctx)
}
func (s *serviceImpl) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
err := s.repo.updateEmailNotificationsEnabled(ctx, id, enabled)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
err := s.repo.updateLastEmailNotificationSentAt(ctx, id, t)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
-76
View File
@@ -1,76 +0,0 @@
package livekit
import (
"context"
"time"
"github.com/flowy-live/llink/internal/utils"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
)
type Client interface {
// GetJoinToken mints a participant JWT; name surfaces as the display name.
GetJoinToken(roomId string, humanId string, name string) (string, error)
ServerUrl() string
ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error)
// KeyProvider is used by handlers to verify webhook signatures.
KeyProvider() auth.KeyProvider
}
type clientImpl struct {
apiSecret string
apiKey string
hostUrl string
roomService *lksdk.RoomServiceClient
keyProvider auth.KeyProvider
}
func NewClient() Client {
apiKey := utils.MustGetEnv("LIVEKIT_API_KEY")
apiSecret := utils.MustGetEnv("LIVEKIT_API_SECRET")
hostUrl := utils.MustGetEnv("LIVEKIT_URL")
roomService := lksdk.NewRoomServiceClient(hostUrl, apiKey, apiSecret)
return &clientImpl{
apiSecret: apiSecret,
apiKey: apiKey,
hostUrl: hostUrl,
roomService: roomService,
keyProvider: auth.NewSimpleKeyProvider(apiKey, apiSecret),
}
}
func (c *clientImpl) ServerUrl() string {
return c.hostUrl
}
func (c *clientImpl) GetJoinToken(room, humanId, name string) (string, error) {
at := auth.NewAccessToken(c.apiKey, c.apiSecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: room,
}
at.SetVideoGrant(grant).
SetIdentity(humanId).
SetName(name).
SetValidFor(time.Hour)
return at.ToJWT()
}
func (c *clientImpl) ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) {
resp, err := c.roomService.ListParticipants(ctx, &livekit.ListParticipantsRequest{
Room: roomName,
})
if err != nil {
return nil, err
}
return resp.Participants, nil
}
func (c *clientImpl) KeyProvider() auth.KeyProvider {
return c.keyProvider
}
@@ -1,41 +0,0 @@
package livestore
import (
"context"
"cloud.google.com/go/firestore"
)
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
// MembershipPublisher fans network membership changes out to Firestore.
// Postgres is the source of truth; the reconciler heals drift, so publish
// failures are safe to log and ignore.
type MembershipPublisher interface {
Add(ctx context.Context, humanId, networkID string) error
Remove(ctx context.Context, humanId, networkID string) error
}
func NewMembershipPublisher(fs *firestore.Client) MembershipPublisher {
return &firestoreMembershipPublisher{fs: fs}
}
type firestoreMembershipPublisher struct {
fs *firestore.Client
}
func (p *firestoreMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error {
_, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
"networks": firestore.ArrayUnion(networkID),
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll)
return err
}
func (p *firestoreMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error {
_, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
"networks": firestore.ArrayRemove(networkID),
"updated_at": firestore.ServerTimestamp,
}, firestore.MergeAll)
return err
}
@@ -1,69 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./membershippublisher.go
//
// Generated by this command:
//
// mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
//
// Package mock_livestore is a generated GoMock package.
package mock_livestore
import (
context "context"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockMembershipPublisher is a mock of MembershipPublisher interface.
type MockMembershipPublisher struct {
ctrl *gomock.Controller
recorder *MockMembershipPublisherMockRecorder
isgomock struct{}
}
// MockMembershipPublisherMockRecorder is the mock recorder for MockMembershipPublisher.
type MockMembershipPublisherMockRecorder struct {
mock *MockMembershipPublisher
}
// NewMockMembershipPublisher creates a new mock instance.
func NewMockMembershipPublisher(ctrl *gomock.Controller) *MockMembershipPublisher {
mock := &MockMembershipPublisher{ctrl: ctrl}
mock.recorder = &MockMembershipPublisherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockMembershipPublisher) EXPECT() *MockMembershipPublisherMockRecorder {
return m.recorder
}
// Add mocks base method.
func (m *MockMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Add", ctx, humanId, networkID)
ret0, _ := ret[0].(error)
return ret0
}
// Add indicates an expected call of Add.
func (mr *MockMembershipPublisherMockRecorder) Add(ctx, humanId, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockMembershipPublisher)(nil).Add), ctx, humanId, networkID)
}
// Remove mocks base method.
func (m *MockMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Remove", ctx, humanId, networkID)
ret0, _ := ret[0].(error)
return ret0
}
// Remove indicates an expected call of Remove.
func (mr *MockMembershipPublisherMockRecorder) Remove(ctx, humanId, networkID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockMembershipPublisher)(nil).Remove), ctx, humanId, networkID)
}
-104
View File
@@ -1,104 +0,0 @@
package media
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"strings"
"github.com/flowy-live/llink/internal/utils/flog"
)
func IsIOSPlayableMime(mime string) bool {
switch mime {
case "video/mp4", "video/quicktime", "audio/mp4", "audio/aac", "audio/x-m4a", "audio/mpeg":
return true
}
return false
}
func IsAudio(mime string) bool {
return strings.HasPrefix(mime, "audio/")
}
type TranscodeInput struct {
SourceURL string
MimeType string
}
type TranscodeOutput struct {
TempLocalFilePath string
OutputMimeType string
OutputExt string // e.g. ".m4a" or ".mp4"
}
var (
ErrInvalidInput error = errors.New("invalid input")
)
// TranscodeToMp4 writes the result to a temp file; caller is responsible
// for deleting TempLocalFilePath.
func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) {
if input.SourceURL == "" || input.MimeType == "" {
return nil, ErrInvalidInput
}
isAudio := IsAudio(input.MimeType)
var outputExt, outputMime string
if isAudio {
outputExt = ".m4a"
outputMime = "audio/mp4"
} else {
outputExt = ".mp4"
outputMime = "video/mp4"
}
tmp, err := os.CreateTemp("", "transcode-*"+outputExt)
if err != nil {
flog.Error("transcode: failed to create temp file", "error", err)
return nil, err
}
tmpPath := tmp.Name()
tmp.Close()
var args []string
if isAudio {
args = []string{
"-y", "-i", input.SourceURL,
"-vn",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
tmpPath,
}
} else {
// 1440p4K screen recordings + libx264's lookahead buffers can OOM the
// worker. Bound parallelism/lookahead and downscale to 1080p; the
// original WebM stays in GCS untouched.
args = []string{
"-y", "-i", input.SourceURL,
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1",
"-x264-params", "rc-lookahead=20:ref=2",
"-threads", "2", "-filter_threads", "2",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
tmpPath,
}
}
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, errors.Join(err, errors.New(stderr.String()))
}
return &TranscodeOutput{
TempLocalFilePath: tmpPath,
OutputMimeType: outputMime,
OutputExt: outputExt,
}, nil
}
+10 -32
View File
@@ -2,51 +2,29 @@ package middleware
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/flowy-live/llink/internal/auth"
)
type contextKey string
const (
emailContextKey contextKey = "email"
humanIdContextKey contextKey = "humanId"
isAdminContextKey contextKey = "isAdmin"
)
const emailContextKey contextKey = "email"
// WithEmail adds the email to the context
func WithEmail(ctx context.Context, email string) context.Context {
return context.WithValue(ctx, emailContextKey, email)
}
// EmailFromContext extracts the email from the context
func EmailFromContext(ctx context.Context) (string, bool) {
email, ok := ctx.Value(emailContextKey).(string)
return email, ok
}
func WithHumanId(ctx context.Context, humanId string) context.Context {
return context.WithValue(ctx, humanIdContextKey, humanId)
}
func HumanIdFromContext(ctx context.Context) (string, bool) {
humanId, ok := ctx.Value(humanIdContextKey).(string)
return humanId, ok
}
func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context {
return context.WithValue(ctx, isAdminContextKey, isAdmin)
}
func IsAdminFromContext(ctx context.Context) bool {
isAdmin, ok := ctx.Value(isAdminContextKey).(bool)
return ok && isAdmin
}
// Auth validates the bearer session token and populates email/humanId/isAdmin
// into the request context for downstream handlers.
// Auth returns a middleware that validates the session token and adds the email to the context
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -56,24 +34,24 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
return
}
session, err := authSvc.GetSession(r.Context(), token)
email, err := authSvc.GetSession(r.Context(), token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Auto-extend session
if err := authSvc.ExtendSession(r.Context(), token); err != nil {
flog.Warn("failed to extend session", "error", err)
slog.Warn("failed to extend session", "error", err)
}
ctx := WithEmail(r.Context(), session.Email)
ctx = WithHumanId(ctx, session.HumanId)
ctx = WithIsAdmin(ctx, authSvc.IsSystemAdmin(r.Context(), session.Email))
ctx := WithEmail(r.Context(), email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// extractBearerToken extracts the token from the Authorization header
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
-38
View File
@@ -1,38 +0,0 @@
package middleware
import "net/http"
// CORS adds CORS headers and short-circuits preflight requests.
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
originSet := make(map[string]struct{}, len(allowedOrigins))
for _, o := range allowedOrigins {
originSet[o] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Empty allowedOrigins means allow all.
allowed := len(originSet) == 0
if !allowed {
_, allowed = originSet[origin]
}
if allowed && origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
+7 -12
View File
@@ -3,16 +3,11 @@ package network
import "time"
type Network struct {
ID string
Name string
AdminHumanId string
MemberHumanIds []string
CreatedAt time.Time
}
type Invitation struct {
NetworkID string
NetworkName string
Email string
CreatedAt time.Time
ID string
Name string
AdminEmail string
MemberEmails []string
OpenStreamCapacity int
OpenStreamCount int
CreatedAt time.Time
}
-100
View File
@@ -1,100 +0,0 @@
package network
import (
"context"
"errors"
"fmt"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
type Reader interface {
// GetByID returns ErrNotFound if network doesn't exist.
GetByID(ctx context.Context, id string) (*Network, error)
// ListForHuman returns ErrInvalidHumanId if humanId is empty.
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
// IsMember returns ErrInvalidHumanId if humanId is empty.
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
// ListMembers returns an empty slice if the network doesn't exist.
ListMembers(ctx context.Context, networkID string) ([]string, error)
ListAll(ctx context.Context) ([]*Network, error)
// ListAllMemberships returns humanId -> networkIds for every human with at
// least one membership. Humans with zero memberships are absent from the map.
ListAllMemberships(ctx context.Context) (map[string][]string, error)
CountSeats(ctx context.Context, networkID string) (int, error)
// ListInvitationsForEmail returns ErrInvalidEmail if normalization fails.
ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error)
ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
}
type readerImpl struct {
pool *pgxpool.Pool
repo repository
}
// newReader exposes the concrete type so the service can embed it without
// hiding pool/repo behind the Reader interface.
func newReader(pool *pgxpool.Pool) *readerImpl {
return &readerImpl{
pool: pool,
repo: newRepository(pool),
}
}
func NewReader(pool *pgxpool.Pool) Reader {
return newReader(pool)
}
func (r *readerImpl) GetByID(ctx context.Context, id string) (*Network, error) {
n, err := r.repo.getByID(ctx, id)
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return n, err
}
func (r *readerImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) {
if humanId == "" {
return nil, ErrInvalidHumanId
}
return r.repo.getNetworksForHuman(ctx, humanId)
}
func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (bool, error) {
if humanId == "" {
return false, ErrInvalidHumanId
}
return r.repo.isMember(ctx, networkID, humanId)
}
func (r *readerImpl) ListMembers(ctx context.Context, networkID string) ([]string, error) {
// Admin is guaranteed to be in network_members: Create() calls AddMembers
// for the admin, and RemoveMemberFromNetwork rejects admin removal.
return r.repo.getMemberHumanIds(ctx, networkID)
}
func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) {
return r.repo.listAll(ctx)
}
func (r *readerImpl) ListAllMemberships(ctx context.Context) (map[string][]string, error) {
return r.repo.listAllMemberships(ctx)
}
func (r *readerImpl) CountSeats(ctx context.Context, networkID string) (int, error) {
return r.repo.countSeats(ctx, r.pool, networkID)
}
func (r *readerImpl) ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidEmail, err)
}
return r.repo.getInvitationsByEmail(ctx, normalized)
}
func (r *readerImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
return r.repo.getInvitationsByNetwork(ctx, networkID)
}
+131 -226
View File
@@ -5,19 +5,10 @@ import (
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"go.jetify.com/typeid"
)
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx,
// so repository helpers can run standalone or inside a transaction.
type dbtx interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
var errNotFound = errors.New("not found")
type networkIDPrefix struct{}
@@ -32,35 +23,21 @@ func newNetworkID() (networkID, error) {
return typeid.New[networkID]()
}
var errCapacityExceeded = errors.New("capacity exceeded")
type repository interface {
create(ctx context.Context, name, adminHumanId string) (*Network, error)
create(ctx context.Context, name, adminEmail string) (*Network, error)
getByID(ctx context.Context, id string) (*Network, error)
updateName(ctx context.Context, id, name string) error
delete(ctx context.Context, id string) error
addMember(ctx context.Context, db dbtx, networkID, humanId string) error
removeMember(ctx context.Context, db dbtx, networkID, humanId string) error
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
countSeats(ctx context.Context, db dbtx, networkID string) (int, error)
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
isMember(ctx context.Context, networkID, humanId string) (bool, error)
listAll(ctx context.Context) ([]*Network, error)
// listAllMemberships returns humanId -> networkIds for every human with at
// least one membership. Humans with zero memberships are absent from the map.
listAllMemberships(ctx context.Context) (map[string][]string, error)
// Invitations
createInvitation(ctx context.Context, networkID, email string) error
getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error)
getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
}
// Centralized so SELECTs and scanNetwork stay in sync.
const networkColumns = `id, name, admin_human_id, created_at`
func scanNetwork(row pgx.Row, n *Network) error {
return row.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
addMember(ctx context.Context, networkID, email string) error
removeMember(ctx context.Context, networkID, email string) error
getMemberEmails(ctx context.Context, networkID string) ([]string, error)
getNetworksForEmail(ctx context.Context, email string) ([]*Network, error)
isMember(ctx context.Context, networkID, email string) (bool, error)
setOpenStreamCapacity(ctx context.Context, id string, capacity int) error
incrementOpenStreamCount(ctx context.Context, id string) error
decrementOpenStreamCount(ctx context.Context, id string) error
}
type repositoryImpl struct {
@@ -71,44 +48,43 @@ func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) (*Network, error) {
func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (*Network, error) {
id, err := newNetworkID()
if err != nil {
return nil, err
}
var n Network
row := r.pool.QueryRow(ctx,
`INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
RETURNING `+networkColumns,
id.String(), name, adminHumanId,
)
if err := scanNetwork(row, &n); err != nil {
err = r.pool.QueryRow(ctx,
`INSERT INTO networks (id, name, admin_email) VALUES ($1, $2, $3)
RETURNING id, name, admin_email, open_stream_capacity, open_stream_count, created_at`,
id.String(), name, adminEmail,
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
if err != nil {
return nil, err
}
n.MemberHumanIds = []string{}
n.MemberEmails = []string{}
return &n, nil
}
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
var n Network
row := r.pool.QueryRow(ctx,
`SELECT `+networkColumns+` FROM networks WHERE id = $1`,
err := r.pool.QueryRow(ctx,
`SELECT id, name, admin_email, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`,
id,
)
if err := scanNetwork(row, &n); err != nil {
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
}
return nil, err
}
memberIds, err := r.getMemberHumanIds(ctx, id)
n.MemberEmails, err = r.getMemberEmails(ctx, id)
if err != nil {
return nil, err
}
n.MemberHumanIds = memberIds
return &n, nil
}
@@ -138,164 +114,50 @@ func (r *repositoryImpl) delete(ctx context.Context, id string) error {
return nil
}
func (r *repositoryImpl) addMember(ctx context.Context, db dbtx, networkID, humanId string) error {
_, err := db.Exec(ctx,
`INSERT INTO network_members (network_id, human_id) VALUES ($1, $2)
ON CONFLICT (network_id, human_id) DO NOTHING`,
networkID, humanId,
)
return err
}
func (r *repositoryImpl) removeMember(ctx context.Context, db dbtx, networkID, humanId string) error {
_, err := db.Exec(ctx,
`DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`,
networkID, humanId,
)
return err
}
func (r *repositoryImpl) countSeats(ctx context.Context, db dbtx, networkID string) (int, error) {
var count int
err := db.QueryRow(ctx,
`SELECT COUNT(*) FROM network_members WHERE network_id = $1`,
networkID,
).Scan(&count)
return count, err
}
func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) {
rows, err := r.pool.Query(ctx,
`SELECT human_id FROM network_members WHERE network_id = $1`,
networkID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var humanIds []string
for rows.Next() {
var humanId string
if err := rows.Scan(&humanId); err != nil {
return nil, err
}
humanIds = append(humanIds, humanId)
}
return humanIds, rows.Err()
}
func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+networkColumns+`
FROM networks
WHERE admin_human_id = $1
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = id AND nm.human_id = $1)`,
humanId,
)
if err != nil {
return nil, err
}
defer rows.Close()
var networks []*Network
for rows.Next() {
var n Network
if err := scanNetwork(rows, &n); err != nil {
return nil, err
}
networks = append(networks, &n)
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, n := range networks {
n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
if err != nil {
return nil, err
}
}
return networks, nil
}
func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string) (bool, error) {
var isMember bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM networks n
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.human_id = $2
WHERE n.id = $1 AND (n.admin_human_id = $2 OR nm.human_id IS NOT NULL)
)
`, networkID, humanId).Scan(&isMember)
return isMember, err
}
func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) {
rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]string{}
for rows.Next() {
var humanId, networkId string
if err := rows.Scan(&humanId, &networkId); err != nil {
return nil, err
}
out[humanId] = append(out[humanId], networkId)
}
return out, rows.Err()
}
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+networkColumns+` FROM networks`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var networks []*Network
for rows.Next() {
var n Network
if err := scanNetwork(rows, &n); err != nil {
return nil, err
}
networks = append(networks, &n)
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, n := range networks {
n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
if err != nil {
return nil, err
}
}
return networks, nil
}
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
func (r *repositoryImpl) addMember(ctx context.Context, networkID, email string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
`INSERT INTO network_members (network_id, email) VALUES ($1, $2)
ON CONFLICT (network_id, email) DO NOTHING`,
networkID, email,
)
return err
}
func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) {
func (r *repositoryImpl) removeMember(ctx context.Context, networkID, email string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM network_members WHERE network_id = $1 AND email = $2`,
networkID, email,
)
return err
}
func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string) ([]string, error) {
rows, err := r.pool.Query(ctx,
`SELECT ni.network_id, n.name, ni.email, ni.created_at
FROM network_invitations ni
JOIN networks n ON n.id = ni.network_id
WHERE ni.email = $1`,
`SELECT email FROM network_members WHERE network_id = $1`,
networkID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var emails []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
emails = append(emails, email)
}
return emails, rows.Err()
}
func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) {
rows, err := r.pool.Query(ctx,
`SELECT n.id, n.name, n.admin_email, n.open_stream_capacity, n.open_stream_count, n.created_at
FROM networks n
WHERE n.admin_email = $1
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.email = $1)`,
email,
)
if err != nil {
@@ -303,45 +165,88 @@ func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string
}
defer rows.Close()
var invitations []*Invitation
var networks []*Network
for rows.Next() {
var inv Invitation
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
var n Network
if err := rows.Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil {
return nil, err
}
invitations = append(invitations, &inv)
networks = append(networks, &n)
}
return invitations, rows.Err()
}
func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
rows, err := r.pool.Query(ctx,
`SELECT ni.network_id, n.name, ni.email, ni.created_at
FROM network_invitations ni
JOIN networks n ON n.id = ni.network_id
WHERE ni.network_id = $1`,
networkID,
)
if err != nil {
if err := rows.Err(); err != nil {
return nil, err
}
defer rows.Close()
var invitations []*Invitation
for rows.Next() {
var inv Invitation
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
for _, n := range networks {
n.MemberEmails, err = r.getMemberEmails(ctx, n.ID)
if err != nil {
return nil, err
}
invitations = append(invitations, &inv)
}
return invitations, rows.Err()
return networks, nil
}
func (r *repositoryImpl) deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error {
_, err := db.Exec(ctx,
`DELETE FROM network_invitations WHERE network_id = $1 AND email = $2`,
networkID, email,
func (r *repositoryImpl) isMember(ctx context.Context, networkID, email string) (bool, error) {
var isMember bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM networks n
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.email = $2
WHERE n.id = $1 AND (n.admin_email = $2 OR nm.email IS NOT NULL)
)
`, networkID, email).Scan(&isMember)
return isMember, err
}
func (r *repositoryImpl) setOpenStreamCapacity(ctx context.Context, id string, capacity int) error {
result, err := r.pool.Exec(ctx,
`UPDATE networks SET open_stream_capacity = $1 WHERE id = $2`,
capacity, id,
)
return err
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (r *repositoryImpl) incrementOpenStreamCount(ctx context.Context, id string) error {
result, err := r.pool.Exec(ctx,
`UPDATE networks SET open_stream_count = open_stream_count + 1
WHERE id = $1 AND open_stream_count < open_stream_capacity`,
id,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
// Check if network exists vs capacity exceeded
var exists bool
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM networks WHERE id = $1)`, id).Scan(&exists)
if err != nil {
return err
}
if !exists {
return errNotFound
}
return errCapacityExceeded
}
return nil
}
func (r *repositoryImpl) decrementOpenStreamCount(ctx context.Context, id string) error {
result, err := r.pool.Exec(ctx,
`UPDATE networks SET open_stream_count = GREATEST(0, open_stream_count - 1) WHERE id = $1`,
id,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return errNotFound
}
return nil
}
+72 -320
View File
@@ -4,114 +4,76 @@ import (
"context"
"errors"
"fmt"
"html"
"slices"
"strings"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
pbaero "github.com/flowy-live/llink/genproto/aero"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/constants"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/livestore"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("network not found")
var ErrInvalidName = errors.New("name cannot be empty")
var ErrInvalidEmail = errors.New("invalid email")
var ErrInvalidHumanId = errors.New("invalid humanId")
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")
type humanLookup interface {
GetByID(ctx context.Context, id string) (*human.Human, error)
}
type Service interface {
Reader
// Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
Create(ctx context.Context, name, adminHumanId string) (*Network, error)
// Create creates a network and adds adminEmail as the first member. Returns ErrInvalidName if name is empty.
Create(ctx context.Context, name, adminEmail string) (*Network, error)
// GetByID returns ErrNotFound if network doesn't exist.
GetByID(ctx context.Context, id string) (*Network, error)
// SetName returns ErrNotFound or ErrInvalidName.
SetName(ctx context.Context, id, name string) error
// AddMembers inserts members and syncs the new seat count to billing
// atomically; a Stripe failure rolls the insert back.
// Returns ErrInvalidHumanId if any humanId is empty.
AddMembers(ctx context.Context, networkID string, humanIds []string) error
// RemoveMember returns ErrInvalidHumanId if humanId is empty.
RemoveMember(ctx context.Context, networkID, humanId string) error
AddMembers(ctx context.Context, networkID string, emails []string) error
RemoveMember(ctx context.Context, networkID, email string) error
ListForEmail(ctx context.Context, email string) ([]*Network, error)
IsMember(ctx context.Context, networkID, email string) (bool, error)
// Invitations (email-based, for users who haven't registered yet)
// InviteByEmail returns ErrNotFound if the network doesn't exist
// or ErrInvalidEmail if any email fails normalization.
InviteByEmail(ctx context.Context, networkID string, emails []string) error
// AcceptInvitation returns ErrInvalidEmail or ErrInvalidHumanId.
AcceptInvitation(ctx context.Context, networkID, email, humanId string) error
// RevokeInvitation returns ErrInvalidEmail.
RevokeInvitation(ctx context.Context, networkID, email string) error
// SetOpenStreamCapacity sets the max open streams for a network. Returns ErrNotFound.
SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error
// IncrementOpenStreamCount returns ErrNotFound or ErrCapacityExceeded.
IncrementOpenStreamCount(ctx context.Context, networkID string) error
// DecrementOpenStreamCount returns ErrNotFound.
DecrementOpenStreamCount(ctx context.Context, networkID string) error
}
type serviceImpl struct {
*readerImpl
aeroSvc pbaero.PrimaryClient
billingSvc billing.Service
pub livestore.MembershipPublisher
humanLookup humanLookup
repo repository
}
func NewService(
pool *pgxpool.Pool,
aeroSvc pbaero.PrimaryClient,
billingSvc billing.Service,
pub livestore.MembershipPublisher,
humanLookup humanLookup,
) Service {
return &serviceImpl{
readerImpl: newReader(pool),
aeroSvc: aeroSvc,
billingSvc: billingSvc,
pub: pub,
humanLookup: humanLookup,
}
func NewService(pool *pgxpool.Pool) Service {
return &serviceImpl{repo: newRepository(pool)}
}
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
func (s *serviceImpl) Create(ctx context.Context, name, adminEmail string) (*Network, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrInvalidName
}
network, err := s.repo.create(ctx, name, adminHumanId)
adminEmail, err := utils.NormalizeEmail(adminEmail)
if err != nil {
return nil, err
}
if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
network, err := s.repo.create(ctx, name, adminEmail)
if err != nil {
return nil, err
}
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{constants.FlowyAdminEmail},
Subject: fmt.Sprintf("New network created: %s", network.Name),
TemplateData: &pbaero.ShootEmailRequest_GenericFlowyAdminAlertData{
GenericFlowyAdminAlertData: &pbaero.GenericFlowyAdminAlertData{
Message: "This is a simple notification that a new network was created. Please attend to them.",
},
},
})
err = s.AddMembers(ctx, network.ID, []string{adminEmail})
if err != nil {
flog.Warn("unable to send admin update email", "error", err)
return nil, err
}
return network, nil
}
func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Network, error) {
n, err := s.repo.getByID(ctx, id)
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return n, err
}
func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
name = strings.TrimSpace(name)
if name == "" {
@@ -125,279 +87,69 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
return err
}
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
if slices.Contains(humanIds, "") {
return ErrInvalidHumanId
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
for _, humanId := range humanIds {
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
for _, humanId := range humanIds {
s.mirrorAddMembership(ctx, humanId, networkID)
}
return nil
}
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
if humanId == "" {
return ErrInvalidHumanId
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
return s.repo.removeMember(ctx, tx, networkID, humanId)
}); err != nil {
return err
}
s.mirrorRemoveMembership(ctx, humanId, networkID)
return nil
}
// Mirror the live store membership projection (humans/{humanId}.networks).
// Postgres is the source of truth: failures are logged and the reconciler heals drift.
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
if err := s.pub.Add(ctx, humanId, networkID); err != nil {
flog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) {
if err := s.pub.Remove(ctx, humanId, networkID); err != nil {
flog.Error("membership publish remove failed", "error", err, "humanId", humanId, "networkID", networkID)
}
}
// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if err := fn(tx); err != nil {
return err
}
seats, err := s.repo.countSeats(ctx, tx, networkID)
if err != nil {
return fmt.Errorf("count seats: %w", err)
}
if err := s.billingSvc.SyncSeats(ctx, networkID, seats); err != nil {
return fmt.Errorf("sync billing seats: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
network, err := s.repo.getByID(ctx, networkID)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, emails []string) error {
for _, email := range emails {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return fmt.Errorf("%q: %w: %w", email, ErrInvalidEmail, err)
return fmt.Errorf("invalid email: %w", err)
}
if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil {
if err := s.repo.addMember(ctx, networkID, normalized); err != nil {
return err
}
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: []string{email},
Subject: fmt.Sprintf("Invitation to Join %s on Flowy.llink", network.Name),
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
SimpleHtmlData: &pbaero.SimpleHtmlData{
Html: buildInvitationHTML(network.Name),
},
},
})
if err != nil {
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
}
}
return nil
}
func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error {
network, err := s.repo.getByID(ctx, networkID)
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, email string) error {
email, err := utils.NormalizeEmail(email)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
return fmt.Errorf("invalid email: %w", err)
}
return s.repo.removeMember(ctx, networkID, email)
}
normalized, err := utils.NormalizeEmail(email)
func (s *serviceImpl) ListForEmail(ctx context.Context, email string) ([]*Network, error) {
email, err := utils.NormalizeEmail(email)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
return nil, fmt.Errorf("invalid email: %w", err)
}
if humanId == "" {
return ErrInvalidHumanId
}
prevMembersHumanIds, membersErr := s.repo.getMemberHumanIds(ctx, networkID)
if membersErr != nil {
flog.Warn("unable to get member human ids", "error", membersErr)
}
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
if err != nil {
return err
}
return s.repo.addMember(ctx, tx, networkID, humanId)
}); err != nil {
return err
}
if membersErr == nil && len(prevMembersHumanIds) > 0 {
emailRecipients := make([]string, 0, len(prevMembersHumanIds))
for _, memberHumanId := range prevMembersHumanIds {
if memberHumanId != "" {
human, err := s.humanLookup.GetByID(ctx, memberHumanId)
if err != nil {
flog.Warn("unable to find human", "error", err, "humanId", memberHumanId)
continue
}
if human.Email == "" || !human.EmailNotificationsEnabled {
continue
}
emailRecipients = append(emailRecipients, human.Email)
}
}
if len(emailRecipients) > 0 {
newMemberEmailPrefix := strings.Split(normalized, "@")[0]
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
ToEmails: emailRecipients,
Subject: fmt.Sprintf("A new member has joined %s", network.Name),
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
SimpleHtmlData: &pbaero.SimpleHtmlData{
Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
},
},
})
if err != nil {
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
}
}
}
s.mirrorAddMembership(ctx, humanId, networkID)
return nil
return s.repo.getNetworksForEmail(ctx, email)
}
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
normalized, err := utils.NormalizeEmail(email)
func (s *serviceImpl) IsMember(ctx context.Context, networkID, email string) (bool, error) {
email, err := utils.NormalizeEmail(email)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
return false, err
}
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
return s.repo.isMember(ctx, networkID, email)
}
const (
emailWebAppURL = "https://llink.flowy.live"
emailDesktopURL = "llink://"
emailDownloadURL = "https://flowylabs.ai/llink/download"
)
// emailDesktopFooter is the shared secondary line offering the desktop app.
// The web app is always the primary CTA (no install required), so desktop is
// kept quiet here and shared across templates so the two can't drift apart.
func emailDesktopFooter() string {
return fmt.Sprintf(`<tr>
<td style="font-size:13px;color:#888888;">
Prefer the desktop app? <a href="%s" style="color:#111111;">Open it</a> or <a href="%s" style="color:#111111;">download here</a>.
</td>
</tr>`, emailDesktopURL, emailDownloadURL)
func (s *serviceImpl) SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error {
if capacity < 0 {
capacity = 0
}
err := s.repo.setOpenStreamCapacity(ctx, networkID, capacity)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
func buildInvitationHTML(networkName string) string {
safeName := html.EscapeString(networkName)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
You&#39;ve been invited to join %s on Flowy.llink
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
Open the app to accept your invitation. If you&#39;re new, you&#39;ll be prompted to create a free account first.
</td>
</tr>
<tr>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, emailWebAppURL, emailDesktopFooter())
func (s *serviceImpl) IncrementOpenStreamCount(ctx context.Context, networkID string) error {
err := s.repo.incrementOpenStreamCount(ctx, networkID)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
if errors.Is(err, errCapacityExceeded) {
return ErrCapacityExceeded
}
return err
}
// buildNewMemberHTML is an email template to notify other members that a new member has joined
func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string {
safeName := html.EscapeString(networkName)
safeEmail := html.EscapeString(newMemberEmailPrefix)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
A new member joined %s
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
<strong style="color:#111111;">%s</strong> just joined your network on Flowy.llink. Say hello and bring them up to speed.
</td>
</tr>
<tr>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter())
func (s *serviceImpl) DecrementOpenStreamCount(ctx context.Context, networkID string) error {
err := s.repo.decrementOpenStreamCount(ctx, networkID)
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
+17 -117
View File
@@ -5,17 +5,10 @@ import (
"os"
"testing"
pbaero "github.com/flowy-live/llink/genproto/aero"
mock_billing "github.com/flowy-live/llink/internal/billing/mocks"
"github.com/flowy-live/llink/internal/human"
mock_human "github.com/flowy-live/llink/internal/human/mocks"
mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/testhelper"
"github.com/flowy-live/llink/internal/testhelper/mocks/aero"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
var dbPool *pgxpool.Pool
@@ -28,47 +21,16 @@ func TestMain(m *testing.M) {
os.Exit(ret)
}
func newTestService(t *testing.T) network.Service {
ctrl := gomock.NewController(t)
mockAero := aero.NewMockPrimaryClient(ctrl)
mockAero.EXPECT().
ShootEmail(gomock.Any(), gomock.Any()).
Return(&pbaero.ShootEmailResponse{}, nil).
AnyTimes()
mockBilling := mock_billing.NewMockService(ctrl)
mockBilling.EXPECT().SyncSeats(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockPub := mock_livestore.NewMockMembershipPublisher(ctrl)
mockPub.EXPECT().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockPub.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockHuman := mock_human.NewMockService(ctrl)
mockHuman.EXPECT().GetByID(gomock.Any(), gomock.Any()).Return(&human.Human{
ID: "test_human",
Email: "[email protected]",
EmailPrefix: "test",
EmailNotificationsEnabled: false,
}, nil).AnyTimes()
return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman)
}
func TestNetworkService(t *testing.T) {
ctx := context.Background()
svc := newTestService(t)
adminHumanId := "human_admin123"
member1HumanId := "human_member1abc"
member2HumanId := "human_member2def"
strangerHumanId := "human_stranger789"
svc := network.NewService(dbPool)
// Test Create
createdNetwork, err := svc.Create(ctx, "Test Network", adminHumanId)
createdNetwork, err := svc.Create(ctx, "Test Network", "[email protected]")
assert.NoError(t, err)
assert.NotEmpty(t, createdNetwork.ID)
assert.Equal(t, "Test Network", createdNetwork.Name)
assert.Equal(t, adminHumanId, createdNetwork.AdminHumanId)
assert.Equal(t, "[email protected]", createdNetwork.AdminEmail)
assert.NotZero(t, createdNetwork.CreatedAt)
// Test GetByID
@@ -76,7 +38,7 @@ func TestNetworkService(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, createdNetwork.ID, foundNetwork.ID)
assert.Equal(t, createdNetwork.Name, foundNetwork.Name)
assert.Equal(t, createdNetwork.AdminHumanId, foundNetwork.AdminHumanId)
assert.Equal(t, createdNetwork.AdminEmail, foundNetwork.AdminEmail)
// Test GetByID with non-existent id
_, err = svc.GetByID(ctx, "network_nonexistent")
@@ -98,45 +60,45 @@ func TestNetworkService(t *testing.T) {
assert.ErrorIs(t, err, network.ErrNotFound)
// Test AddMembers
err = svc.AddMembers(ctx, createdNetwork.ID, []string{member1HumanId, member2HumanId})
err = svc.AddMembers(ctx, createdNetwork.ID, []string{"[email protected]", "[email protected]"})
assert.NoError(t, err)
// Test ListForHuman - should find network for admin
networks, err := svc.ListForHuman(ctx, adminHumanId)
// Test ListForEmail - should find network for admin
networks, err := svc.ListForEmail(ctx, "[email protected]")
assert.NoError(t, err)
assert.Len(t, networks, 1)
assert.Equal(t, createdNetwork.ID, networks[0].ID)
// Test ListForHuman - should find network for member
networks, err = svc.ListForHuman(ctx, member1HumanId)
// Test ListForEmail - should find network for member
networks, err = svc.ListForEmail(ctx, "[email protected]")
assert.NoError(t, err)
assert.Len(t, networks, 1)
assert.Equal(t, createdNetwork.ID, networks[0].ID)
// Test ListForHuman - should return empty for non-member
networks, err = svc.ListForHuman(ctx, strangerHumanId)
// Test ListForEmail - should return empty for non-member
networks, err = svc.ListForEmail(ctx, "[email protected]")
assert.NoError(t, err)
assert.Len(t, networks, 0)
// Test RemoveMember
err = svc.RemoveMember(ctx, createdNetwork.ID, member1HumanId)
err = svc.RemoveMember(ctx, createdNetwork.ID, "[email protected]")
assert.NoError(t, err)
// Verify member was removed
networks, err = svc.ListForHuman(ctx, member1HumanId)
networks, err = svc.ListForEmail(ctx, "[email protected]")
assert.NoError(t, err)
assert.Len(t, networks, 0)
// member2 should still have access
networks, err = svc.ListForHuman(ctx, member2HumanId)
networks, err = svc.ListForEmail(ctx, "[email protected]")
assert.NoError(t, err)
assert.Len(t, networks, 1)
// Create another network and verify ListForHuman returns multiple
network2, err := svc.Create(ctx, "Second Network", member2HumanId)
// Create another network and verify ListForEmail returns multiple
network2, err := svc.Create(ctx, "Second Network", "[email protected]")
assert.NoError(t, err)
networks, err = svc.ListForHuman(ctx, member2HumanId)
networks, err = svc.ListForEmail(ctx, "[email protected]")
assert.NoError(t, err)
assert.Len(t, networks, 2)
@@ -145,65 +107,3 @@ func TestNetworkService(t *testing.T) {
assert.Contains(t, networkIDs, createdNetwork.ID)
assert.Contains(t, networkIDs, network2.ID)
}
func TestNetworkInvitations(t *testing.T) {
ctx := context.Background()
svc := newTestService(t)
adminHumanId := "human_invtest_admin"
inviteeEmail := "[email protected]"
inviteeHumanId := "human_invitee123"
// Create a network
net, err := svc.Create(ctx, "Invitation Test Network", adminHumanId)
assert.NoError(t, err)
// Invite by email
err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail})
assert.NoError(t, err)
// List invitations for email
invitations, err := svc.ListInvitationsForEmail(ctx, inviteeEmail)
assert.NoError(t, err)
assert.Len(t, invitations, 1)
assert.Equal(t, net.ID, invitations[0].NetworkID)
assert.Equal(t, inviteeEmail, invitations[0].Email)
// List invitations for network
invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID)
assert.NoError(t, err)
assert.Len(t, invitations, 1)
// Duplicate invite is idempotent
err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail})
assert.NoError(t, err)
invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID)
assert.NoError(t, err)
assert.Len(t, invitations, 1)
// Accept invitation
err = svc.AcceptInvitation(ctx, net.ID, inviteeEmail, inviteeHumanId)
assert.NoError(t, err)
// Invitation should be removed
invitations, err = svc.ListInvitationsForEmail(ctx, inviteeEmail)
assert.NoError(t, err)
assert.Len(t, invitations, 0)
// Human should now be a member
isMember, err := svc.IsMember(ctx, net.ID, inviteeHumanId)
assert.NoError(t, err)
assert.True(t, isMember)
// Test revoke invitation
revokeEmail := "[email protected]"
err = svc.InviteByEmail(ctx, net.ID, []string{revokeEmail})
assert.NoError(t, err)
err = svc.RevokeInvitation(ctx, net.ID, revokeEmail)
assert.NoError(t, err)
invitations, err = svc.ListInvitationsForEmail(ctx, revokeEmail)
assert.NoError(t, err)
assert.Len(t, invitations, 0)
}
-41
View File
@@ -1,41 +0,0 @@
package network
import "strings"
// ResolveVisibility expands a stream particle's visible_to entries into the set
// of human IDs that should see (and thus be notified about) activity in that
// stream. Entries are formatted as `human:{id}` for a specific human or
// `network:{id}` to expand to every member of the surrounding network.
//
// networkMembers must contain every human currently in the network (members +
// admin). visible_to entries that point to humans no longer in the network are
// dropped — they may have been removed since the stream was created.
//
// Returns a deduped slice; ordering is not stable.
func ResolveVisibility(visibleTo []string, networkMembers []string) []string {
memberSet := make(map[string]bool, len(networkMembers))
for _, id := range networkMembers {
memberSet[id] = true
}
result := make(map[string]bool)
for _, entry := range visibleTo {
switch {
case strings.HasPrefix(entry, "human:"):
id := strings.TrimPrefix(entry, "human:")
if memberSet[id] {
result[id] = true
}
case strings.HasPrefix(entry, "network:"):
for id := range memberSet {
result[id] = true
}
}
}
out := make([]string, 0, len(result))
for id := range result {
out = append(out, id)
}
return out
}
-1
View File
@@ -16,5 +16,4 @@ var (
ErrMembersRequired = errors.New("custom visibility requires at least one member")
ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility")
ErrNotAContainer = errors.New("only streams can have members")
ErrInvalidMember = errors.New("member is not in the network")
)
-57
View File
@@ -1,57 +0,0 @@
package particle
import "time"
type FirestoreMediaParticle struct {
CreatedByHumanId string `firestore:"created_by_human_id"`
Type string `firestore:"type"`
Properties FirestoreMediaParticleProperties `firestore:"properties"`
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
}
type FirestoreTranscriptWord struct {
Word string `firestore:"word"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscriptSentence struct {
Text string `firestore:"text"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscriptParagraph struct {
Sentences []FirestoreTranscriptSentence `firestore:"sentences"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscript struct {
Transcript string `firestore:"transcript"`
Words []FirestoreTranscriptWord `firestore:"words"`
Paragraphs []FirestoreTranscriptParagraph `firestore:"paragraphs"`
}
type FirestoreMediaParticleProperties struct {
ObjectId string `firestore:"object_id"`
MimeType string `firestore:"mime_type"`
DurationMs int `firestore:"duration_ms"`
SizeBytes int `firestore:"size_bytes"`
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
TranscodedObjectId string `firestore:"transcoded_object_id,omitempty"`
TranscodedMimeType string `firestore:"transcoded_mime_type,omitempty"`
}
type FirestoreStreamParticle struct {
CreatedByHumanId string `firestore:"created_by_human_id"`
Type string `firestore:"type"`
Status string `firestore:"status"`
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
VisibleTo []string `firestore:"visible_to"`
PlaybackMarkers map[string]time.Time `firestore:"playback_markers,omitempty"`
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
HuddleActiveParticipants []string `firestore:"huddle_active_participants,omitempty"`
}
@@ -1,56 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./networkmembershipchecker.go
//
// Generated by this command:
//
// mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
//
// Package mock_particle is a generated GoMock package.
package mock_particle
import (
context "context"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockNetworkMembershipChecker is a mock of NetworkMembershipChecker interface.
type MockNetworkMembershipChecker struct {
ctrl *gomock.Controller
recorder *MockNetworkMembershipCheckerMockRecorder
isgomock struct{}
}
// MockNetworkMembershipCheckerMockRecorder is the mock recorder for MockNetworkMembershipChecker.
type MockNetworkMembershipCheckerMockRecorder struct {
mock *MockNetworkMembershipChecker
}
// NewMockNetworkMembershipChecker creates a new mock instance.
func NewMockNetworkMembershipChecker(ctrl *gomock.Controller) *MockNetworkMembershipChecker {
mock := &MockNetworkMembershipChecker{ctrl: ctrl}
mock.recorder = &MockNetworkMembershipCheckerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockNetworkMembershipChecker) EXPECT() *MockNetworkMembershipCheckerMockRecorder {
return m.recorder
}
// IsMember mocks base method.
func (m *MockNetworkMembershipChecker) IsMember(ctx context.Context, networkID, humanId string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsMember", ctx, networkID, humanId)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsMember indicates an expected call of IsMember.
func (mr *MockNetworkMembershipCheckerMockRecorder) IsMember(ctx, networkID, humanId any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsMember", reflect.TypeOf((*MockNetworkMembershipChecker)(nil).IsMember), ctx, networkID, humanId)
}
+23 -4
View File
@@ -6,6 +6,7 @@ import (
"time"
)
// ParticleType represents the type of particle
type ParticleType string
const (
@@ -19,6 +20,7 @@ const (
// TypeThink ParticleType = "think"
)
// VisibilityMode represents how access to a particle is determined
type VisibilityMode string
const (
@@ -30,6 +32,7 @@ const (
var ErrInvalidParticleType = errors.New("invalid particle type")
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
// ParseParticleType parses a string into a ParticleType
func ParseParticleType(s string) (ParticleType, error) {
switch s {
case string(TypeStream):
@@ -51,6 +54,7 @@ func ParseParticleType(s string) (ParticleType, error) {
}
}
// ParseVisibilityMode parses a string into a VisibilityMode
func ParseVisibilityMode(s string) (VisibilityMode, error) {
switch s {
case "", string(VisibilityNetworkAll):
@@ -64,6 +68,7 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) {
}
}
// Stream status values
type StreamStatus string
const (
@@ -71,6 +76,7 @@ const (
StreamStatusClosed StreamStatus = "closed"
)
// Particle represents a content particle in the system
type Particle struct {
ID string
Type ParticleType
@@ -83,6 +89,7 @@ type Particle struct {
CreatedAt time.Time
}
// CreateInput represents the input for creating a new particle
type CreateInput struct {
Type ParticleType
NetworkID string
@@ -92,15 +99,18 @@ type CreateInput struct {
Visibility VisibilityMode
}
// ListFilter represents filtering options for listing particles
type ListFilter struct {
Types []ParticleType
}
// Cursor represents a pagination cursor for bidirectional pagination
type Cursor struct {
Position string // particle ID or timestamp
Direction string // "before" or "after"
}
// ParticleList represents a paginated list of particles
type ParticleList struct {
Particles []*Particle
HasMore bool
@@ -108,48 +118,57 @@ type ParticleList struct {
PrevCursor *Cursor
}
// StreamData represents the data stored for stream particles
type StreamData struct {
Name string `json:"name"`
Status string `json:"status"` // "open" or "closed"
Description *string `json:"description"`
}
// FolderData represents the data stored for folder particles
type FolderData struct {
Name string `json:"name"`
Color *string `json:"color"`
}
// MediaData represents the data stored for media particles
type MediaData struct {
ObjectID string `json:"object_id"`
ObjectID string `json:"object_id"` // reference to storage object
MimeType string `json:"mime_type"`
DurationMs int `json:"duration_ms"`
// Caption *string `json:"caption"`
}
// FileData represents the data stored for file particles
type FileData struct {
ObjectID string `json:"object_id"`
ObjectID string `json:"object_id"` // reference to storage object
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"` // bytes
Size int64 `json:"size"` // in bytes
}
// TextData represents the data stored for text particles
type TextData struct {
Content string `json:"content"`
}
// QuestData represents the data stored for quest particles
type QuestData struct {
Title string `json:"title"`
Description string `json:"description"`
Done bool `json:"done"`
Status *string `json:"status"`
AssignedTo *string `json:"assigned_to,omitempty"` // email
DueDate *string `json:"due_date,omitempty"` // ISO date
DueDate *string `json:"due_date,omitempty"` // ISO date string
}
// PaperData represents the data stored for paper particles
type PaperData struct {
Title string `json:"title"`
Content string `json:"content"` // markdown
}
// AckInfo represents an acknowledgment record
type AckInfo struct {
Email string
AckedAt time.Time
@@ -1,9 +0,0 @@
package particle
import "context"
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
type NetworkMembershipChecker interface {
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
}
@@ -1,37 +0,0 @@
package particle
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type ProcessingRepository interface {
IsProcessed(ctx context.Context, particleID string) (bool, error)
MarkProcessed(ctx context.Context, particleID string) error
}
type processingRepositoryImpl struct {
pool *pgxpool.Pool
}
func NewProcessingRepository(pool *pgxpool.Pool) ProcessingRepository {
return &processingRepositoryImpl{pool: pool}
}
func (r *processingRepositoryImpl) IsProcessed(ctx context.Context, particleID string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM processed_particles WHERE particle_id = $1)`,
particleID,
).Scan(&exists)
return exists, err
}
func (r *processingRepositoryImpl) MarkProcessed(ctx context.Context, particleID string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO processed_particles (particle_id) VALUES ($1) ON CONFLICT (particle_id) DO NOTHING`,
particleID,
)
return err
}
+1 -1
View File
@@ -40,7 +40,7 @@ type repository interface {
getMembers(ctx context.Context, particleID string) ([]string, error)
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
// getAncestorChain returns the particle followed by its ancestors, in order.
// getAncestorChain returns the particle and all its ancestors (for access checks)
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
+146 -84
View File
@@ -4,75 +4,75 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/utils"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultPageSize = 50
// Deprecated: particle data now lives in Firestore. The Postgres-backed
// service is retained only for legacy paths.
type Service interface {
// Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired,
// ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network
// membership is verified by the handler.
// Create creates a new particle. Caller must be a network member (verified by handler).
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
// GetByID returns ErrNotFound or ErrAccessDenied.
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
// Update returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
// Delete returns ErrNotFound or ErrAccessDenied.
Delete(ctx context.Context, id, requesterEmail string) error
// List uses parentID=nil for root particles. Returns ErrNotFound or
// ErrAccessDenied when parentID is given but inaccessible.
// List returns particles in a network. Use parentID=nil for root particles.
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
// OpenStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream,
// ErrStreamAlreadyOpen, or ErrCapacityExceeded.
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
OpenStream(ctx context.Context, id, requesterEmail string) error
// CloseStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or
// ErrStreamAlreadyClosed.
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
CloseStream(ctx context.Context, id, requesterEmail string) error
// SetVisibility returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
// AddMembers / RemoveMembers operate on custom-visibility streams only.
// Both return ErrNotFound or ErrAccessDenied.
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
// Seen tracking is private per human; Ack is public and permanent.
// Seen tracking (private)
MarkSeen(ctx context.Context, id, requesterEmail string) error
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
// Ack tracking (public, permanent)
Ack(ctx context.Context, id, requesterEmail string) error
// Unseen counts for stream list view
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
// Bulk lookups for batch hydration.
// Bulk lookups for handler enrichment
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
}
type serviceImpl struct {
repo repository
networkMembershipChecker NetworkMembershipChecker
repo repository
networkSvc network.Service
}
func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Service {
func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service {
return &serviceImpl{
repo: newRepository(pool),
networkMembershipChecker: networkReader,
repo: newRepository(pool),
networkSvc: networkSvc,
}
}
// Walks the ancestor chain when visibility is inherited, stopping at the
// first network_all or custom node. Assumes network membership is already
// verified by the handler.
// checkAccess verifies that the email has access to the particle based on visibility.
// Assumes the caller is already verified as a network member (handler responsibility).
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
// network_all or custom node.
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil {
@@ -83,12 +83,13 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
return false, errNotFound
}
// Build lookup map by ID
byID := make(map[string]*Particle, len(ancestors))
for _, p := range ancestors {
byID[p.ID] = p
}
// ancestors[0] is the target; walk up only on inherited.
// Start from the target particle (first in chain) and walk up on inherited
current := ancestors[0]
for {
switch current.Visibility {
@@ -98,7 +99,7 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
return s.repo.isMemberOf(ctx, current.ID, email)
case VisibilityInherited:
if current.ParentID == nil {
// inherited-at-root is invalid; deny.
// inherited at root is invalid state, deny access
return false, nil
}
parent, ok := byID[*current.ParentID]
@@ -118,24 +119,30 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
return nil, err
}
// Validate particle type
if !isValidParticleType(input.Type) {
return nil, ErrInvalidType
}
// Validate data matches type requirements
if err := validateParticleData(input.Type, input.Data); err != nil {
return nil, err
}
// MVP visibility: children always inherit; roots cannot inherit and
// default to network_all. Streams/folders are root-only.
// MVP visibility rules:
// - Child particles (have parent) → always inherited
// - Root particles (no parent) → cannot be inherited, default network_all
if input.ParentID != nil {
// Children always inherit from parent
input.Visibility = VisibilityInherited
input.Members = nil
input.Members = nil // no members on inherited particles
// Reject streams and folders as children (MVP: streams are root-level only)
if input.Type == TypeStream || input.Type == TypeFolder {
return nil, ErrInvalidParent
}
} else {
// Root particles cannot be inherited
if input.Visibility == VisibilityInherited {
return nil, ErrInheritedAtRoot
}
@@ -144,7 +151,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
}
}
var customMembers []string
// Custom visibility requires at least one member and must be a stream
if input.Visibility == VisibilityCustom {
if input.Type != TypeStream {
return nil, ErrNotAContainer
@@ -152,33 +159,10 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
if len(input.Members) == 0 {
return nil, ErrMembersRequired
}
// Validate every member upfront so DB writes are all-or-nothing.
customMembers = make([]string, 0, len(input.Members)+1)
customMembers = append(customMembers, requesterEmail)
seen := map[string]bool{requesterEmail: true}
for _, email := range input.Members {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return nil, errors.Join(ErrInvalidData, err)
}
if seen[normalized] {
continue
}
seen[normalized] = true
isMember, err := s.networkMembershipChecker.IsMember(ctx, input.NetworkID, normalized)
if err != nil {
return nil, err
}
if !isMember {
return nil, fmt.Errorf("%w: %s", ErrInvalidMember, normalized)
}
customMembers = append(customMembers, normalized)
}
}
// Network membership is verified by the handler; only particle visibility is checked here.
// Network membership is verified by handler - we only check particle visibility
// If parent specified, check parent access (visibility-based)
if input.ParentID != nil {
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
if err != nil {
@@ -192,6 +176,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
}
}
// Build the particle
p := &Particle{
Type: input.Type,
NetworkID: input.NetworkID,
@@ -205,22 +190,53 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
p.Data = json.RawMessage("{}")
}
// New streams default to open.
// For streams, set initial status to open and check capacity
if input.Type == TypeStream {
// Set status to open in the data JSON
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil {
return nil, err
}
p.Data = data
// Check and increment capacity
err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID)
if err != nil {
if errors.Is(err, network.ErrCapacityExceeded) {
return nil, ErrCapacityExceeded
}
return nil, err
}
}
// Create the particle
created, err := s.repo.create(ctx, p)
if err != nil {
// If we incremented the stream count but creation failed, decrement it
if input.Type == TypeStream {
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID)
}
}
return nil, err
}
if len(customMembers) > 0 {
if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
// Add members if custom visibility (only streams for MVP)
if input.Visibility == VisibilityCustom && len(input.Members) > 0 {
normalizedEmails := make([]string, 0, len(input.Members)+1)
// Always include the creator
normalizedEmails = append(normalizedEmails, requesterEmail)
for _, email := range input.Members {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
continue // Skip invalid emails
}
if normalized == requesterEmail {
continue // Already added
}
normalizedEmails = append(normalizedEmails, normalized)
}
if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); err != nil {
return nil, err
}
}
@@ -234,6 +250,7 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*
return nil, err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -261,6 +278,7 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -272,6 +290,7 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, ErrAccessDenied
}
// Get the particle to validate data against its type
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -280,6 +299,7 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, err
}
// Validate data matches type requirements
if err := validateParticleData(p.Type, data); err != nil {
return nil, err
}
@@ -301,6 +321,7 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -312,7 +333,8 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return ErrAccessDenied
}
_, err = s.repo.getByID(ctx, id)
// Get the particle to check if it's an open stream
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
@@ -320,6 +342,13 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return err
}
// If it's an open stream, decrement the count
if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) {
if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil {
return err
}
}
err = s.repo.delete(ctx, id)
if errors.Is(err, errNotFound) {
return ErrNotFound
@@ -333,7 +362,8 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
return nil, err
}
// Network membership is verified by the handler; only particle visibility is checked here.
// Network membership is verified by handler - we only check particle visibility
// If parentID specified, check access to parent (visibility-based)
if parentID != nil {
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
if err != nil {
@@ -347,7 +377,8 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
}
}
// Fetch limit+1 to detect a next page; visibility filtering lives in the query.
// Fetch one extra to determine if there are more
// Access filtering is done in the query itself (network_all OR user is member)
if limit == 0 {
limit = defaultPageSize
}
@@ -394,6 +425,7 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -405,6 +437,7 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return ErrAccessDenied
}
// Get the particle
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -421,13 +454,30 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return ErrStreamAlreadyOpen
}
// Check and increment capacity
err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID)
if err != nil {
if errors.Is(err, network.ErrCapacityExceeded) {
return ErrCapacityExceeded
}
return err
}
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil {
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
}
return err
}
err = s.repo.update(ctx, id, newData, time.Now())
if err != nil {
// Rollback the capacity increment
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
}
if errors.Is(err, errNotFound) {
return ErrNotFound
}
@@ -443,6 +493,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -454,6 +505,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return ErrAccessDenied
}
// Get the particle
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -470,6 +522,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return ErrStreamAlreadyClosed
}
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
if err != nil {
return err
@@ -483,7 +536,8 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return err
}
return nil
// Decrement capacity
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
}
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
@@ -492,6 +546,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -503,6 +558,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return ErrAccessDenied
}
// Get the particle to check constraints
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -511,11 +567,12 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err
}
// Root particles cannot be inherited
if mode == VisibilityInherited && p.ParentID == nil {
return ErrInheritedAtRoot
}
// Expanding to network_all is rejected if any ancestor restricts to custom.
// If expanding to network_all, check that parent's effective visibility allows it
if mode == VisibilityNetworkAll && p.ParentID != nil {
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
if err != nil {
@@ -533,7 +590,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err
}
// Walks up the inherited chain to the concrete visibility node.
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil {
@@ -570,6 +627,7 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -581,6 +639,7 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return ErrAccessDenied
}
// Get the particle to check type and parent access
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -589,30 +648,25 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return err
}
// Only streams can have members
if p.Type != TypeStream {
return ErrNotAContainer
}
// Validate every email upfront so any failure aborts before DB writes.
// Validate and normalize emails, check network membership
normalizedEmails := make([]string, 0, len(emails))
seen := make(map[string]bool, len(emails))
for _, email := range emails {
normalized, err := utils.NormalizeEmail(email)
if err != nil {
return errors.Join(ErrInvalidData, err)
}
if seen[normalized] {
continue
}
seen[normalized] = true
isMember, err := s.networkMembershipChecker.IsMember(ctx, p.NetworkID, normalized)
if err != nil {
return err
}
if !isMember {
return fmt.Errorf("%w: %s", ErrInvalidMember, normalized)
// Root stream - check network membership
isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized)
if err != nil || !isMember {
continue
}
normalizedEmails = append(normalizedEmails, normalized)
}
@@ -629,6 +683,7 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -640,6 +695,7 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return ErrAccessDenied
}
// Get the particle to check type
p, err := s.repo.getByID(ctx, id)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -648,6 +704,7 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return err
}
// Only streams can have members
if p.Type != TypeStream {
return ErrNotAContainer
}
@@ -674,6 +731,7 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -694,17 +752,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester
return err
}
// Silently skip particles that are missing or inaccessible.
// Check access for each particle and mark seen
for _, id := range ids {
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
continue
continue // Skip non-existent particles
}
return err
}
if !hasAccess {
continue
continue // Skip inaccessible particles
}
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
@@ -721,6 +779,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
return err
}
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil {
if errors.Is(err, errNotFound) {
@@ -732,7 +791,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
return ErrAccessDenied
}
// Ack implies seen.
// Ack also marks as seen
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
return err
}
@@ -775,6 +834,7 @@ func isValidParticleType(t ParticleType) bool {
}
}
// getStreamStatus extracts the status from a stream particle's data
func getStreamStatus(data json.RawMessage) string {
var d StreamData
if err := json.Unmarshal(data, &d); err != nil {
@@ -783,6 +843,7 @@ func getStreamStatus(data json.RawMessage) string {
return d.Status
}
// setStreamStatus updates the status in a stream particle's data
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
var d StreamData
if err := json.Unmarshal(data, &d); err != nil {
@@ -792,9 +853,10 @@ func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, erro
return json.Marshal(d)
}
// Returns ErrInvalidData if data is not valid JSON or is missing required
// fields for pType. Empty/null data is allowed and treated as {}.
// validateParticleData validates that the data field contains valid JSON
// and has required fields for the given particle type.
func validateParticleData(pType ParticleType, data json.RawMessage) error {
// Empty or null data is allowed - will default to {}
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
return nil
}
+112 -215
View File
@@ -3,16 +3,14 @@ package particle_test
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
mock_particle "github.com/flowy-live/llink/internal/particle/mocks"
"github.com/flowy-live/llink/internal/testhelper"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
var dbPool *pgxpool.Pool
@@ -33,46 +31,27 @@ func getStreamStatus(data json.RawMessage) string {
return d.Status
}
// newTestService returns a particle.Service backed by the test DB and a fresh
// MockNetworkMembershipChecker. The mock is returned so each test can set its
// own EXPECT() calls for whatever membership behavior it needs. Tests that
// never trigger AddMembers (the only path that consults the checker) can
// discard the mock with _ — gomock will fail the test if it's called
// unexpectedly.
func newTestService(t *testing.T) (particle.Service, *mock_particle.MockNetworkMembershipChecker) {
ctrl := gomock.NewController(t)
mockChecker := mock_particle.NewMockNetworkMembershipChecker(ctrl)
return particle.NewService(dbPool, mockChecker), mockChecker
}
// expectIsMember configures the mock so that IsMember(networkID, email) returns
// the given result. Each entry is matched once — the test fails if a configured
// email is never queried, or if any unconfigured email is.
func expectIsMember(mock *mock_particle.MockNetworkMembershipChecker, networkID string, results map[string]bool) {
for email, isMember := range results {
mock.EXPECT().
IsMember(gomock.Any(), networkID, email).
Return(isMember, nil)
}
}
func TestParticleService_CreateAndGet(t *testing.T) {
ctx := context.Background()
svc, _ := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network first
net, err := networkSvc.Create(ctx, "Test Network", "[email protected]")
assert.NoError(t, err)
networkID := "net_123"
// Test Create stream particle
data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`)
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
NetworkID: net.ID,
Data: data,
}
created, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
assert.NotEmpty(t, created.ID)
assert.Equal(t, particle.TypeStream, created.Type)
assert.Equal(t, networkID, created.NetworkID)
assert.Equal(t, net.ID, created.NetworkID)
assert.Nil(t, created.ParentID)
assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data))
@@ -91,16 +70,71 @@ func TestParticleService_CreateAndGet(t *testing.T) {
// Service assumes caller is already verified as network member
}
func TestParticleService_StreamCapacity(t *testing.T) {
ctx := context.Background()
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
// Create a network with capacity 2
net, err := networkSvc.Create(ctx, "Capacity Test Network", "[email protected]")
assert.NoError(t, err)
err = networkSvc.SetOpenStreamCapacity(ctx, net.ID, 2)
assert.NoError(t, err)
// Create first stream - should succeed
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Stream 1","status":"open"}`),
}
stream1, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
// Create second stream - should succeed
input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`)
stream2, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
// Create third stream - should fail with capacity exceeded
input.Data = json.RawMessage(`{"name":"Stream 3","status":"open"}`)
_, err = svc.Create(ctx, input, "[email protected]")
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrCapacityExceeded)
// Close a stream
err = svc.CloseStream(ctx, stream1.ID, "[email protected]")
assert.NoError(t, err)
// Now we can create another stream
stream3, err := svc.Create(ctx, input, "[email protected]")
assert.NoError(t, err)
assert.NotEmpty(t, stream3.ID)
// Verify stream2 is still open
found, err := svc.GetByID(ctx, stream2.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
// Verify stream1 is closed
found, err = svc.GetByID(ctx, stream1.ID, "[email protected]")
assert.NoError(t, err)
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
}
func TestParticleService_NestedParticles(t *testing.T) {
ctx := context.Background()
svc, _ := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
networkID := "net_123"
// Create a network
net, err := networkSvc.Create(ctx, "Nested Test Network", "[email protected]")
assert.NoError(t, err)
// Create a parent stream
streamInput := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`),
}
stream, err := svc.Create(ctx, streamInput, "[email protected]")
@@ -109,7 +143,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
// Create a text particle as child
textInput := particle.CreateInput{
Type: particle.TypeText,
NetworkID: networkID,
NetworkID: net.ID,
ParentID: &stream.ID,
Data: json.RawMessage(`{"content":"Hello world"}`),
}
@@ -120,7 +154,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
// Create a file as child of stream
fileInput := particle.CreateInput{
Type: particle.TypeFile,
NetworkID: networkID,
NetworkID: net.ID,
ParentID: &stream.ID,
Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`),
}
@@ -129,26 +163,27 @@ func TestParticleService_NestedParticles(t *testing.T) {
assert.Equal(t, stream.ID, *file.ParentID)
// List children of stream
children, err := svc.List(ctx, networkID, &stream.ID, "[email protected]", particle.ListFilter{}, nil, 50)
children, err := svc.List(ctx, net.ID, &stream.ID, "[email protected]", particle.ListFilter{}, nil, 50)
assert.NoError(t, err)
assert.Len(t, children.Particles, 2)
}
func TestParticleService_CustomVisibility(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
networkID := "net_123"
// Create a network with a member
net, err := networkSvc.Create(ctx, "Visibility Test Network", "[email protected]")
assert.NoError(t, err)
// admin is the creator (exempt from the check); member is a network member.
expectIsMember(mockChecker, networkID, map[string]bool{
"[email protected]": true,
})
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "[email protected]"})
assert.NoError(t, err)
// Create a stream with custom visibility including only admin and member
streamInput := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
NetworkID: net.ID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]", "[email protected]"},
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
@@ -177,14 +212,17 @@ func TestParticleService_CustomVisibility(t *testing.T) {
func TestParticleService_UpdateAndDelete(t *testing.T) {
ctx := context.Background()
svc, _ := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
networkID := "net_123"
// Create a network
net, err := networkSvc.Create(ctx, "Update Test Network", "[email protected]")
assert.NoError(t, err)
// Create a text particle
input := particle.CreateInput{
Type: particle.TypeText,
NetworkID: networkID,
NetworkID: net.ID,
Data: json.RawMessage(`{"content":"Original content"}`),
}
created, err := svc.Create(ctx, input, "[email protected]")
@@ -212,15 +250,18 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
func TestParticleService_ListRootParticles(t *testing.T) {
ctx := context.Background()
svc, _ := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
networkID := "net_123"
// Create a network
net, err := networkSvc.Create(ctx, "List Test Network", "[email protected]")
assert.NoError(t, err)
// Create multiple root particles
for i := 0; i < 3; i++ {
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
}
_, err := svc.Create(ctx, input, "[email protected]")
@@ -228,21 +269,24 @@ func TestParticleService_ListRootParticles(t *testing.T) {
}
// List root particles (parentID = nil)
list, err := svc.List(ctx, networkID, nil, "[email protected]", particle.ListFilter{}, nil, 50)
list, err := svc.List(ctx, net.ID, nil, "[email protected]", particle.ListFilter{}, nil, 50)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(list.Particles), 3)
}
func TestParticleService_OpenCloseStream(t *testing.T) {
ctx := context.Background()
svc, _ := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
networkID := "net_123"
// Create a network
net, err := networkSvc.Create(ctx, "Open Close Test Network", "[email protected]")
assert.NoError(t, err)
// Create a stream
input := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
NetworkID: net.ID,
Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`),
}
stream, err := svc.Create(ctx, input, "[email protected]")
@@ -280,14 +324,17 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
func TestParticleService_NotAStream(t *testing.T) {
ctx := context.Background()
svc, _ := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
networkID := "net_123"
// Create a network
net, err := networkSvc.Create(ctx, "Not Stream Test Network", "[email protected]")
assert.NoError(t, err)
// Create a text particle
input := particle.CreateInput{
Type: particle.TypeText,
NetworkID: networkID,
NetworkID: net.ID,
Data: json.RawMessage(`{"content":"Hello"}`),
}
text, err := svc.Create(ctx, input, "[email protected]")
@@ -306,19 +353,20 @@ func TestParticleService_NotAStream(t *testing.T) {
func TestParticleService_AccessInheritance(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkSvc := network.NewService(dbPool)
svc := particle.NewService(dbPool, networkSvc)
networkID := "net_123"
// Create a network with members
net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "[email protected]")
assert.NoError(t, err)
// admin is the creator (exempt); member is a network member.
expectIsMember(mockChecker, networkID, map[string]bool{
"[email protected]": true,
})
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "[email protected]"})
assert.NoError(t, err)
// Create a stream with custom visibility (admin and member only)
streamInput := particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
NetworkID: net.ID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]", "[email protected]"},
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
@@ -329,7 +377,7 @@ func TestParticleService_AccessInheritance(t *testing.T) {
// Create a child text (network_all visibility)
textInput := particle.CreateInput{
Type: particle.TypeText,
NetworkID: networkID,
NetworkID: net.ID,
ParentID: &stream.ID,
Data: json.RawMessage(`{"content":"Child text"}`),
}
@@ -349,154 +397,3 @@ func TestParticleService_AccessInheritance(t *testing.T) {
assert.Error(t, err)
assert.ErrorIs(t, err, particle.ErrAccessDenied)
}
func TestParticleService_AddMembers(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_addmembers"
// Create a custom-visibility stream owned by admin.
stream, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]"},
Data: json.RawMessage(`{"name":"Members Stream","status":"open"}`),
}, "[email protected]")
assert.NoError(t, err)
expectIsMember(mockChecker, networkID, map[string]bool{
"[email protected]": true,
"[email protected]": true,
})
err = svc.AddMembers(ctx, stream.ID,
[]string{"[email protected]", "[email protected]"},
"[email protected]")
assert.NoError(t, err)
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
assert.NoError(t, err)
assert.Contains(t, members[stream.ID], "[email protected]")
assert.Contains(t, members[stream.ID], "[email protected]")
}
func TestParticleService_AddMembers_RejectsNonNetworkMember(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_addmembers_strict"
stream, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]"},
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
}, "[email protected]")
assert.NoError(t, err)
// alice is in the network and is processed first; stranger is not, so the
// whole operation must abort and neither row should be persisted.
expectIsMember(mockChecker, networkID, map[string]bool{
"[email protected]": true,
"[email protected]": false,
})
err = svc.AddMembers(ctx, stream.ID,
[]string{"[email protected]", "[email protected]"},
"[email protected]")
assert.ErrorIs(t, err, particle.ErrInvalidMember)
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
assert.NoError(t, err)
assert.NotContains(t, members[stream.ID], "[email protected]")
assert.NotContains(t, members[stream.ID], "[email protected]")
}
func TestParticleService_AddMembers_BubblesCheckerError(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_addmembers_checker_err"
stream, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]"},
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
}, "[email protected]")
assert.NoError(t, err)
checkerErr := errors.New("checker unavailable")
mockChecker.EXPECT().
IsMember(gomock.Any(), networkID, "[email protected]").
Return(false, checkerErr)
err = svc.AddMembers(ctx, stream.ID,
[]string{"[email protected]"}, "[email protected]")
assert.ErrorIs(t, err, checkerErr)
}
func TestParticleService_Create_RejectsNonNetworkMember(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_create_strict"
// stranger is not in the network — Create must reject the call before
// writing anything to the DB.
expectIsMember(mockChecker, networkID, map[string]bool{
"[email protected]": false,
})
_, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]", "[email protected]"},
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
}, "[email protected]")
assert.ErrorIs(t, err, particle.ErrInvalidMember)
}
func TestParticleService_Create_BubblesCheckerError(t *testing.T) {
ctx := context.Background()
svc, mockChecker := newTestService(t)
networkID := "net_create_checker_err"
checkerErr := errors.New("checker unavailable")
mockChecker.EXPECT().
IsMember(gomock.Any(), networkID, "[email protected]").
Return(false, checkerErr)
_, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeStream,
NetworkID: networkID,
Visibility: particle.VisibilityCustom,
Members: []string{"[email protected]"},
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
}, "[email protected]")
assert.ErrorIs(t, err, checkerErr)
}
func TestParticleService_AddMembers_NotAStream(t *testing.T) {
ctx := context.Background()
// No EXPECT calls — AddMembers should fail on type check before hitting the checker.
svc, _ := newTestService(t)
networkID := "net_addmembers_notstream"
text, err := svc.Create(ctx, particle.CreateInput{
Type: particle.TypeText,
NetworkID: networkID,
Data: json.RawMessage(`{"content":"hi"}`),
}, "[email protected]")
assert.NoError(t, err)
err = svc.AddMembers(ctx, text.ID, []string{"[email protected]"}, "[email protected]")
assert.ErrorIs(t, err, particle.ErrNotAContainer)
}
-142
View File
@@ -1,142 +0,0 @@
package particle
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/media"
)
const (
SkipReasonNotMedia = "not_media"
SkipReasonAlreadyTranscoded = "already_transcoded"
SkipReasonIOSPlayable = "ios_playable"
)
// TranscodeResult reports the outcome of a single Transcode call. Skipped is
// true for any of the three idempotency short-circuits (with SkipReason set);
// Err is non-nil for real failures (download URL, ffmpeg, GCS upload, Firestore
// write). On success both flags are zero-value and TranscodedObjectID +
// OutputMimeType are populated.
type TranscodeResult struct {
Skipped bool
SkipReason string
TranscodedObjectID string
OutputMimeType string
Err error
}
// Transcode produces an iOS-playable MP4/m4a derivative for media particles
// whose original mime type AVPlayer can't decode (notably the WebM the desktop
// recorder emits today). Skips when the particle is not of type media, when a
// transcoded variant has already been written, or when the source is already
// in an iOS-playable family.
//
// ffmpeg reads directly from the GCS signed URL and writes to a local temp
// file — `+faststart` requires seekable output, so a stdout pipe wouldn't
// work. The temp file is then streamed to GCS via depotSvc.CreateFromReader.
func Transcode(ctx context.Context, depotSvc depot.Service, doc *firestore.DocumentSnapshot) TranscodeResult {
var mp FirestoreMediaParticle
if err := doc.DataTo(&mp); err != nil {
flog.Error("transcode: unable to marshal particle data", "error", err)
return TranscodeResult{Err: fmt.Errorf("unmarshal particle: %w", err)}
}
particleType, err := ParseParticleType(mp.Type)
if err != nil {
flog.Error("transcode: invalid particle type", "error", err)
return TranscodeResult{Err: fmt.Errorf("parse type: %w", err)}
}
if particleType != TypeMedia {
flog.Info("transcode: particle is not of type media")
return TranscodeResult{Skipped: true, SkipReason: SkipReasonNotMedia}
}
if mp.Properties.TranscodedObjectId != "" {
return TranscodeResult{Skipped: true, SkipReason: SkipReasonAlreadyTranscoded}
}
if media.IsIOSPlayableMime(mp.Properties.MimeType) {
flog.Info("transcode: skipping because already playable on ios")
return TranscodeResult{Skipped: true, SkipReason: SkipReasonIOSPlayable}
}
sourceURL, err := depotSvc.GetDownloadURL(ctx, mp.Properties.ObjectId)
if err != nil {
flog.Error("transcode: failed to get download URL", "error", err, "object_id", mp.Properties.ObjectId)
return TranscodeResult{Err: fmt.Errorf("download URL: %w", err)}
}
networkID, err := NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
flog.Error("transcode: failed to derive network id", "error", err, "path", doc.Ref.Path)
return TranscodeResult{Err: fmt.Errorf("network id: %w", err)}
}
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
transcodeOutput, err := media.TranscodeToMp4(transcodeCtx, media.TranscodeInput{
SourceURL: sourceURL,
MimeType: mp.Properties.MimeType,
})
if err != nil {
flog.Error("transcode: ffmpeg failed", "error", err)
return TranscodeResult{Err: fmt.Errorf("ffmpeg: %w", err)}
}
defer os.Remove(transcodeOutput.TempLocalFilePath)
f, err := os.Open(transcodeOutput.TempLocalFilePath)
if err != nil {
flog.Error("transcode: failed to open transcoded file", "error", err, "path", transcodeOutput.TempLocalFilePath)
return TranscodeResult{Err: fmt.Errorf("open temp file: %w", err)}
}
defer f.Close()
newObj, err := depotSvc.CreateFromReader(ctx, depot.CreateFromReaderInput{
Prefix: networkID,
Name: "transcoded" + transcodeOutput.OutputExt,
ContentType: transcodeOutput.OutputMimeType,
}, f)
if err != nil {
flog.Error("transcode: failed to upload transcoded object", "error", err, "particleID", doc.Ref.ID)
return TranscodeResult{Err: fmt.Errorf("upload: %w", err)}
}
if _, err := doc.Ref.Set(ctx, map[string]interface{}{
"properties": map[string]interface{}{
"transcoded_object_id": newObj.ID,
"transcoded_mime_type": transcodeOutput.OutputMimeType,
},
}, firestore.MergeAll); err != nil {
flog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
return TranscodeResult{Err: fmt.Errorf("firestore update: %w", err)}
}
flog.Info("transcoded media particle", "particleID", doc.Ref.ID, "transcoded_object_id", newObj.ID, "mime", transcodeOutput.OutputMimeType)
return TranscodeResult{
TranscodedObjectID: newObj.ID,
OutputMimeType: transcodeOutput.OutputMimeType,
}
}
// NetworkIDFromParticlePath extracts the network id from a Firestore particle
// document path. Particles live at `networks/{network_id}/children/.../children/{id}`
// at arbitrary nesting depth, so the network id is always the segment directly
// following the "networks" collection name in the full resource path.
func NetworkIDFromParticlePath(path string) (string, error) {
segments := strings.Split(path, "/")
for i, seg := range segments {
if seg == "networks" && i+1 < len(segments) {
return segments[i+1], nil
}
}
return "", fmt.Errorf("no networks segment in path: %s", path)
}
-70
View File
@@ -1,70 +0,0 @@
package pusher
import (
"context"
"errors"
"strings"
"github.com/flowy-live/llink/internal/network"
)
var ErrUnauthorized = errors.New("unauthorized")
type Authorizer struct {
networkReader network.Reader
}
func NewAuthorizer(networkReader network.Reader) *Authorizer {
return &Authorizer{networkReader: networkReader}
}
// Authorize accepts channel IDs of the form:
//
// network:{networkId}
// stream:{networkId}:{streamId}
// _presence:{humanId}
func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error {
parts := strings.SplitN(channelID, ":", 2)
if len(parts) < 2 {
return ErrUnauthorized
}
channelType := parts[0]
rest := parts[1]
switch channelType {
case "network":
return a.authorizeNetwork(ctx, rest, humanID)
case "stream":
return a.authorizeStream(ctx, rest, humanID)
case "_presence":
// Only the owning human may subscribe to their presence channel.
if rest != humanID {
return ErrUnauthorized
}
return nil
default:
return ErrUnauthorized
}
}
func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID string) error {
isMember, err := a.networkReader.IsMember(ctx, networkID, humanID)
if err != nil {
return err
}
if !isMember {
return ErrUnauthorized
}
return nil
}
// rest is "{networkId}:{streamId}"; stream-level visibility is enforced by network access.
func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error {
parts := strings.SplitN(rest, ":", 2)
if len(parts) < 2 {
return ErrUnauthorized
}
networkID := parts[0]
return a.authorizeNetwork(ctx, networkID, humanID)
}
-57
View File
@@ -1,57 +0,0 @@
package pusher
// Channel tracks the local connections subscribed on this pod.
// State is only mutated by the Hub goroutine, so no locks are needed.
type Channel struct {
id string
members map[*Conn]string // conn → humanID
}
func newChannel(id string) *Channel {
return &Channel{
id: id,
members: make(map[*Conn]string),
}
}
func (ch *Channel) addMember(conn *Conn, humanID string) {
ch.members[conn] = humanID
}
func (ch *Channel) removeMember(conn *Conn) {
delete(ch.members, conn)
}
func (ch *Channel) isEmpty() bool {
return len(ch.members) == 0
}
// Deduplicated set; the same human may have multiple connections.
func (ch *Channel) localHumanIDs() []string {
seen := make(map[string]bool, len(ch.members))
ids := make([]string, 0, len(ch.members))
for _, hid := range ch.members {
if !seen[hid] {
seen[hid] = true
ids = append(ids, hid)
}
}
return ids
}
func (ch *Channel) hasHumanID(humanID string) bool {
for _, hid := range ch.members {
if hid == humanID {
return true
}
}
return false
}
func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) {
for conn := range ch.members {
if conn != exclude {
conn.Send(msg)
}
}
}
-128
View File
@@ -1,128 +0,0 @@
package pusher
import (
"context"
"encoding/json"
"sync"
"github.com/flowy-live/llink/internal/utils/flog"
"nhooyr.io/websocket"
)
const sendBufferSize = 256
type Conn struct {
id string
humanID string
ws *websocket.Conn
send chan []byte
once sync.Once // guards Close
}
func newConn(id, humanID string, ws *websocket.Conn) *Conn {
return &Conn{
id: id,
humanID: humanID,
ws: ws,
send: make(chan []byte, sendBufferSize),
}
}
// ReadPump forwards inbound frames to the hub; blocks until the connection
// closes or ctx is cancelled.
func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
defer hub.disconnect(c)
for {
_, data, err := c.ws.Read(ctx)
if err != nil {
if ctx.Err() != nil {
flog.Info("websocket context cancelled", "connId", c.id, "humanId", c.humanID, "error", ctx.Err())
return
}
flog.Warn("websocket read error", "connId", c.id, "humanId", c.humanID, "error", err)
return
}
if string(data) == "ping" {
if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil {
flog.Warn("websocket pong write error", "connId", c.id, "error", err)
}
continue
}
var msg ClientMessage
if err := json.Unmarshal(data, &msg); err != nil {
c.sendError("invalid message format")
continue
}
switch msg.Type {
case TypeSubscribe:
if msg.Channel == "" {
c.sendError("channel is required")
continue
}
hub.subscribeCh <- &subscribeRequest{conn: c, channelID: msg.Channel}
case TypeUnsubscribe:
if msg.Channel == "" {
c.sendError("channel is required")
continue
}
hub.unsubscribeCh <- &unsubscribeRequest{conn: c, channelID: msg.Channel}
case TypeMessage:
if msg.Channel == "" {
c.sendError("channel is required")
continue
}
hub.broadcastCh <- &broadcastRequest{conn: c, channelID: msg.Channel, payload: msg.Payload}
default:
c.sendError("unknown message type: " + msg.Type)
}
}
}
func (c *Conn) WritePump(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case data, ok := <-c.send:
if !ok {
return
}
if err := c.ws.Write(ctx, websocket.MessageText, data); err != nil {
flog.Debug("websocket write error", "connId", c.id, "error", err)
return
}
}
}
}
// A full send buffer closes the connection (slow client policy).
func (c *Conn) Send(msg ServerMessage) {
data, err := json.Marshal(msg)
if err != nil {
flog.Error("failed to marshal server message", "error", err)
return
}
select {
case c.send <- data:
default:
flog.Warn("slow client, closing connection", "connId", c.id, "humanId", c.humanID)
c.Close()
}
}
func (c *Conn) Close() {
c.once.Do(func() {
c.ws.Close(websocket.StatusNormalClosure, "closing")
close(c.send)
})
}
func (c *Conn) sendError(msg string) {
c.Send(ServerMessage{Type: TypeError, Message: msg})
}
-259
View File
@@ -1,259 +0,0 @@
package pusher
import (
"context"
"encoding/json"
"github.com/flowy-live/llink/internal/utils/flog"
)
type subscribeRequest struct {
conn *Conn
channelID string
}
type unsubscribeRequest struct {
conn *Conn
channelID string
}
type broadcastRequest struct {
conn *Conn
channelID string
payload json.RawMessage
}
type remoteEvent struct {
channelID string
event redisEvent
}
// Hub manages all local WebSocket connections and channels on this pod.
// All state mutations happen in a single goroutine via Go channels — no locks.
type Hub struct {
channels map[string]*Channel
connChannels map[*Conn]map[string]bool // reverse index: conn → set of channel IDs
bridge *RedisBridge
authorizer *Authorizer
subscribeCh chan *subscribeRequest
unsubscribeCh chan *unsubscribeRequest
broadcastCh chan *broadcastRequest
disconnectCh chan *Conn
remoteEventCh chan *remoteEvent
}
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
return &Hub{
channels: make(map[string]*Channel),
connChannels: make(map[*Conn]map[string]bool),
bridge: bridge,
authorizer: authorizer,
subscribeCh: make(chan *subscribeRequest, 256),
unsubscribeCh: make(chan *unsubscribeRequest, 256),
broadcastCh: make(chan *broadcastRequest, 256),
disconnectCh: make(chan *Conn, 256),
remoteEventCh: make(chan *remoteEvent, 256),
}
}
// Run starts the hub event loop. Blocks until the context is cancelled.
func (h *Hub) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case req := <-h.subscribeCh:
h.handleSubscribe(ctx, req)
case req := <-h.unsubscribeCh:
h.handleUnsubscribe(ctx, req)
case req := <-h.broadcastCh:
h.handleBroadcast(ctx, req)
case conn := <-h.disconnectCh:
h.handleDisconnect(ctx, conn)
case evt := <-h.remoteEventCh:
h.handleRemoteEvent(evt)
}
}
}
func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
req.conn.Send(ServerMessage{
Type: TypeError,
Channel: req.channelID,
Message: "unauthorized",
})
return
}
ch, ok := h.channels[req.channelID]
if !ok {
ch = newChannel(req.channelID)
h.channels[req.channelID] = ch
}
// Capture before addMember so multi-tab joins don't emit a spurious join.
wasPresentLocally := ch.hasHumanID(req.conn.humanID)
ch.addMember(req.conn, req.conn.humanID)
if h.connChannels[req.conn] == nil {
h.connChannels[req.conn] = make(map[string]bool)
}
h.connChannels[req.conn][req.channelID] = true
presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID)
if err != nil {
flog.Error("redis subscribe failed", "channelId", req.channelID, "error", err)
// Fall back to local-only presence.
presence = ch.localHumanIDs()
}
req.conn.Send(ServerMessage{
Type: TypeSubscribed,
Channel: req.channelID,
Presence: presence,
})
// Redis self-filter drops our own echo, so same-pod peers need a direct nudge.
if !wasPresentLocally {
ch.broadcast(ServerMessage{
Type: TypeJoin,
Channel: req.channelID,
HumanID: req.conn.humanID,
}, req.conn)
}
}
func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
ch, ok := h.channels[req.channelID]
if !ok {
return
}
ch.removeMember(req.conn)
if chans, ok := h.connChannels[req.conn]; ok {
delete(chans, req.channelID)
}
if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil {
flog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err)
}
// Only emit leave once the humanID has no remaining tabs on this pod.
if !ch.hasHumanID(req.conn.humanID) {
ch.broadcast(ServerMessage{
Type: TypeLeave,
Channel: req.channelID,
HumanID: req.conn.humanID,
}, req.conn)
}
if ch.isEmpty() {
delete(h.channels, req.channelID)
}
}
func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
ch, ok := h.channels[req.channelID]
if !ok {
return
}
if _, isMember := ch.members[req.conn]; !isMember {
req.conn.sendError("not subscribed to channel: " + req.channelID)
return
}
// Local fanout (excluding sender), then publish for other pods.
ch.broadcast(ServerMessage{
Type: TypeMessage,
Channel: req.channelID,
HumanID: req.conn.humanID,
Payload: req.payload,
}, req.conn)
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload)
}
func (h *Hub) handleDisconnect(ctx context.Context, conn *Conn) {
chans, ok := h.connChannels[conn]
if !ok {
return
}
for channelID := range chans {
ch, ok := h.channels[channelID]
if !ok {
continue
}
ch.removeMember(conn)
if err := h.bridge.Unsubscribe(ctx, channelID, conn.id, conn.humanID); err != nil {
flog.Error("redis unsubscribe on disconnect failed", "channelId", channelID, "error", err)
}
if !ch.hasHumanID(conn.humanID) {
ch.broadcast(ServerMessage{
Type: TypeLeave,
Channel: channelID,
HumanID: conn.humanID,
}, conn)
}
if ch.isEmpty() {
delete(h.channels, channelID)
}
}
delete(h.connChannels, conn)
}
func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
ch, ok := h.channels[evt.channelID]
if !ok {
// No local subscribers — drop the event.
return
}
switch evt.event.Type {
case TypeJoin:
ch.broadcast(ServerMessage{
Type: TypeJoin,
Channel: evt.channelID,
HumanID: evt.event.HumanID,
}, nil)
case TypeLeave:
ch.broadcast(ServerMessage{
Type: TypeLeave,
Channel: evt.channelID,
HumanID: evt.event.HumanID,
}, nil)
case TypeMessage:
ch.broadcast(ServerMessage{
Type: TypeMessage,
Channel: evt.channelID,
HumanID: evt.event.HumanID,
Payload: evt.event.Payload,
}, nil)
}
}
func (h *Hub) Subscribe(conn *Conn, channelID string) {
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
}
func (h *Hub) disconnect(conn *Conn) {
h.disconnectCh <- conn
}
-395
View File
@@ -1,395 +0,0 @@
package pusher
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/redis/go-redis/v9"
)
const (
podHeartbeatInterval = 30 * time.Second
podHeartbeatTTL = 60 * time.Second
cleanupInterval = 60 * time.Second
// Redis key prefixes
channelConnsPrefix = "pusher:ch:"
channelConnsSuffix = ":conns"
podKeyPrefix = "pusher:pod:"
pubsubPrefix = "pusher:events:"
)
// Wire format for cross-pod Pub/Sub.
type redisEvent struct {
Type string `json:"type"` // "join", "leave", "message"
HumanID string `json:"humanId,omitempty"` // who triggered the event
PodID string `json:"podId,omitempty"` // originating pod
Payload json.RawMessage `json:"payload,omitempty"` // message events only
}
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence
// tracking.
type RedisBridge struct {
client *redis.Client
podID string
hub *Hub // wired post-construction; see SetHub
}
func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
return &RedisBridge{
client: client,
podID: podID,
}
}
// SetHub resolves the circular dependency between Hub and RedisBridge.
func (rb *RedisBridge) SetHub(hub *Hub) {
rb.hub = hub
}
// --- Presence management ---
// Subscribe records the connection in Redis and returns the channel's
// current deduplicated presence set.
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
key := channelConnsKey(channelID)
field := rb.connField(connID)
// Snapshot before the insert so multi-tab joins don't double-emit.
existingMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil {
return nil, fmt.Errorf("failed to get channel members: %w", err)
}
wasPresent := containsString(existingMembers, humanID)
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
return nil, fmt.Errorf("failed to add connection to channel: %w", err)
}
if !wasPresent {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeJoin,
HumanID: humanID,
PodID: rb.podID,
})
}
allMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil {
return nil, fmt.Errorf("failed to get channel members: %w", err)
}
return deduplicateStrings(allMembers), nil
}
func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
key := channelConnsKey(channelID)
field := rb.connField(connID)
if err := rb.client.HDel(ctx, key, field).Err(); err != nil {
return fmt.Errorf("failed to remove connection from channel: %w", err)
}
// Only emit leave once this humanID has no tabs left in the channel.
remainingMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil {
return fmt.Errorf("failed to get remaining members: %w", err)
}
if !containsString(remainingMembers, humanID) {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeLeave,
HumanID: humanID,
PodID: rb.podID,
})
}
if len(remainingMembers) == 0 {
rb.client.Del(ctx, key)
}
return nil
}
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeMessage,
HumanID: humanID,
PodID: rb.podID,
Payload: payload,
})
}
func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
result := make(map[string][]string, len(channelIDs))
for _, chID := range channelIDs {
members, err := rb.client.HVals(ctx, channelConnsKey(chID)).Result()
if err != nil && err != redis.Nil {
return nil, fmt.Errorf("failed to get presence for %s: %w", chID, err)
}
result[chID] = deduplicateStrings(members)
}
return result, nil
}
// Returns every humanID with at least one active connection cluster-wide.
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
allHumanIDs := make(map[string]bool)
var cursor uint64
for {
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
if err != nil {
return nil, fmt.Errorf("failed to scan channel keys: %w", err)
}
for _, key := range keys {
members, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil {
continue
}
for _, humanID := range members {
allHumanIDs[humanID] = true
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
result := make([]string, 0, len(allHumanIDs))
for id := range allHumanIDs {
result = append(result, id)
}
return result, nil
}
// --- Pub/Sub listener ---
// Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled.
func (rb *RedisBridge) Listen(ctx context.Context) {
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
defer pubsub.Close()
ch := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
rb.handlePubSubMessage(msg)
}
}
}
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
// Topic: "pusher:events:{channelID}".
channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
if channelID == "" {
return
}
var event redisEvent
if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil {
flog.Error("failed to parse pub/sub event", "error", err)
return
}
// Same-pod events were already handled by the local hub.
if event.PodID == rb.podID {
return
}
if rb.hub == nil {
return
}
rb.hub.remoteEventCh <- &remoteEvent{
channelID: channelID,
event: event,
}
}
// --- Heartbeat + cleanup ---
// Heartbeat refreshes this pod's liveness key and reaps stale pods on a tick.
func (rb *RedisBridge) Heartbeat(ctx context.Context) {
podKey := podKeyPrefix + rb.podID
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
heartbeatTicker := time.NewTicker(podHeartbeatInterval)
cleanupTicker := time.NewTicker(cleanupInterval)
defer heartbeatTicker.Stop()
defer cleanupTicker.Stop()
for {
select {
case <-ctx.Done():
// On shutdown, drop our pod key and reclaim our connection slots.
rb.client.Del(context.Background(), podKey)
rb.cleanupPod(context.Background(), rb.podID)
return
case <-heartbeatTicker.C:
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
case <-cleanupTicker.C:
rb.cleanupStalePods(ctx)
}
}
}
func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
// Collect every pod referenced in channel-conn hashes, then drop those
// whose liveness key has expired.
var cursor uint64
knownPods := make(map[string]bool)
alivePods := make(map[string]bool)
for {
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
if err != nil {
flog.Error("failed to scan channel keys", "error", err)
return
}
for _, key := range keys {
fields, err := rb.client.HKeys(ctx, key).Result()
if err != nil {
continue
}
for _, field := range fields {
podID := extractPodID(field)
if podID != "" {
knownPods[podID] = true
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
for podID := range knownPods {
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
if err != nil {
continue
}
if exists > 0 {
alivePods[podID] = true
}
}
for podID := range knownPods {
if !alivePods[podID] {
flog.Info("cleaning up stale pod", "podId", podID)
rb.cleanupPod(ctx, podID)
}
}
}
func (rb *RedisBridge) cleanupPod(ctx context.Context, podID string) {
var cursor uint64
for {
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
if err != nil {
return
}
for _, key := range keys {
fields, err := rb.client.HGetAll(ctx, key).Result()
if err != nil {
continue
}
channelID := extractChannelID(key)
for field, humanID := range fields {
if extractPodID(field) == podID {
rb.client.HDel(ctx, key, field)
remaining, _ := rb.client.HVals(ctx, key).Result()
if !containsString(remaining, humanID) {
rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeLeave,
HumanID: humanID,
PodID: rb.podID,
})
}
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
}
// --- Helpers ---
func (rb *RedisBridge) connField(connID string) string {
return rb.podID + ":" + connID
}
func (rb *RedisBridge) publishEvent(ctx context.Context, channelID string, event redisEvent) {
data, err := json.Marshal(event)
if err != nil {
flog.Error("failed to marshal event", "error", err)
return
}
if err := rb.client.Publish(ctx, pubsubPrefix+channelID, data).Err(); err != nil {
flog.Error("failed to publish event", "channelId", channelID, "error", err)
}
}
func channelConnsKey(channelID string) string {
return channelConnsPrefix + channelID + channelConnsSuffix
}
// "pusher:ch:{channelID}:conns" → channelID
func extractChannelID(redisKey string) string {
s := strings.TrimPrefix(redisKey, channelConnsPrefix)
s = strings.TrimSuffix(s, channelConnsSuffix)
return s
}
// "{podID}:{connID}" → podID
func extractPodID(field string) string {
parts := strings.SplitN(field, ":", 2)
if len(parts) == 2 {
return parts[0]
}
return ""
}
func containsString(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func deduplicateStrings(slice []string) []string {
seen := make(map[string]bool, len(slice))
result := make([]string, 0, len(slice))
for _, s := range slice {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
-115
View File
@@ -1,115 +0,0 @@
package pusher
import (
"context"
"net/http"
"github.com/google/uuid"
"nhooyr.io/websocket"
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/utils/flog"
)
// Server handles WebSocket upgrades and gRPC presence queries.
type Server struct {
pbpusher.UnimplementedPusherServiceServer
ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully
hub *Hub
bridge *RedisBridge
authSvc auth.SessionReader
}
func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server {
return &Server{
ctx: ctx,
hub: hub,
bridge: bridge,
authSvc: authSvc,
}
}
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// Token rides in the query string — WebSocket upgrades can't carry custom headers.
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "token required", http.StatusUnauthorized)
return
}
session, err := s.authSvc.GetSession(r.Context(), token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// CORS is enforced at the gateway.
InsecureSkipVerify: true,
})
if err != nil {
flog.Error("websocket accept failed", "error", err)
return
}
connID := uuid.New().String()
conn := newConn(connID, session.HumanId, ws)
flog.Info("websocket connected", "connId", connID, "humanId", session.HumanId)
// Use the server context, not r.Context(): after upgrade the HTTP request
// context can be cancelled by load balancers and nhooyr/websocket would
// then permanently close the conn.
ctx, cancel := context.WithCancel(s.ctx)
defer cancel()
// Auto-subscribe to the presence channel so this user appears online.
s.hub.Subscribe(conn, "_presence:"+session.HumanId)
go conn.WritePump(ctx)
conn.ReadPump(ctx, s.hub)
flog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId)
}
func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) {
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
if err != nil {
return nil, err
}
return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil
}
func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) {
allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx)
if err != nil {
return nil, err
}
onlineSet := make(map[string]bool, len(allOnline))
for _, id := range allOnline {
onlineSet[id] = true
}
result := make(map[string]bool, len(req.HumanIds))
for _, id := range req.HumanIds {
result[id] = onlineSet[id]
}
return &pbpusher.IsOnlineResponse{Online: result}, nil
}
func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) {
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
if err != nil {
return nil, err
}
resp := &pbpusher.GetChannelPresenceResponse{
Presences: make(map[string]*pbpusher.ChannelPresence, len(presence)),
}
for chID, humanIDs := range presence {
resp.Presences[chID] = &pbpusher.ChannelPresence{
HumanIds: humanIDs,
}
}
return resp, nil
}
-34
View File
@@ -1,34 +0,0 @@
package pusher
import "encoding/json"
// Client → Server message types
const (
TypeSubscribe = "subscribe"
TypeUnsubscribe = "unsubscribe"
TypeMessage = "message"
)
// Server → Client message types
const (
TypeSubscribed = "subscribed"
TypeJoin = "join"
TypeLeave = "leave"
// TypeMessage is reused for server → client messages
TypeError = "error"
)
type ClientMessage struct {
Type string `json:"type"`
Channel string `json:"channel,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type ServerMessage struct {
Type string `json:"type"`
Channel string `json:"channel,omitempty"`
HumanID string `json:"humanId,omitempty"`
Presence []string `json:"presence,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
Message string `json:"message,omitempty"`
}
+5 -7
View File
@@ -3,25 +3,23 @@ package internal
import (
"context"
"fmt"
"log/slog"
"os"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/redis/go-redis/v9"
"github.com/flowy-live/llink/internal/utils"
"github.com/redis/go-redis/v9"
)
func ConnectAndTestRedis(db int) *redis.Client {
redisHost := utils.MustGetEnv("REDIS_HOST")
if redisHost == "" {
flog.Error("must provide REDIS_HOST")
slog.Error("must provide REDIS_HOST")
os.Exit(1)
}
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
Password: "",
Password: "", // no password set
DB: db,
})
@@ -34,7 +32,7 @@ func ConnectAndTestRedis(db int) *redis.Client {
if err != nil {
panic(err)
}
flog.Debug("redis test", "key", val)
slog.Debug("redis test", "key", val)
if val != "value" {
panic("unexpected value")
}
-100
View File
@@ -1,100 +0,0 @@
package speech
import (
"context"
"github.com/flowy-live/llink/internal/utils/flog"
dgapi "github.com/deepgram/deepgram-go-sdk/v3/pkg/api/listen/v1/rest"
interfaces "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/interfaces"
client "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/listen"
)
type TranscriptWord struct {
Word string
Start float64
End float64
}
type TranscriptSentence struct {
Text string
Start float64
End float64
}
type TranscriptParagraph struct {
Sentences []TranscriptSentence
Start float64
End float64
}
type TranscriptResult struct {
Transcript string
Words []TranscriptWord
Paragraphs []TranscriptParagraph
}
type SpeechService interface {
Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error)
}
type speechServiceImpl struct {
deepgramClient *dgapi.Client
}
func NewSpeechService(ctx context.Context) SpeechService {
return &speechServiceImpl{
deepgramClient: dgapi.New(client.NewRESTWithDefaults()),
}
}
func (s *speechServiceImpl) Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error) {
options := &interfaces.PreRecordedTranscriptionOptions{
Model: "nova-3",
SmartFormat: true,
Paragraphs: true,
}
response, err := s.deepgramClient.FromURL(ctx, mediaUrl, options)
if err != nil {
flog.Error("failed to transcribe prerecorded media", "error", err)
return nil, err
}
alt := response.Results.Channels[0].Alternatives[0]
words := make([]TranscriptWord, len(alt.Words))
for i, w := range alt.Words {
words[i] = TranscriptWord{
Word: w.PunctuatedWord,
Start: w.Start,
End: w.End,
}
}
var paragraphs []TranscriptParagraph
if alt.Paragraphs != nil {
paragraphs = make([]TranscriptParagraph, len(alt.Paragraphs.Paragraphs))
for i, p := range alt.Paragraphs.Paragraphs {
sentences := make([]TranscriptSentence, len(p.Sentences))
for j, s := range p.Sentences {
sentences[j] = TranscriptSentence{
Text: s.Text,
Start: s.Start,
End: s.End,
}
}
paragraphs[i] = TranscriptParagraph{
Sentences: sentences,
Start: p.Start,
End: p.End,
}
}
}
return &TranscriptResult{
Transcript: alt.Transcript,
Words: words,
Paragraphs: paragraphs,
}, nil
}
-6
View File
@@ -1,6 +0,0 @@
// Package aero hosts a mock for the third-party aero gRPC client. The
// interface is generated protobuf code we don't own, so the //go:generate
// directive lives here rather than next to the source.
package aero
//go:generate go tool mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
@@ -1,63 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/flowy-live/llink/genproto/aero (interfaces: PrimaryClient)
//
// Generated by this command:
//
// mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
//
// Package aero is a generated GoMock package.
package aero
import (
context "context"
reflect "reflect"
pbaero "github.com/flowy-live/llink/genproto/aero"
gomock "go.uber.org/mock/gomock"
grpc "google.golang.org/grpc"
)
// MockPrimaryClient is a mock of PrimaryClient interface.
type MockPrimaryClient struct {
ctrl *gomock.Controller
recorder *MockPrimaryClientMockRecorder
isgomock struct{}
}
// MockPrimaryClientMockRecorder is the mock recorder for MockPrimaryClient.
type MockPrimaryClientMockRecorder struct {
mock *MockPrimaryClient
}
// NewMockPrimaryClient creates a new mock instance.
func NewMockPrimaryClient(ctrl *gomock.Controller) *MockPrimaryClient {
mock := &MockPrimaryClient{ctrl: ctrl}
mock.recorder = &MockPrimaryClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPrimaryClient) EXPECT() *MockPrimaryClientMockRecorder {
return m.recorder
}
// ShootEmail mocks base method.
func (m *MockPrimaryClient) ShootEmail(ctx context.Context, in *pbaero.ShootEmailRequest, opts ...grpc.CallOption) (*pbaero.ShootEmailResponse, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, in}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "ShootEmail", varargs...)
ret0, _ := ret[0].(*pbaero.ShootEmailResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ShootEmail indicates an expected call of ShootEmail.
func (mr *MockPrimaryClientMockRecorder) ShootEmail(ctx, in any, opts ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, in}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryClient)(nil).ShootEmail), varargs...)
}
+8 -9
View File
@@ -3,11 +3,10 @@ package testhelper
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
@@ -40,20 +39,20 @@ func SetupTestDB() *pgxpool.Pool {
),
)
if err != nil {
flog.Error("failed to start postgres container", "error", err)
slog.Error("failed to start postgres container", "error", err)
os.Exit(1)
}
// Get connection URL
host, err := container.Host(ctx)
if err != nil {
flog.Error("failed to get container host", "error", err)
slog.Error("failed to get container host", "error", err)
os.Exit(1)
}
port, err := container.MappedPort(ctx, "5432")
if err != nil {
flog.Error("failed to get container port", "error", err)
slog.Error("failed to get container port", "error", err)
os.Exit(1)
}
@@ -63,20 +62,20 @@ func SetupTestDB() *pgxpool.Pool {
// Run migrations
m, err := migrate.New("file://../../migrations", connectionURL)
if err != nil {
flog.Error("failed to create migrate instance", "error", err)
slog.Error("failed to create migrate instance", "error", err)
os.Exit(1)
}
defer m.Close()
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
flog.Error("failed to run migrations", "error", err)
slog.Error("failed to run migrations", "error", err)
os.Exit(1)
}
// Create connection pool
dbPool, err = pgxpool.New(ctx, connectionURL)
if err != nil {
flog.Error("failed to create connection pool", "error", err)
slog.Error("failed to create connection pool", "error", err)
os.Exit(1)
}
@@ -90,7 +89,7 @@ func TeardownTestDB() {
}
if container != nil {
if err := container.Terminate(ctx); err != nil {
flog.Error("failed to terminate container", "error", err)
slog.Error("failed to terminate container", "error", err)
}
}
}
+8 -6
View File
@@ -3,31 +3,33 @@ package utils
import (
"os"
"github.com/flowy-live/llink/internal/utils/flog"
"github.com/sirupsen/logrus"
)
// EnvVar enumerates the env vars referenced via this package.
// enum of environment variables
type EnvVar string
const ()
// MustGetEnv panics if the variable is unset.
// MustGetEnv returns the value of the environment variable with the given key.
// panics if the variable is not set.
func MustGetEnv[T string | EnvVar](key T) string {
keyString := string(key)
value := os.Getenv(keyString)
if value == "" {
flog.Error("missing required environment variable", "key", key)
logrus.Errorf("Missing required environment variable %s", key)
panic("Missing required environment variable")
}
return value
}
// GetEnv returns "" if the variable is unset (and logs a warning).
// GetEnv returns the value of the environment variable with the given key.
// returns an empty string if the variable is not set.
func GetEnv(key string) string {
value := os.Getenv(key)
if value == "" {
flog.Warn("missing optional environment variable", "key", key)
logrus.Warnf("Missing optional environment variable %s", key)
}
return value
}
-58
View File
@@ -1,58 +0,0 @@
// Package flog is a structured logger formatted for GCP Cloud Logging.
//
// The API mirrors log/slog: a message string followed by alternating key/value
// pairs. Each kv pair becomes a JSON field in the emitted record, which Cloud
// Logging promotes to a queryable jsonPayload field.
//
// flog.Info("particle processed", "particleID", id, "elapsed", dt)
package flog
import (
"os"
"github.com/sirupsen/logrus"
)
// Prepares global logger for GCP Cloud Logging's quirks
var log = func() *logrus.Logger {
logger := logrus.New()
logger.SetFormatter(&logrus.JSONFormatter{
FieldMap: logrus.FieldMap{
logrus.FieldKeyLevel: "severity",
logrus.FieldKeyMsg: "message",
logrus.FieldKeyTime: "timestamp",
},
})
logger.SetOutput(os.Stderr)
logger.SetLevel(logrus.DebugLevel)
return logger
}()
// fields converts alternating key/value args into a logrus.Fields map. A
// trailing odd arg or non-string key is stored under "!BADKEY" to match
// slog's behavior.
func fields(args []any) logrus.Fields {
if len(args) == 0 {
return nil
}
f := make(logrus.Fields, len(args)/2+1)
for i := 0; i < len(args); i += 2 {
if i+1 >= len(args) {
f["!BADKEY"] = args[i]
break
}
key, ok := args[i].(string)
if !ok {
f["!BADKEY"] = args[i]
continue
}
f[key] = args[i+1]
}
return f
}
func Debug(msg string, args ...any) { log.WithFields(fields(args)).Debug(msg) }
func Info(msg string, args ...any) { log.WithFields(fields(args)).Info(msg) }
func Warn(msg string, args ...any) { log.WithFields(fields(args)).Warn(msg) }
func Error(msg string, args ...any) { log.WithFields(fields(args)).Error(msg) }
func Fatal(msg string, args ...any) { log.WithFields(fields(args)).Fatal(msg) }
+10 -4
View File
@@ -21,6 +21,7 @@ func CreateOptionalBool(input bool) *bool {
return &input
}
// OptionalString converts a non-nil *string to the respective string or returns "".
func OptionalString(input *string) string {
if input == nil {
return ""
@@ -29,6 +30,7 @@ func OptionalString(input *string) string {
return *input
}
// OptionalInt converts a non-nil *int to the respective int, otherwise returns 0.
func OptionalInt(input *int) int {
if input == nil {
return 0
@@ -37,7 +39,8 @@ func OptionalInt(input *int) int {
return *input
}
// Zero values become nil; the inverse of OptionalInt.
// CreateOptionalInt when given a zero value int (0), it returns a nil *int.
// Otherwise, it gives a proper *int with valid value.
func CreateOptionalInt(input int) *int {
if input == 0 {
return nil
@@ -46,7 +49,8 @@ func CreateOptionalInt(input int) *int {
return &input
}
// Empty string becomes nil; the inverse of OptionalString.
// CreateOptionalString when given an empty string, it returns a nil *string.
// Otherwise, it gives a proper *string with valid value.
func CreateOptionalString(input string) *string {
if input == "" {
return nil
@@ -55,7 +59,8 @@ func CreateOptionalString(input string) *string {
return &input
}
// Returns an error if input contains non-digit characters or parses to <= 0.
// GetNumberFromString converts a string to a number.
// Returns error if the query is not a number.
func GetNumberFromString(input string) (int, error) {
for _, c := range input {
if c < '0' || c > '9' {
@@ -75,6 +80,7 @@ type Number interface {
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
}
// OptionalNumber converts a non-nil *NUMBER to the respective number value or returns 0.
func OptionalNumber[T Number](input *T) T {
if input == nil {
return 0
@@ -83,7 +89,7 @@ func OptionalNumber[T Number](input *T) T {
return *input
}
// Zero values become nil; the inverse of OptionalNumber.
// CreateOptionalNumber when given an zero value NUMBER (0), it returns a nil *NUMBER, otherwise, it gives a proper *NUMBER with valid value.
func CreateOptionalNumber[T Number](input T) *T {
if input == 0 {
return nil

Some files were not shown because too many files have changed in this diff Show More