diff --git a/GOOGLE_PLAY_ACCESS_SETUP.md b/GOOGLE_PLAY_ACCESS_SETUP.md new file mode 100644 index 0000000..9706e4d --- /dev/null +++ b/GOOGLE_PLAY_ACCESS_SETUP.md @@ -0,0 +1,187 @@ + +# Google Play Closed Testing — server-side setup + +Deze functionaliteit beheert **distributietoegang** tot de Android-app via Google Play Closed Testing. +Het vervangt **niet** de bestaande app-login of API-autorisatie. + +## Architectuur + +``` +Adminpaneel → backend → Google Cloud Identity Groups API → Google Group + ↓ + Google Play Closed Testing track +``` + +- Gebruikers worden **niet** individueel via de Play Developer API beheerd. +- De Closed Testing-track wordt **eenmalig** gekoppeld aan `PLAY_ACCESS_GROUP_EMAIL`. +- Het adminpaneel beheert alleen lidmaatschap van die groep. + +## Benodigde environment variables + +Stel deze in op de **master-api** server (niet in de admin UI, niet in Git): + +| Variabele | Verplicht | Beschrijving | +|-----------|-----------|--------------| +| `GOOGLE_CLOUD_PROJECT_ID` | Ja | Google Cloud project ID | +| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | Ja | Service account e-mail | +| `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | Ja | PEM private key ( `\n` als `\n` in env) | +| `PLAY_ACCESS_GROUP_ID` | Ja | Group resource ID (zonder `groups/` prefix mag ook) | +| `PLAY_ACCESS_GROUP_EMAIL` | Ja | E-mailadres van de Google Group | +| `GOOGLE_WORKSPACE_ADMIN_EMAIL` | Meestal ja | Admin voor domain-wide delegation | +| `GOOGLE_WORKSPACE_CUSTOMER_ID` | Optioneel | Workspace customer ID | +| `GOOGLE_PLAY_OPT_IN_URL` | Aanbevolen | Publieke Closed Testing opt-in URL | +| `GOOGLE_PLAY_SYNC_INTERVAL_MS` | Optioneel | Reconciliatie-interval (default: 86400000 = 24u) | + +### Waar instellen + +- **Lokaal:** `apps/master-api/.env` +- **Productie/Dokploy:** omgeving van de master-api container (`deploy/master/.env` template) + +## Google Cloud — handmatige stappen + +### 1. APIs inschakelen + +In Google Cloud Console → APIs & Services: + +- **Cloud Identity Groups API** (`cloudidentity.googleapis.com`) + +### 2. Service account aanmaken + +1. IAM & Admin → Service Accounts → Create +2. Download JSON key **of** kopieer private key naar `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` +3. Noteer `client_email` → `GOOGLE_SERVICE_ACCOUNT_EMAIL` + +### 3. Domain-wide delegation (Workspace) + +Als de group in Google Workspace staat: + +1. Service account → Enable Domain-Wide Delegation +2. Google Admin Console → Security → API Controls → Domain-wide delegation +3. Voeg scope toe: `https://www.googleapis.com/auth/cloud-identity.groups` +4. Stel `GOOGLE_WORKSPACE_ADMIN_EMAIL` in op een super admin + +### 4. Service account rechten op de group + +De service account (of geïmpersonateerde admin) moet leden kunnen beheren: + +- Google Group → **Group member manager** of **Owner** +- Of via Cloud Identity met de juiste IAM-rollen + +### 5. Google Group aanmaken + +Maak een **technische ACL-groep** (geen hardcoded naam in code): + +- Niet publiek vindbaar +- Self-join uit +- Alleen beheerders beheren leden +- Ledenlijst niet zichtbaar voor gewone members +- Posten/conversations uit indien mogelijk +- Externe accounts alleen toestaan als nodig (bijv. `@gmail.com` testers) + +Noteer: + +- Group e-mail → `PLAY_ACCESS_GROUP_EMAIL` +- Group ID → `PLAY_ACCESS_GROUP_ID` + +#### Group ID vinden + +Via [Cloud Identity Groups API lookup](https://cloud.google.com/identity/docs/reference/rest/v1/groups/lookup): + +``` +GET https://cloudidentity.googleapis.com/v1/groups:lookup?groupKey.id=GROUP_EMAIL +``` + +Het veld `name` is bijv. `groups/03abc123` → `PLAY_ACCESS_GROUP_ID=03abc123` + +### 6. Google Play Console — Closed Testing koppelen + +1. Play Console → app → Testing → Closed testing +2. Maak of open een track +3. Testers → **Google Groups** +4. Voeg `PLAY_ACCESS_GROUP_EMAIL` toe +5. Kopieer de **opt-in URL** → `GOOGLE_PLAY_OPT_IN_URL` + +## Gebruik in adminpaneel + +1. Ga naar **Kijkers** +2. Per kijker: sectie **Google Play access** + - **Google Play email** — het Google-account voor Play (fallback: account-e-mail) + - **Store access** — ON/OFF + - **Status** — sync-status (ACTIVE, ERROR, …) + - **Retry synchronization** — bij ERROR +3. Pagina-acties: + - **Sync all** — alle kijkers synchroniseren + - **Reconcile** — lokale status vergelijken met Google Group + +## Testgebruiker toevoegen + +1. Maak kijker aan (of bestaande) +2. Vul Google Play e-mail in (bijv. `user@gmail.com`) +3. Zet **Store access** aan +4. Status wordt **ACTIVE** na succesvolle sync +5. Gebruiker opent `GOOGLE_PLAY_OPT_IN_URL` terwijl ingelogd met hetzelfde Google-account +6. Installeert app via Play Store + +## Toegang intrekken + +1. Zet **Store access** uit in adminpaneel +2. Backend verwijdert lidmaatschap uit de group +3. Nieuwe installs via Play zijn niet meer mogelijk +4. **Let op:** reeds geïnstalleerde apps blijven op het toestel — app-login/API blijft leidend voor contenttoegang + +## Sync & reconciliatie + +| Lokaal | Google Group | Actie | +|--------|--------------|-------| +| ON | member | OK | +| ON | geen member | toevoegen | +| OFF | member | verwijderen | +| OFF | geen member | OK | + +- **Sync all:** past gewenste status toe voor alle kijkers +- **Reconcile:** vergelijkt en herstelt afwijkingen +- **Scheduled:** elke `GOOGLE_PLAY_SYNC_INTERVAL_MS` (default 24u) + +## Foutafhandeling + +Bij Google API-fouten: + +- Gewenste instelling blijft bewaard +- Status → **ERROR** +- Fout staat in admin UI + audit log +- Gebruik **Retry synchronization** + +Audit log tabel: `google_play_access_audit_logs` (geen tokens/keys gelogd). + +## API endpoints (admin only) + +| Method | Route | +|--------|-------| +| GET | `/api/v1/admin/google-play-access/config` | +| PATCH | `/api/v1/admin/viewers/:id/google-play-access` | +| POST | `/api/v1/admin/viewers/:id/google-play-access/retry` | +| POST | `/api/v1/admin/google-play-access/sync-all` | +| POST | `/api/v1/admin/google-play-access/reconcile` | + +Body PATCH: + +```json +{ + "enabled": true, + "email": "user@gmail.com" +} +``` + +## Wat jij nog handmatig moet invullen + +Stop hier — vul **geen** credentials in deze repo: + +1. `GOOGLE_CLOUD_PROJECT_ID` +2. `GOOGLE_SERVICE_ACCOUNT_EMAIL` +3. `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` +4. `GOOGLE_WORKSPACE_ADMIN_EMAIL` (indien Workspace) +5. `PLAY_ACCESS_GROUP_ID` +6. `PLAY_ACCESS_GROUP_EMAIL` +7. `GOOGLE_PLAY_OPT_IN_URL` + +Daarna master-api herstarten en in adminpaneel testen met één tester-account. diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index dd45a75..c8707de 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -11,36 +11,85 @@ type Device = { createdAt: string; }; +type GooglePlaySyncStatus = + | "NOT_ENABLED" + | "PENDING_ADD" + | "ACTIVE" + | "PENDING_REMOVE" + | "REMOVED" + | "ERROR"; + type Viewer = { id: string; email: string; name: string | null; enabled: boolean; appAccess: boolean; + googlePlayEmail: string | null; + googlePlayAccess: boolean; + googlePlayMembershipId: string | null; + googlePlaySyncStatus: GooglePlaySyncStatus; + googlePlayLastSyncedAt: string | null; + googlePlaySyncError: string | null; + googlePlayResolvedEmail: string; createdAt: string; deviceCount: number; devices: Device[]; }; +type PlayConfig = { + configured: boolean; + groupEmail: string | null; + groupId: string | null; + optInUrl: string | null; +}; + +const SYNC_LABELS: Record = { + NOT_ENABLED: "Niet ingeschakeld", + PENDING_ADD: "Bezig met toevoegen…", + ACTIVE: "Actief", + PENDING_REMOVE: "Bezig met verwijderen…", + REMOVED: "Verwijderd", + ERROR: "Fout", +}; + export default function ViewersPage() { useAuth(); const [viewers, setViewers] = useState([]); + const [playConfig, setPlayConfig] = useState(null); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); const [err, setErr] = useState(null); + const [playEmails, setPlayEmails] = useState>({}); const load = useCallback(() => { fetch("/api/v1/admin/viewers", { credentials: "include" }) .then((r) => r.json()) - .then((d) => setViewers(d.viewers ?? [])) + .then((d) => { + const list: Viewer[] = d.viewers ?? []; + setViewers(list); + setPlayEmails((prev) => { + const next = { ...prev }; + for (const v of list) { + if (next[v.id] === undefined) { + next[v.id] = v.googlePlayEmail ?? ""; + } + } + return next; + }); + }) .catch(() => setErr("Kon kijkers niet laden")); }, []); useEffect(() => { load(); + fetch("/api/v1/admin/google-play-access/config", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => setPlayConfig(d.config ?? null)) + .catch(() => undefined); }, [load]); async function createViewer(e: FormEvent) { @@ -80,6 +129,64 @@ export default function ViewersPage() { load(); } + async function setPlayAccess(v: Viewer, enabled: boolean) { + setMsg(null); + setErr(null); + const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + enabled, + email: playEmails[v.id]?.trim() || null, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Google Play-toegang bijwerken mislukt"); + } else { + setMsg(data.message ?? (enabled ? "Google Play access enabled." : "Google Play access removed.")); + if (data.syncError) setErr(data.syncError); + } + load(); + } + + async function retryPlaySync(v: Viewer) { + setMsg(null); + setErr(null); + const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access/retry`, { + method: "POST", + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Synchronisatie mislukt"); + } else { + setMsg(data.message ?? "Synchronization completed."); + if (data.syncError) setErr(data.syncError); + } + load(); + } + + async function bulkAction(path: "sync-all" | "reconcile") { + setMsg(null); + setErr(null); + const res = await fetch(`/api/v1/admin/google-play-access/${path}`, { + method: "POST", + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Actie mislukt"); + return; + } + const s = data.summary; + setMsg( + `Scanned: ${s.scanned} · Correct: ${s.correct} · Added: ${s.added} · Removed: ${s.removed} · Errors: ${s.errors}` + ); + load(); + } + async function revokeDevice(deviceId: string) { if (!confirm("Apparaat intrekken?")) return; await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, { @@ -104,6 +211,27 @@ export default function ViewersPage() { + {playConfig && ( +
+

Google Play access

+

+ {playConfig.configured + ? `Groep: ${playConfig.groupEmail ?? playConfig.groupId}` + : "Google Play-toegang is nog niet geconfigureerd op de server (zie GOOGLE_PLAY_ACCESS_SETUP.md)."} +

+ {playConfig.configured && ( +
+ + +
+ )} +
+ )} +

Nieuwe kijker

@@ -148,7 +276,7 @@ export default function ViewersPage() {
{v.name || v.email} -
{v.email}
+
Account email: {v.email}
{v.enabled ? "Actief" : "Uit"} · app {v.appAccess ? "aan" : "uit"} ·{" "} {v.deviceCount} apparaat(en) @@ -162,6 +290,74 @@ export default function ViewersPage() { {v.appAccess ? "App uit" : "App aan"}
+ +
+

Google Play access

+ +
+ + Store access:{" "} + {v.googlePlayAccess ? "ON" : "OFF"} + + + Status:{" "} + + {SYNC_LABELS[v.googlePlaySyncStatus] ?? v.googlePlaySyncStatus} + + + {v.googlePlayLastSyncedAt && ( + + Last synchronized:{" "} + {new Date(v.googlePlayLastSyncedAt).toLocaleString("nl-NL")} + + )} + {v.googlePlaySyncError && ( + {v.googlePlaySyncError} + )} +
+
+ {!v.googlePlayAccess ? ( + + ) : ( + + )} + {v.googlePlaySyncStatus === "ERROR" && ( + + )} + {v.googlePlayAccess && + v.googlePlaySyncStatus === "ACTIVE" && + playConfig?.optInUrl && ( + + Open Google Play + + )} +
+
+ {v.devices.length > 0 && (
    {v.devices.map((d) => ( @@ -198,6 +394,41 @@ export default function ViewersPage() { display: flex; flex-wrap: wrap; gap: 0.5rem; + align-items: center; + } + .play-section { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 0.75rem; + display: grid; + gap: 0.5rem; + } + .play-section h3 { + margin: 0; + font-size: 0.95rem; + } + .play-field { + display: grid; + gap: 0.25rem; + max-width: 360px; + } + .play-field input { + width: 100%; + } + .play-meta { + display: grid; + gap: 0.25rem; + font-size: 0.9rem; + } + .play-banner .viewer-actions { + margin-top: 0.5rem; + } + .status-err { + color: #ff8b8b; + } + .btn-link { + color: #8ec5ff; + text-decoration: underline; + font-size: 0.9rem; } .device-list { list-style: none; diff --git a/apps/master-api/.env.example b/apps/master-api/.env.example index c0e09ef..ed11d43 100644 --- a/apps/master-api/.env.example +++ b/apps/master-api/.env.example @@ -19,3 +19,13 @@ PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_OFFLINE_THRESHOLD_SECONDS=90 HIDE_UNAVAILABLE_TITLES=true + +# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md) +# GOOGLE_CLOUD_PROJECT_ID= +# GOOGLE_SERVICE_ACCOUNT_EMAIL= +# GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY= +# GOOGLE_WORKSPACE_ADMIN_EMAIL= +# PLAY_ACCESS_GROUP_ID= +# PLAY_ACCESS_GROUP_EMAIL= +# GOOGLE_PLAY_OPT_IN_URL= +# GOOGLE_PLAY_SYNC_INTERVAL_MS=86400000 diff --git a/apps/master-api/package.json b/apps/master-api/package.json index 40160f4..c89e89b 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -6,7 +6,7 @@ "dev": "tsx watch src/app.ts", "build": "tsc", "start": "node dist/app.js", - "test": "tsx src/metadata/filename-parser.test.ts", + "test": "tsx src/metadata/filename-parser.test.ts && tsx src/google-play/service.test.ts", "db:generate": "prisma generate", "db:migrate": "prisma migrate deploy", "db:migrate:dev": "prisma migrate dev", @@ -22,7 +22,7 @@ "@prisma/client": "^6.3.1", "argon2": "^0.41.1", "dotenv": "^16.4.7", - "fastify": "^5.2.1", + "google-auth-library": "^9.15.1", "ioredis": "^5.4.2", "nanoid": "^5.0.9", "prisma": "^6.3.1", diff --git a/apps/master-api/prisma/migrations/20260901000000_google_play_access/migration.sql b/apps/master-api/prisma/migrations/20260901000000_google_play_access/migration.sql new file mode 100644 index 0000000..533481d --- /dev/null +++ b/apps/master-api/prisma/migrations/20260901000000_google_play_access/migration.sql @@ -0,0 +1,39 @@ +-- Google Play Closed Testing group membership tracking on viewer users. + +CREATE TYPE "GooglePlaySyncStatus" AS ENUM ( + 'NOT_ENABLED', + 'PENDING_ADD', + 'ACTIVE', + 'PENDING_REMOVE', + 'REMOVED', + 'ERROR' +); + +ALTER TABLE "viewer_users" + ADD COLUMN "google_play_email" TEXT, + ADD COLUMN "google_play_access" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "google_play_membership_id" TEXT, + ADD COLUMN "google_play_sync_status" "GooglePlaySyncStatus" NOT NULL DEFAULT 'NOT_ENABLED', + ADD COLUMN "google_play_last_synced_at" TIMESTAMP(3), + ADD COLUMN "google_play_sync_error" TEXT; + +CREATE TABLE "google_play_access_audit_logs" ( + "id" TEXT NOT NULL, + "admin_user_id" TEXT, + "admin_email" TEXT, + "viewer_user_id" TEXT NOT NULL, + "target_email" TEXT NOT NULL, + "action" TEXT NOT NULL, + "result" TEXT NOT NULL, + "membership_id" TEXT, + "error_message" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "google_play_access_audit_logs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "google_play_access_audit_logs_viewer_user_id_created_at_idx" + ON "google_play_access_audit_logs"("viewer_user_id", "created_at"); + +ALTER TABLE "google_play_access_audit_logs" + ADD CONSTRAINT "google_play_access_audit_logs_viewer_user_id_fkey" + FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 65b3db6..24868fb 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -43,6 +43,15 @@ enum DownloadJobStatus { FAILED } +enum GooglePlaySyncStatus { + NOT_ENABLED + PENDING_ADD + ACTIVE + PENDING_REMOVE + REMOVED + ERROR +} + model User { id String @id @default(uuid()) email String @unique @@ -65,6 +74,14 @@ model ViewerUser { name String? enabled Boolean @default(true) appAccess Boolean @default(true) @map("app_access") + /** Google-account voor Play Closed Testing; fallback = email */ + googlePlayEmail String? @map("google_play_email") + /** Gewenste Play-distributietoegang (sync met Google Group) */ + googlePlayAccess Boolean @default(false) @map("google_play_access") + googlePlayMembershipId String? @map("google_play_membership_id") + googlePlaySyncStatus GooglePlaySyncStatus @default(NOT_ENABLED) @map("google_play_sync_status") + googlePlayLastSyncedAt DateTime? @map("google_play_last_synced_at") + googlePlaySyncError String? @map("google_play_sync_error") @db.Text createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -72,6 +89,7 @@ model ViewerUser { progress WatchProgress[] shelfPrefs ViewerShelfPreference[] linkCodes DeviceLinkCode[] + googlePlayAuditLogs GooglePlayAccessAuditLog[] @@map("viewer_users") } @@ -568,3 +586,22 @@ model LibrarySubtitle { @@index([imdbId]) @@map("library_subtitles") } + +/** Audit trail voor Google Play group-lidmaatschap (geen secrets). */ +model GooglePlayAccessAuditLog { + id String @id @default(uuid()) + adminUserId String? @map("admin_user_id") + adminEmail String? @map("admin_email") + viewerUserId String @map("viewer_user_id") + targetEmail String @map("target_email") + action String + result String + membershipId String? @map("membership_id") + errorMessage String? @map("error_message") @db.Text + createdAt DateTime @default(now()) @map("created_at") + + viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade) + + @@index([viewerUserId, createdAt]) + @@map("google_play_access_audit_logs") +} diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 0d0045a..c97f106 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -13,6 +13,8 @@ import { registerInstallRoutes } from "./install/routes"; import { registerDownloadRoutes } from "./downloads/routes"; import { registerSettingsRoutes } from "./settings/routes"; import { registerViewerRoutes } from "./viewer/routes"; +import { registerGooglePlayRoutes } from "./google-play/routes"; +import { startGooglePlayReconciliationPoller } from "./google-play/service"; import { nodeConnectionManager } from "./websocket/manager"; import { MetadataService } from "./metadata/service"; import { toErrorResponse } from "./security/errors"; @@ -77,6 +79,8 @@ async function main() { registerSettingsRoutes(app, config); const downloads = registerDownloadRoutes(app, config); registerViewerRoutes(app, config, downloads); + const googlePlay = registerGooglePlayRoutes(app, config); + startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS); app.get("/api/v1/node/connect", { websocket: true }, (socket) => { void nodeConnectionManager.handleConnection(socket); @@ -381,6 +385,46 @@ async function main() { CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id") ) `); + await prisma.$executeRawUnsafe(` + DO $$ BEGIN + CREATE TYPE "GooglePlaySyncStatus" AS ENUM ( + 'NOT_ENABLED', 'PENDING_ADD', 'ACTIVE', 'PENDING_REMOVE', 'REMOVED', 'ERROR' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_email" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_access" BOOLEAN NOT NULL DEFAULT false` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_membership_id" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_sync_status" "GooglePlaySyncStatus" NOT NULL DEFAULT 'NOT_ENABLED'` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_last_synced_at" TIMESTAMP(3)` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_sync_error" TEXT` + ); + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "google_play_access_audit_logs" ( + "id" TEXT NOT NULL, + "admin_user_id" TEXT, + "admin_email" TEXT, + "viewer_user_id" TEXT NOT NULL, + "target_email" TEXT NOT NULL, + "action" TEXT NOT NULL, + "result" TEXT NOT NULL, + "membership_id" TEXT, + "error_message" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "google_play_access_audit_logs_pkey" PRIMARY KEY ("id") + ) + `); } catch (err) { console.warn("Could not ensure viewer client tables:", err); } diff --git a/apps/master-api/src/config.ts b/apps/master-api/src/config.ts index f1b972b..d4ad676 100644 --- a/apps/master-api/src/config.ts +++ b/apps/master-api/src/config.ts @@ -18,6 +18,18 @@ const configSchema = z.object({ NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90), HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(true), + /** Google Cloud Identity Groups — Closed Testing access control */ + GOOGLE_CLOUD_PROJECT_ID: z.string().optional(), + GOOGLE_SERVICE_ACCOUNT_EMAIL: z.string().email().optional(), + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: z.string().optional(), + GOOGLE_WORKSPACE_CUSTOMER_ID: z.string().optional(), + GOOGLE_WORKSPACE_ADMIN_EMAIL: z.string().email().optional(), + PLAY_ACCESS_GROUP_ID: z.string().optional(), + PLAY_ACCESS_GROUP_EMAIL: z.string().email().optional(), + /** Public Closed Testing opt-in URL (shown in admin UI) */ + GOOGLE_PLAY_OPT_IN_URL: z.string().url().optional(), + /** Periodic reconciliation interval (ms); 0 = disabled. Default: 24h */ + GOOGLE_PLAY_SYNC_INTERVAL_MS: z.coerce.number().default(86_400_000), }); export type Config = z.infer; diff --git a/apps/master-api/src/google-play/audit.ts b/apps/master-api/src/google-play/audit.ts new file mode 100644 index 0000000..cfabaf7 --- /dev/null +++ b/apps/master-api/src/google-play/audit.ts @@ -0,0 +1,28 @@ +import { prisma } from "../database/client"; + +export type AuditAction = "ADD" | "REMOVE" | "SYNC" | "RECONCILE" | "RETRY"; +export type AuditResult = "SUCCESS" | "ERROR" | "SKIPPED"; + +export async function logGooglePlayAudit(input: { + adminUserId?: string | null; + adminEmail?: string | null; + viewerUserId: string; + targetEmail: string; + action: AuditAction; + result: AuditResult; + membershipId?: string | null; + errorMessage?: string | null; +}) { + await prisma.googlePlayAccessAuditLog.create({ + data: { + adminUserId: input.adminUserId ?? null, + adminEmail: input.adminEmail ?? null, + viewerUserId: input.viewerUserId, + targetEmail: input.targetEmail, + action: input.action, + result: input.result, + membershipId: input.membershipId ?? null, + errorMessage: input.errorMessage ?? null, + }, + }); +} diff --git a/apps/master-api/src/google-play/client.ts b/apps/master-api/src/google-play/client.ts new file mode 100644 index 0000000..0d79e32 --- /dev/null +++ b/apps/master-api/src/google-play/client.ts @@ -0,0 +1,148 @@ +import { JWT } from "google-auth-library"; +import type { Config } from "../config"; + +const IDENTITY_BASE = "https://cloudidentity.googleapis.com/v1"; +const SCOPE = "https://www.googleapis.com/auth/cloud-identity.groups"; +const REQUEST_TIMEOUT_MS = 15_000; + +export type GooglePlayRuntimeConfig = { + projectId: string; + serviceAccountEmail: string; + privateKey: string; + workspaceAdminEmail?: string; + groupId: string; + groupEmail: string; +}; + +export function getGooglePlayRuntimeConfig(config: Config): GooglePlayRuntimeConfig | null { + const { + GOOGLE_CLOUD_PROJECT_ID, + GOOGLE_SERVICE_ACCOUNT_EMAIL, + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY, + GOOGLE_WORKSPACE_ADMIN_EMAIL, + PLAY_ACCESS_GROUP_ID, + PLAY_ACCESS_GROUP_EMAIL, + } = config; + + if ( + !GOOGLE_CLOUD_PROJECT_ID || + !GOOGLE_SERVICE_ACCOUNT_EMAIL || + !GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY || + !PLAY_ACCESS_GROUP_ID || + !PLAY_ACCESS_GROUP_EMAIL + ) { + return null; + } + + return { + projectId: GOOGLE_CLOUD_PROJECT_ID, + serviceAccountEmail: GOOGLE_SERVICE_ACCOUNT_EMAIL, + privateKey: GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY.replace(/\\n/g, "\n"), + workspaceAdminEmail: GOOGLE_WORKSPACE_ADMIN_EMAIL, + groupId: PLAY_ACCESS_GROUP_ID.replace(/^groups\//, ""), + groupEmail: PLAY_ACCESS_GROUP_EMAIL, + }; +} + +export class GoogleIdentityGroupsClient { + private jwt: JWT | null = null; + + constructor(private readonly cfg: GooglePlayRuntimeConfig) {} + + private auth(): JWT { + if (!this.jwt) { + this.jwt = new JWT({ + email: this.cfg.serviceAccountEmail, + key: this.cfg.privateKey, + scopes: [SCOPE], + subject: this.cfg.workspaceAdminEmail || undefined, + }); + } + return this.jwt; + } + + private async accessToken(): Promise { + const tokenResponse = await this.auth().getAccessToken(); + const token = + typeof tokenResponse === "string" ? tokenResponse : tokenResponse?.token ?? null; + if (!token) throw new Error("Kon geen Google access token verkrijgen"); + return token; + } + + private async request( + path: string, + init: RequestInit = {} + ): Promise<{ ok: boolean; status: number; data: T | null; raw: string }> { + const token = await this.accessToken(); + const res = await fetch(`${IDENTITY_BASE}/${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(init.headers || {}), + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const raw = await res.text(); + let data: T | null = null; + if (raw) { + try { + data = JSON.parse(raw) as T; + } catch { + data = null; + } + } + return { ok: res.ok, status: res.status, data, raw }; + } + + async lookupMembership(email: string): Promise<{ name: string } | null> { + const q = new URLSearchParams({ "memberKey.id": email }); + const { ok, status, data } = await this.request<{ name?: string }>( + `groups/${encodeURIComponent(this.cfg.groupId)}/memberships:lookup?${q}` + ); + if (status === 404) return null; + if (!ok || !data?.name) { + throw new Error(`Membership lookup mislukt (${status})`); + } + return { name: data.name }; + } + + async createMembership(email: string): Promise<{ name: string }> { + const existing = await this.lookupMembership(email); + if (existing) return existing; + + const { ok, status, data, raw } = await this.request<{ name?: string }>( + `groups/${encodeURIComponent(this.cfg.groupId)}/memberships`, + { + method: "POST", + body: JSON.stringify({ + preferredMemberKey: { id: email }, + roles: [{ name: "MEMBER" }], + }), + } + ); + + if (status === 409) { + const again = await this.lookupMembership(email); + if (again) return again; + } + + if (!ok || !data?.name) { + throw new Error(`Membership toevoegen mislukt (${status}): ${raw.slice(0, 200)}`); + } + return { name: data.name }; + } + + async deleteMembership(membershipName: string): Promise { + const name = membershipName.replace(/^\//, ""); + const { ok, status, raw } = await this.request(name, { method: "DELETE" }); + if (status === 404 || ok) return; + throw new Error(`Membership verwijderen mislukt (${status}): ${raw.slice(0, 200)}`); + } + + async deleteMembershipByEmail(email: string): Promise { + const found = await this.lookupMembership(email); + if (!found) return; + await this.deleteMembership(found.name); + } +} diff --git a/apps/master-api/src/google-play/email.ts b/apps/master-api/src/google-play/email.ts new file mode 100644 index 0000000..13bf4e7 --- /dev/null +++ b/apps/master-api/src/google-play/email.ts @@ -0,0 +1,23 @@ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Trim + lowercase for internal comparison and Google API calls. */ +export function normalizeEmail(raw: string): string { + return raw.trim().toLowerCase(); +} + +export function isValidEmail(raw: string): boolean { + const n = normalizeEmail(raw); + return n.length > 0 && EMAIL_RE.test(n); +} + +/** + * Google Play-account voor group membership. + * Fallback: primair account-e-mailadres van de kijker. + */ +export function resolveGooglePlayEmail( + viewer: { email: string; googlePlayEmail?: string | null } +): string { + const explicit = viewer.googlePlayEmail?.trim(); + if (explicit) return normalizeEmail(explicit); + return normalizeEmail(viewer.email); +} diff --git a/apps/master-api/src/google-play/routes.ts b/apps/master-api/src/google-play/routes.ts new file mode 100644 index 0000000..9beba6b --- /dev/null +++ b/apps/master-api/src/google-play/routes.ts @@ -0,0 +1,84 @@ +import type { FastifyInstance } from "fastify"; +import type { Config } from "../config"; +import { requireAdmin, type AuthenticatedRequest } from "../auth/routes"; +import { AppError } from "../security/errors"; +import { GooglePlayAccessService } from "./service"; + +function adminFromRequest(request: import("fastify").FastifyRequest) { + const user = (request as AuthenticatedRequest).user; + return { adminUserId: user.id, adminEmail: user.email }; +} + +export function registerGooglePlayRoutes(app: FastifyInstance, config: Config) { + const playAccess = new GooglePlayAccessService(config); + + app.get( + "/api/v1/admin/google-play-access/config", + { preHandler: requireAdmin }, + async () => ({ config: playAccess.getPublicConfig() }) + ); + + app.patch( + "/api/v1/admin/viewers/:id/google-play-access", + { preHandler: requireAdmin }, + async (request) => { + const { id } = request.params as { id: string }; + const body = request.body as { enabled?: boolean; email?: string | null }; + if (typeof body.enabled !== "boolean") { + throw new AppError("INVALID_REQUEST", "enabled (boolean) is verplicht", 400); + } + const result = await playAccess.setAccess( + id, + { enabled: body.enabled, email: body.email }, + adminFromRequest(request) + ); + return { + viewer: result.viewer, + message: body.enabled + ? result.syncError + ? "Google Play access could not be synchronized. The local setting has been saved and synchronization can be retried." + : "Google Play access enabled." + : result.syncError + ? "Google Play access could not be synchronized. The local setting has been saved and synchronization can be retried." + : "Google Play access removed.", + syncError: result.syncError ?? null, + }; + } + ); + + app.post( + "/api/v1/admin/viewers/:id/google-play-access/retry", + { preHandler: requireAdmin }, + async (request) => { + const { id } = request.params as { id: string }; + const result = await playAccess.retrySync(id, adminFromRequest(request)); + return { + viewer: result.viewer, + message: result.syncError + ? "Google Play access could not be synchronized. The local setting has been saved and synchronization can be retried." + : "Synchronization completed.", + syncError: result.syncError ?? null, + }; + } + ); + + app.post( + "/api/v1/admin/google-play-access/sync-all", + { preHandler: requireAdmin }, + async (request) => { + const summary = await playAccess.syncAllUsers(adminFromRequest(request)); + return { summary }; + } + ); + + app.post( + "/api/v1/admin/google-play-access/reconcile", + { preHandler: requireAdmin }, + async (request) => { + const summary = await playAccess.reconcileAll(adminFromRequest(request)); + return { summary }; + } + ); + + return playAccess; +} diff --git a/apps/master-api/src/google-play/service.test.ts b/apps/master-api/src/google-play/service.test.ts new file mode 100644 index 0000000..10d263d --- /dev/null +++ b/apps/master-api/src/google-play/service.test.ts @@ -0,0 +1,108 @@ +import { getGooglePlayRuntimeConfig } from "./client"; +import { isValidEmail, normalizeEmail, resolveGooglePlayEmail } from "./email"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +/** Mock Google Groups client for unit tests (no network). */ +export class MockGroupsClient { + members = new Map(); + + async lookupMembership(email: string) { + const name = this.members.get(normalizeEmail(email)); + return name ? { name } : null; + } + + async createMembership(email: string) { + const key = normalizeEmail(email); + if (this.members.has(key)) return { name: this.members.get(key)! }; + const name = `groups/test/memberships/${key}`; + this.members.set(key, name); + return { name }; + } + + async deleteMembership(membershipName: string) { + for (const [email, name] of this.members.entries()) { + if (name === membershipName) this.members.delete(email); + } + } + + async deleteMembershipByEmail(email: string) { + this.members.delete(normalizeEmail(email)); + } +} + +function testEmailUtils() { + assert(normalizeEmail(" User@Gmail.COM ") === "user@gmail.com", "normalize"); + assert(isValidEmail("a@b.co"), "valid email"); + assert(!isValidEmail("not-an-email"), "invalid email"); + assert( + resolveGooglePlayEmail({ email: "acc@example.com", googlePlayEmail: null }) === "acc@example.com", + "fallback email" + ); + assert( + resolveGooglePlayEmail({ email: "acc@example.com", googlePlayEmail: "play@gmail.com" }) === + "play@gmail.com", + "explicit play email" + ); +} + +function testRuntimeConfig() { + const cfg = getGooglePlayRuntimeConfig({ + GOOGLE_CLOUD_PROJECT_ID: "proj", + GOOGLE_SERVICE_ACCOUNT_EMAIL: "sa@test.iam.gserviceaccount.com", + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----", + PLAY_ACCESS_GROUP_ID: "groups/abc123", + PLAY_ACCESS_GROUP_EMAIL: "group@example.com", + } as never); + assert(cfg !== null, "config present"); + assert(cfg!.groupId === "abc123", "strip groups/ prefix"); + assert(cfg!.privateKey.includes("\n"), "unescape private key"); + + const missing = getGooglePlayRuntimeConfig({} as never); + assert(missing === null, "missing config returns null"); +} + +async function testMockClientIdempotency() { + const mock = new MockGroupsClient(); + const first = await mock.createMembership("user@gmail.com"); + const second = await mock.createMembership("user@gmail.com"); + assert(first.name === second.name, "duplicate add idempotent"); + assert(mock.members.size === 1, "single member"); + + await mock.deleteMembershipByEmail("user@gmail.com"); + assert(mock.members.size === 0, "removed"); + await mock.deleteMembershipByEmail("user@gmail.com"); + assert(mock.members.size === 0, "remove missing is ok"); +} + +function testCredentialsNeverInPublicConfig() { + const { GooglePlayAccessService } = require("./service") as typeof import("./service"); + const svc = new GooglePlayAccessService({ + GOOGLE_CLOUD_PROJECT_ID: "proj", + GOOGLE_SERVICE_ACCOUNT_EMAIL: "sa@test.iam.gserviceaccount.com", + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: "secret-key-material", + PLAY_ACCESS_GROUP_ID: "abc", + PLAY_ACCESS_GROUP_EMAIL: "g@example.com", + GOOGLE_PLAY_OPT_IN_URL: "https://play.google.com/apps/testing/com.example", + } as never); + const pub = svc.getPublicConfig(); + const json = JSON.stringify(pub); + assert(!json.includes("secret-key-material"), "private key not exposed"); + assert(!json.includes("PRIVATE KEY"), "no key in public config"); + assert(pub.optInUrl?.startsWith("https://"), "opt-in url public"); +} + +async function run() { + testEmailUtils(); + testRuntimeConfig(); + await testMockClientIdempotency(); + testCredentialsNeverInPublicConfig(); + console.log("google-play/service.test.ts: all tests passed"); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/master-api/src/google-play/service.ts b/apps/master-api/src/google-play/service.ts new file mode 100644 index 0000000..6329206 --- /dev/null +++ b/apps/master-api/src/google-play/service.ts @@ -0,0 +1,482 @@ +import type { GooglePlaySyncStatus, ViewerUser } from "@prisma/client"; +import { prisma } from "../database/client"; +import { AppError } from "../security/errors"; +import type { Config } from "../config"; +import { logGooglePlayAudit } from "./audit"; +import { + GoogleIdentityGroupsClient, + getGooglePlayRuntimeConfig, +} from "./client"; +import { isValidEmail, normalizeEmail, resolveGooglePlayEmail } from "./email"; + +const VIEWER_SELECT = { + id: true, + email: true, + name: true, + enabled: true, + appAccess: true, + googlePlayEmail: true, + googlePlayAccess: true, + googlePlayMembershipId: true, + googlePlaySyncStatus: true, + googlePlayLastSyncedAt: true, + googlePlaySyncError: true, + createdAt: true, +} as const; + +export type ViewerGooglePlayFields = { + id: string; + email: string; + name: string | null; + enabled: boolean; + appAccess: boolean; + googlePlayEmail: string | null; + googlePlayAccess: boolean; + googlePlayMembershipId: string | null; + googlePlaySyncStatus: GooglePlaySyncStatus; + googlePlayLastSyncedAt: Date | null; + googlePlaySyncError: string | null; + googlePlayResolvedEmail: string; + createdAt: Date; +}; + +export type SyncResult = { + viewerId: string; + email: string; + action: "added" | "removed" | "ok" | "error"; + error?: string; +}; + +export type BulkSyncSummary = { + scanned: number; + correct: number; + added: number; + removed: number; + errors: number; + results: SyncResult[]; +}; + +export type AdminContext = { + adminUserId?: string; + adminEmail?: string; +}; + +export class GooglePlayAccessService { + private client: GoogleIdentityGroupsClient | null = null; + + constructor( + private readonly config: Config, + /** Test hook: override Google API client factory */ + private readonly clientOverride?: GoogleIdentityGroupsClient + ) {} + + isConfigured(): boolean { + return getGooglePlayRuntimeConfig(this.config) !== null; + } + + getPublicConfig() { + const cfg = getGooglePlayRuntimeConfig(this.config); + return { + configured: cfg !== null, + groupEmail: cfg?.groupEmail ?? null, + groupId: cfg?.groupId ?? null, + optInUrl: this.config.GOOGLE_PLAY_OPT_IN_URL ?? null, + }; + } + + private groupsClient(): GoogleIdentityGroupsClient { + if (this.clientOverride) return this.clientOverride; + if (this.client) return this.client; + const cfg = getGooglePlayRuntimeConfig(this.config); + if (!cfg) { + throw new AppError( + "GOOGLE_PLAY_NOT_CONFIGURED", + "Google Play-toegang is niet geconfigureerd op de server", + 503 + ); + } + this.client = new GoogleIdentityGroupsClient(cfg); + return this.client; + } + + toPublicFields(viewer: { + id: string; + email: string; + name: string | null; + enabled: boolean; + appAccess: boolean; + googlePlayEmail: string | null; + googlePlayAccess: boolean; + googlePlayMembershipId: string | null; + googlePlaySyncStatus: GooglePlaySyncStatus; + googlePlayLastSyncedAt: Date | null; + googlePlaySyncError: string | null; + createdAt: Date; + }): ViewerGooglePlayFields { + return { + ...viewer, + googlePlayResolvedEmail: resolveGooglePlayEmail(viewer), + }; + } + + async getViewerOrThrow(id: string) { + const viewer = await prisma.viewerUser.findUnique({ where: { id }, select: VIEWER_SELECT }); + if (!viewer) throw new AppError("NOT_FOUND", "Kijker niet gevonden", 404); + return viewer; + } + + async setAccess( + viewerId: string, + input: { enabled: boolean; email?: string | null }, + admin?: AdminContext + ): Promise<{ viewer: ViewerGooglePlayFields; syncError?: string }> { + const viewer = await this.getViewerOrThrow(viewerId); + + let googlePlayEmail = viewer.googlePlayEmail; + if (input.email !== undefined) { + if (input.email === null || input.email.trim() === "") { + googlePlayEmail = null; + } else { + if (!isValidEmail(input.email)) { + throw new AppError("INVALID_REQUEST", "Ongeldig Google Play e-mailadres", 400); + } + googlePlayEmail = normalizeEmail(input.email); + } + } + + const nextStatus: GooglePlaySyncStatus = input.enabled ? "PENDING_ADD" : "PENDING_REMOVE"; + + await prisma.viewerUser.update({ + where: { id: viewerId }, + data: { + googlePlayEmail, + googlePlayAccess: input.enabled, + googlePlaySyncStatus: input.enabled ? nextStatus : nextStatus, + googlePlaySyncError: null, + }, + }); + + const sync = await this.syncUser(viewerId, admin, input.enabled ? "ADD" : "REMOVE"); + const updated = await this.getViewerOrThrow(viewerId); + return { + viewer: this.toPublicFields(updated), + syncError: sync.error, + }; + } + + async retrySync(viewerId: string, admin?: AdminContext) { + const viewer = await this.getViewerOrThrow(viewerId); + const action = viewer.googlePlayAccess ? "RETRY" : "RETRY"; + const sync = await this.syncUser(viewerId, admin, action); + const updated = await this.getViewerOrThrow(viewerId); + return { viewer: this.toPublicFields(updated), syncError: sync.error }; + } + + async syncUser( + viewerId: string, + admin?: AdminContext, + auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE" = "SYNC" + ): Promise { + const viewer = await this.getViewerOrThrow(viewerId); + const email = resolveGooglePlayEmail(viewer); + + if (!viewer.googlePlayAccess) { + return this.removeAccess(viewer, email, admin, auditAction); + } + return this.addAccess(viewer, email, admin, auditAction); + } + + private async addAccess( + viewer: Awaited>, + email: string, + admin: AdminContext | undefined, + auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE" + ): Promise { + if (!this.isConfigured()) { + await prisma.viewerUser.update({ + where: { id: viewer.id }, + data: { + googlePlaySyncStatus: "ERROR", + googlePlaySyncError: "Google Play-toegang is niet geconfigureerd op de server", + }, + }); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewer.id, + targetEmail: email, + action: auditAction, + result: "ERROR", + errorMessage: "Not configured", + }); + return { viewerId: viewer.id, email, action: "error", error: "Not configured" }; + } + + try { + const client = this.groupsClient(); + const membership = await client.createMembership(email); + await prisma.viewerUser.update({ + where: { id: viewer.id }, + data: { + googlePlayAccess: true, + googlePlayMembershipId: membership.name, + googlePlaySyncStatus: "ACTIVE", + googlePlayLastSyncedAt: new Date(), + googlePlaySyncError: null, + }, + }); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewer.id, + targetEmail: email, + action: auditAction, + result: "SUCCESS", + membershipId: membership.name, + }); + return { viewerId: viewer.id, email, action: "added" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await prisma.viewerUser.update({ + where: { id: viewer.id }, + data: { + googlePlaySyncStatus: "ERROR", + googlePlaySyncError: message, + }, + }); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewer.id, + targetEmail: email, + action: auditAction, + result: "ERROR", + errorMessage: message, + }); + return { viewerId: viewer.id, email, action: "error", error: message }; + } + } + + private async removeAccess( + viewer: Awaited>, + email: string, + admin: AdminContext | undefined, + auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE" + ): Promise { + if (!this.isConfigured()) { + await prisma.viewerUser.update({ + where: { id: viewer.id }, + data: { + googlePlayAccess: false, + googlePlayMembershipId: null, + googlePlaySyncStatus: "REMOVED", + googlePlayLastSyncedAt: new Date(), + googlePlaySyncError: null, + }, + }); + return { viewerId: viewer.id, email, action: "ok" }; + } + + try { + const client = this.groupsClient(); + if (viewer.googlePlayMembershipId) { + await client.deleteMembership(viewer.googlePlayMembershipId); + } else { + await client.deleteMembershipByEmail(email); + } + await prisma.viewerUser.update({ + where: { id: viewer.id }, + data: { + googlePlayAccess: false, + googlePlayMembershipId: null, + googlePlaySyncStatus: "REMOVED", + googlePlayLastSyncedAt: new Date(), + googlePlaySyncError: null, + }, + }); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewer.id, + targetEmail: email, + action: auditAction, + result: "SUCCESS", + }); + return { viewerId: viewer.id, email, action: "removed" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await prisma.viewerUser.update({ + where: { id: viewer.id }, + data: { + googlePlaySyncStatus: "ERROR", + googlePlaySyncError: message, + }, + }); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewer.id, + targetEmail: email, + action: auditAction, + result: "ERROR", + errorMessage: message, + }); + return { viewerId: viewer.id, email, action: "error", error: message }; + } + } + + async hasAccess(email: string): Promise { + if (!this.isConfigured()) return false; + const client = this.groupsClient(); + const found = await client.lookupMembership(normalizeEmail(email)); + return found !== null; + } + + async getMembership(email: string): Promise<{ name: string } | null> { + if (!this.isConfigured()) return null; + const client = this.groupsClient(); + return client.lookupMembership(normalizeEmail(email)); + } + + async reconcileUser(viewerId: string, admin?: AdminContext): Promise { + const viewer = await this.getViewerOrThrow(viewerId); + const email = resolveGooglePlayEmail(viewer); + + if (!this.isConfigured()) { + return { viewerId, email, action: "error", error: "Not configured" }; + } + + try { + const client = this.groupsClient(); + const member = await client.lookupMembership(email); + const isMember = member !== null; + + if (viewer.googlePlayAccess && isMember) { + await prisma.viewerUser.update({ + where: { id: viewerId }, + data: { + googlePlayMembershipId: member!.name, + googlePlaySyncStatus: "ACTIVE", + googlePlayLastSyncedAt: new Date(), + googlePlaySyncError: null, + }, + }); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewerId, + targetEmail: email, + action: "RECONCILE", + result: "SUCCESS", + membershipId: member!.name, + }); + return { viewerId, email, action: "ok" }; + } + + if (viewer.googlePlayAccess && !isMember) { + return this.addAccess(viewer, email, admin, "RECONCILE"); + } + + if (!viewer.googlePlayAccess && isMember) { + return this.removeAccess( + { ...viewer, googlePlayMembershipId: member!.name }, + email, + admin, + "RECONCILE" + ); + } + + await prisma.viewerUser.update({ + where: { id: viewerId }, + data: { + googlePlayMembershipId: null, + googlePlaySyncStatus: viewer.googlePlayAccess ? "ACTIVE" : "REMOVED", + googlePlayLastSyncedAt: new Date(), + googlePlaySyncError: null, + }, + }); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewerId, + targetEmail: email, + action: "RECONCILE", + result: "SUCCESS", + }); + return { viewerId, email, action: "ok" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await logGooglePlayAudit({ + ...admin, + viewerUserId: viewerId, + targetEmail: email, + action: "RECONCILE", + result: "ERROR", + errorMessage: message, + }); + return { viewerId, email, action: "error", error: message }; + } + } + + async syncAllUsers(admin?: AdminContext): Promise { + const viewers = await prisma.viewerUser.findMany({ select: { id: true } }); + return this.runBulk(viewers.map((v) => v.id), (id) => this.syncUser(id, admin, "SYNC")); + } + + async reconcileAll(admin?: AdminContext): Promise { + const viewers = await prisma.viewerUser.findMany({ select: { id: true } }); + return this.runBulk(viewers.map((v) => v.id), (id) => this.reconcileUser(id, admin)); + } + + private async runBulk( + ids: string[], + fn: (id: string) => Promise + ): Promise { + const summary: BulkSyncSummary = { + scanned: ids.length, + correct: 0, + added: 0, + removed: 0, + errors: 0, + results: [], + }; + + for (const id of ids) { + const result = await fn(id); + summary.results.push(result); + if (result.action === "ok") summary.correct++; + else if (result.action === "added") summary.added++; + else if (result.action === "removed") summary.removed++; + else if (result.action === "error") summary.errors++; + } + return summary; + } + + /** Verwijder Play-lidmaatschap wanneer kijker wordt gedeactiveerd. */ + async onViewerDisabled(viewerId: string, admin?: AdminContext): Promise { + const viewer = await this.getViewerOrThrow(viewerId); + if (!viewer.googlePlayAccess && viewer.googlePlaySyncStatus === "NOT_ENABLED") return; + + await prisma.viewerUser.update({ + where: { id: viewerId }, + data: { + googlePlayAccess: false, + googlePlaySyncStatus: "PENDING_REMOVE", + }, + }); + await this.removeAccess( + { ...viewer, googlePlayAccess: false }, + resolveGooglePlayEmail(viewer), + admin, + "REMOVE" + ); + } +} + +export function startGooglePlayReconciliationPoller( + service: GooglePlayAccessService, + intervalMs: number +): () => void { + if (!service.isConfigured() || intervalMs <= 0) { + return () => undefined; + } + const handle = setInterval(() => { + service.reconcileAll().catch((err) => { + console.error("[google-play] Scheduled reconciliation failed:", err); + }); + }, intervalMs); + return () => clearInterval(handle); +} diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 240af17..8e3cf88 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1,11 +1,12 @@ import type { FastifyInstance } from "fastify"; import { AppError } from "../security/errors"; -import { requireAdmin } from "../auth/routes"; +import { requireAdmin, type AuthenticatedRequest } from "../auth/routes"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; import { ViewerService } from "./service"; import type { DownloadService } from "../downloads/service"; import type { SubtitleSource } from "../opensubtitles/client"; +import { GooglePlayAccessService } from "../google-play/service"; export function registerViewerRoutes( app: FastifyInstance, @@ -14,6 +15,7 @@ export function registerViewerRoutes( ) { const playback = new PlaybackService(config); const viewers = new ViewerService(config, playback, downloads); + const googlePlay = new GooglePlayAccessService(config); // ——— Admin: kijkers ——— app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => { @@ -45,6 +47,13 @@ export function registerViewerRoutes( password?: string; }; const viewer = await viewers.updateViewer(id, body); + if (body.enabled === false) { + const admin = (request as AuthenticatedRequest).user; + await googlePlay.onViewerDisabled(id, { + adminUserId: admin.id, + adminEmail: admin.email, + }); + } return { viewer }; }); diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 89b1e37..9a6d801 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -97,6 +97,13 @@ export class ViewerService { name: v.name, enabled: v.enabled, appAccess: v.appAccess, + googlePlayEmail: v.googlePlayEmail, + googlePlayAccess: v.googlePlayAccess, + googlePlayMembershipId: v.googlePlayMembershipId, + googlePlaySyncStatus: v.googlePlaySyncStatus, + googlePlayLastSyncedAt: v.googlePlayLastSyncedAt, + googlePlaySyncError: v.googlePlaySyncError, + googlePlayResolvedEmail: v.googlePlayEmail?.trim() || v.email, createdAt: v.createdAt, deviceCount: v._count.devices, devices: v.devices, diff --git a/deploy/master/.env.example b/deploy/master/.env.example index 60b464f..73a24ec 100644 --- a/deploy/master/.env.example +++ b/deploy/master/.env.example @@ -21,3 +21,13 @@ PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_OFFLINE_THRESHOLD_SECONDS=90 HIDE_UNAVAILABLE_TITLES=true + +# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md) +# GOOGLE_CLOUD_PROJECT_ID= +# GOOGLE_SERVICE_ACCOUNT_EMAIL= +# GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY= +# GOOGLE_WORKSPACE_ADMIN_EMAIL= +# PLAY_ACCESS_GROUP_ID= +# PLAY_ACCESS_GROUP_EMAIL= +# GOOGLE_PLAY_OPT_IN_URL= +# GOOGLE_PLAY_SYNC_INTERVAL_MS=86400000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51c41e8..2a56ab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.6.1 - fastify: - specifier: ^5.2.1 - version: 5.12.1 + google-auth-library: + specifier: ^9.15.1 + version: 9.15.1(supports-color@5.5.0) ioredis: specifier: ^5.4.2 version: 5.11.1(supports-color@5.5.0) @@ -274,30 +274,12 @@ packages: cpu: [x64] os: [win32] - '@fastify/ajv-compiler@4.0.6': - resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} - '@fastify/cookie@11.1.2': resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} '@fastify/cors@10.1.0': resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} - '@fastify/error@4.2.0': - resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - - '@fastify/fast-json-stringify-compiler@5.1.0': - resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} - - '@fastify/forwarded@3.0.2': - resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} - - '@fastify/merge-json-schemas@0.2.1': - resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - - '@fastify/proxy-addr@5.1.0': - resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@fastify/rate-limit@10.3.0': resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} @@ -523,9 +505,6 @@ packages: resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@prisma/client@6.19.3': resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} engines: {node: '>=18.18'} @@ -576,23 +555,13 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} ansi-escapes@3.2.0: resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==} @@ -620,17 +589,19 @@ packages: array-flatten@3.0.0: resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==} - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - avvio@9.3.0: - resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} body-parser@1.20.6: resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -713,10 +684,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - cookie@2.0.1: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} @@ -760,10 +727,6 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -786,6 +749,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -838,6 +804,9 @@ packages: exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} @@ -846,36 +815,12 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} - fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stringify@7.0.1: - resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} - - fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} - - fast-uri@4.1.3: - resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==} - fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} fastify-plugin@6.0.0: resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} - fastify@5.12.1: - resolution: {integrity: sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -884,10 +829,6 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} - find-my-way@9.9.0: - resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} - engines: {node: '>=20'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -904,6 +845,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -916,10 +865,22 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -936,6 +897,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -955,14 +920,14 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.5.0: - resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} - engines: {node: '>= 10'} - is-fullwidth-code-point@2.0.0: resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} engines: {node: '>=4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-wsl@1.1.0: resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} engines: {node: '>=4'} @@ -971,14 +936,14 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - json-schema-ref-resolver@3.0.0: - resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - light-my-request@6.6.0: - resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -1108,10 +1073,6 @@ packages: ohash@2.0.12: resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1150,16 +1111,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true - pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} @@ -1177,12 +1128,6 @@ packages: typescript: optional: true - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - - process-warning@5.1.0: - resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1194,9 +1139,6 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1225,13 +1167,6 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - real-require@1.0.0: - resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} - redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -1240,25 +1175,10 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - router@1.3.8: resolution: {integrity: sha512-461UFH44NtSfIlS83PUg2N7OZo86BC/kB3dY77gJdsODsBhhw7+2uE0tzTINxrY9CahCUVk1VhpWCA5i1yoIEg==} engines: {node: '>= 0.8'} @@ -1274,23 +1194,12 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-regex2@5.1.1: - resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} - hasBin: true - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -1308,9 +1217,6 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -1337,17 +1243,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -1397,10 +1296,6 @@ packages: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} - thread-stream@4.2.0: - resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} - engines: {node: '>=20'} - through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -1457,6 +1352,11 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -1570,12 +1470,6 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@fastify/ajv-compiler@4.0.6': - dependencies: - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 4.1.3 - '@fastify/cookie@11.1.2': dependencies: cookie: 2.0.1 @@ -1586,23 +1480,6 @@ snapshots: fastify-plugin: 5.1.0 mnemonist: 0.40.0 - '@fastify/error@4.2.0': {} - - '@fastify/fast-json-stringify-compiler@5.1.0': - dependencies: - fast-json-stringify: 7.0.1 - - '@fastify/forwarded@3.0.2': {} - - '@fastify/merge-json-schemas@0.2.1': - dependencies: - dequal: 2.0.3 - - '@fastify/proxy-addr@5.1.0': - dependencies: - '@fastify/forwarded': 3.0.2 - ipaddr.js: 2.5.0 - '@fastify/rate-limit@10.3.0': dependencies: '@lukeed/ms': 2.0.2 @@ -1747,8 +1624,6 @@ snapshots: '@phc/format@1.0.0': {} - '@pinojs/redact@0.4.0': {} - '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.3(typescript@5.9.3) @@ -1806,23 +1681,12 @@ snapshots: dependencies: '@types/node': 22.20.1 - abstract-logging@2.0.1: {} - accepts@1.3.8: dependencies: mime-types: 2.1.35 negotiator: 0.6.3 - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 + agent-base@7.1.4: {} ansi-escapes@3.2.0: {} @@ -1844,12 +1708,9 @@ snapshots: array-flatten@3.0.0: {} - atomic-sleep@1.0.0: {} + base64-js@1.5.1: {} - avvio@9.3.0: - dependencies: - '@fastify/error': 4.2.0 - fastq: 1.20.1 + bignumber.js@9.3.1: {} body-parser@1.20.6(supports-color@5.5.0): dependencies: @@ -1868,6 +1729,8 @@ snapshots: transitivePeerDependencies: - supports-color + buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} c12@3.1.0: @@ -1945,8 +1808,6 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: {} - cookie@2.0.1: {} cors@2.8.6: @@ -1976,8 +1837,6 @@ snapshots: depd@2.0.0: {} - dequal@2.0.3: {} - destr@2.0.5: {} destroy@1.2.0: {} @@ -2000,6 +1859,10 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.21.0: @@ -2096,6 +1959,8 @@ snapshots: exsolve@1.1.1: {} + extend@3.0.2: {} + external-editor@3.1.0: dependencies: chardet: 0.7.0 @@ -2106,53 +1971,10 @@ snapshots: dependencies: pure-rand: 6.1.0 - fast-decode-uri-component@1.0.1: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stringify@7.0.1: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 4.1.3 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - - fast-querystring@1.1.2: - dependencies: - fast-decode-uri-component: 1.0.1 - - fast-uri@3.1.6: {} - - fast-uri@4.1.3: {} - fastify-plugin@5.1.0: {} fastify-plugin@6.0.0: {} - fastify@5.12.1: - dependencies: - '@fastify/ajv-compiler': 4.0.6 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.1.0 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.3.0 - fast-json-stringify: 7.0.1 - find-my-way: 9.9.0 - light-my-request: 6.6.0 - pino: 10.3.1 - process-warning: 5.1.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.8.5 - toad-cache: 3.7.4 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -2169,12 +1991,6 @@ snapshots: transitivePeerDependencies: - supports-color - find-my-way@9.9.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-querystring: 1.1.2 - safe-regex2: 5.1.1 - forwarded@0.2.0: {} fresh@0.5.2: {} @@ -2184,6 +2000,26 @@ snapshots: function-bind@1.1.2: {} + gaxios@6.7.1(supports-color@5.5.0): + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6(supports-color@5.5.0) + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.1(supports-color@5.5.0): + dependencies: + gaxios: 6.7.1(supports-color@5.5.0) + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2211,8 +2047,30 @@ snapshots: nypm: 0.6.9 pathe: 2.0.3 + google-auth-library@9.15.1(supports-color@5.5.0): + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1(supports-color@5.5.0) + gcp-metadata: 6.1.1(supports-color@5.5.0) + gtoken: 7.1.0(supports-color@5.5.0) + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + gopd@1.2.0: {} + gtoken@7.1.0(supports-color@5.5.0): + dependencies: + gaxios: 6.7.1(supports-color@5.5.0) + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@3.0.0: {} has-symbols@1.1.0: {} @@ -2229,6 +2087,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@7.0.6(supports-color@5.5.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -2265,25 +2130,28 @@ snapshots: ipaddr.js@1.9.1: {} - ipaddr.js@2.5.0: {} - is-fullwidth-code-point@2.0.0: {} + is-stream@2.0.1: {} + is-wsl@1.1.0: {} jiti@2.7.0: {} - json-schema-ref-resolver@3.0.0: + json-bigint@1.0.0: dependencies: - dequal: 2.0.3 + bignumber.js: 9.3.1 - json-schema-traverse@1.0.0: {} - - light-my-request@6.6.0: + jwa@2.0.1: dependencies: - cookie: 1.1.1 - process-warning: 4.0.1 - set-cookie-parser: 2.7.2 + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 lodash@4.18.1: {} @@ -2374,8 +2242,6 @@ snapshots: ohash@2.0.12: {} - on-exit-leak-free@2.1.2: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -2406,26 +2272,6 @@ snapshots: picocolors@1.1.1: {} - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.1.0: {} - - pino@10.3.1: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.1.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 4.2.0 - pkg-types@2.3.1: dependencies: confbox: 0.2.4 @@ -2447,10 +2293,6 @@ snapshots: transitivePeerDependencies: - magicast - process-warning@4.0.1: {} - - process-warning@5.1.0: {} - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2463,8 +2305,6 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 - quick-format-unescaped@4.0.4: {} - range-parser@1.2.1: {} raw-body@2.5.3: @@ -2494,29 +2334,17 @@ snapshots: readdirp@4.1.2: {} - real-require@0.2.0: {} - - real-require@1.0.0: {} - redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 - require-from-string@2.0.2: {} - restore-cursor@2.0.0: dependencies: onetime: 2.0.1 signal-exit: 3.0.7 - ret@0.5.0: {} - - reusify@1.1.0: {} - - rfdc@1.4.1: {} - router@1.3.8(supports-color@5.5.0): dependencies: array-flatten: 3.0.0 @@ -2537,21 +2365,14 @@ snapshots: safe-buffer@5.2.1: {} - safe-regex2@5.1.1: - dependencies: - ret: 0.5.0 - - safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: {} scheduler@0.27.0: {} - secure-json-parse@4.1.0: {} - semver@5.7.2: {} - semver@7.8.5: {} + semver@7.8.5: + optional: true send@0.19.2(supports-color@5.5.0): dependencies: @@ -2580,8 +2401,6 @@ snapshots: transitivePeerDependencies: - supports-color - set-cookie-parser@2.7.2: {} - setprototypeof@1.2.0: {} sharp@0.34.5: @@ -2646,14 +2465,8 @@ snapshots: signal-exit@3.0.7: {} - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - source-map-js@1.2.1: {} - split2@4.2.0: {} - standard-as-callback@2.1.0: {} statuses@2.0.2: {} @@ -2705,10 +2518,6 @@ snapshots: dependencies: has-flag: 3.0.0 - thread-stream@4.2.0: - dependencies: - real-require: 1.0.0 - through@2.3.8: {} tinyexec@1.3.0: {} @@ -2748,6 +2557,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@9.0.1: {} + vary@1.1.2: {} webidl-conversions@3.0.1: {}