Add admin Google Play Closed Testing access via Cloud Identity Groups.

Admins can enable or revoke store access per viewer; membership syncs to a configurable Google Group without changing app auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 03:04:34 +02:00
parent 04d6a38234
commit 0eb0c5d367
18 changed files with 1599 additions and 329 deletions

187
GOOGLE_PLAY_ACCESS_SETUP.md Normal file
View file

@ -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.

View file

@ -11,36 +11,85 @@ type Device = {
createdAt: string; createdAt: string;
}; };
type GooglePlaySyncStatus =
| "NOT_ENABLED"
| "PENDING_ADD"
| "ACTIVE"
| "PENDING_REMOVE"
| "REMOVED"
| "ERROR";
type Viewer = { type Viewer = {
id: string; id: string;
email: string; email: string;
name: string | null; name: string | null;
enabled: boolean; enabled: boolean;
appAccess: boolean; appAccess: boolean;
googlePlayEmail: string | null;
googlePlayAccess: boolean;
googlePlayMembershipId: string | null;
googlePlaySyncStatus: GooglePlaySyncStatus;
googlePlayLastSyncedAt: string | null;
googlePlaySyncError: string | null;
googlePlayResolvedEmail: string;
createdAt: string; createdAt: string;
deviceCount: number; deviceCount: number;
devices: Device[]; devices: Device[];
}; };
type PlayConfig = {
configured: boolean;
groupEmail: string | null;
groupId: string | null;
optInUrl: string | null;
};
const SYNC_LABELS: Record<GooglePlaySyncStatus, string> = {
NOT_ENABLED: "Niet ingeschakeld",
PENDING_ADD: "Bezig met toevoegen…",
ACTIVE: "Actief",
PENDING_REMOVE: "Bezig met verwijderen…",
REMOVED: "Verwijderd",
ERROR: "Fout",
};
export default function ViewersPage() { export default function ViewersPage() {
useAuth(); useAuth();
const [viewers, setViewers] = useState<Viewer[]>([]); const [viewers, setViewers] = useState<Viewer[]>([]);
const [playConfig, setPlayConfig] = useState<PlayConfig | null>(null);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [playEmails, setPlayEmails] = useState<Record<string, string>>({});
const load = useCallback(() => { const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" }) fetch("/api/v1/admin/viewers", { credentials: "include" })
.then((r) => r.json()) .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")); .catch(() => setErr("Kon kijkers niet laden"));
}, []); }, []);
useEffect(() => { useEffect(() => {
load(); load();
fetch("/api/v1/admin/google-play-access/config", { credentials: "include" })
.then((r) => r.json())
.then((d) => setPlayConfig(d.config ?? null))
.catch(() => undefined);
}, [load]); }, [load]);
async function createViewer(e: FormEvent) { async function createViewer(e: FormEvent) {
@ -80,6 +129,64 @@ export default function ViewersPage() {
load(); 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) { async function revokeDevice(deviceId: string) {
if (!confirm("Apparaat intrekken?")) return; if (!confirm("Apparaat intrekken?")) return;
await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, { await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, {
@ -104,6 +211,27 @@ export default function ViewersPage() {
</div> </div>
</header> </header>
{playConfig && (
<section className="card-block play-banner">
<h2>Google Play access</h2>
<p className="muted">
{playConfig.configured
? `Groep: ${playConfig.groupEmail ?? playConfig.groupId}`
: "Google Play-toegang is nog niet geconfigureerd op de server (zie GOOGLE_PLAY_ACCESS_SETUP.md)."}
</p>
{playConfig.configured && (
<div className="viewer-actions">
<button type="button" onClick={() => bulkAction("sync-all")}>
Sync all
</button>
<button type="button" onClick={() => bulkAction("reconcile")}>
Reconcile
</button>
</div>
)}
</section>
)}
<section className="card-block"> <section className="card-block">
<h2>Nieuwe kijker</h2> <h2>Nieuwe kijker</h2>
<form className="form-grid" onSubmit={createViewer}> <form className="form-grid" onSubmit={createViewer}>
@ -148,7 +276,7 @@ export default function ViewersPage() {
<article key={v.id} className="viewer-row"> <article key={v.id} className="viewer-row">
<div> <div>
<strong>{v.name || v.email}</strong> <strong>{v.name || v.email}</strong>
<div className="muted">{v.email}</div> <div className="muted">Account email: {v.email}</div>
<div className="muted"> <div className="muted">
{v.enabled ? "Actief" : "Uit"} · app {v.appAccess ? "aan" : "uit"} ·{" "} {v.enabled ? "Actief" : "Uit"} · app {v.appAccess ? "aan" : "uit"} ·{" "}
{v.deviceCount} apparaat(en) {v.deviceCount} apparaat(en)
@ -162,6 +290,74 @@ export default function ViewersPage() {
{v.appAccess ? "App uit" : "App aan"} {v.appAccess ? "App uit" : "App aan"}
</button> </button>
</div> </div>
<div className="play-section">
<h3>Google Play access</h3>
<label className="play-field">
Google Play email
<input
type="email"
placeholder={v.email}
value={playEmails[v.id] ?? ""}
onChange={(e) =>
setPlayEmails((prev) => ({ ...prev, [v.id]: e.target.value }))
}
/>
<span className="muted">
Fallback: {v.googlePlayResolvedEmail || v.email}
</span>
</label>
<div className="play-meta">
<span>
Store access:{" "}
<strong>{v.googlePlayAccess ? "ON" : "OFF"}</strong>
</span>
<span>
Status:{" "}
<strong className={v.googlePlaySyncStatus === "ERROR" ? "status-err" : ""}>
{SYNC_LABELS[v.googlePlaySyncStatus] ?? v.googlePlaySyncStatus}
</strong>
</span>
{v.googlePlayLastSyncedAt && (
<span className="muted">
Last synchronized:{" "}
{new Date(v.googlePlayLastSyncedAt).toLocaleString("nl-NL")}
</span>
)}
{v.googlePlaySyncError && (
<span className="form-error">{v.googlePlaySyncError}</span>
)}
</div>
<div className="viewer-actions">
{!v.googlePlayAccess ? (
<button type="button" onClick={() => setPlayAccess(v, true)}>
Store access aan
</button>
) : (
<button type="button" onClick={() => setPlayAccess(v, false)}>
Store access uit
</button>
)}
{v.googlePlaySyncStatus === "ERROR" && (
<button type="button" onClick={() => retryPlaySync(v)}>
Retry synchronization
</button>
)}
{v.googlePlayAccess &&
v.googlePlaySyncStatus === "ACTIVE" &&
playConfig?.optInUrl && (
<a
href={playConfig.optInUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-link"
>
Open Google Play
</a>
)}
</div>
</div>
{v.devices.length > 0 && ( {v.devices.length > 0 && (
<ul className="device-list"> <ul className="device-list">
{v.devices.map((d) => ( {v.devices.map((d) => (
@ -198,6 +394,41 @@ export default function ViewersPage() {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; 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 { .device-list {
list-style: none; list-style: none;

View file

@ -19,3 +19,13 @@ PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90 NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=true 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

View file

@ -6,7 +6,7 @@
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/app.js", "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:generate": "prisma generate",
"db:migrate": "prisma migrate deploy", "db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev", "db:migrate:dev": "prisma migrate dev",
@ -22,7 +22,7 @@
"@prisma/client": "^6.3.1", "@prisma/client": "^6.3.1",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"fastify": "^5.2.1", "google-auth-library": "^9.15.1",
"ioredis": "^5.4.2", "ioredis": "^5.4.2",
"nanoid": "^5.0.9", "nanoid": "^5.0.9",
"prisma": "^6.3.1", "prisma": "^6.3.1",

View file

@ -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;

View file

@ -43,6 +43,15 @@ enum DownloadJobStatus {
FAILED FAILED
} }
enum GooglePlaySyncStatus {
NOT_ENABLED
PENDING_ADD
ACTIVE
PENDING_REMOVE
REMOVED
ERROR
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
@ -65,6 +74,14 @@ model ViewerUser {
name String? name String?
enabled Boolean @default(true) enabled Boolean @default(true)
appAccess Boolean @default(true) @map("app_access") 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -72,6 +89,7 @@ model ViewerUser {
progress WatchProgress[] progress WatchProgress[]
shelfPrefs ViewerShelfPreference[] shelfPrefs ViewerShelfPreference[]
linkCodes DeviceLinkCode[] linkCodes DeviceLinkCode[]
googlePlayAuditLogs GooglePlayAccessAuditLog[]
@@map("viewer_users") @@map("viewer_users")
} }
@ -568,3 +586,22 @@ model LibrarySubtitle {
@@index([imdbId]) @@index([imdbId])
@@map("library_subtitles") @@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")
}

View file

@ -13,6 +13,8 @@ import { registerInstallRoutes } from "./install/routes";
import { registerDownloadRoutes } from "./downloads/routes"; import { registerDownloadRoutes } from "./downloads/routes";
import { registerSettingsRoutes } from "./settings/routes"; import { registerSettingsRoutes } from "./settings/routes";
import { registerViewerRoutes } from "./viewer/routes"; import { registerViewerRoutes } from "./viewer/routes";
import { registerGooglePlayRoutes } from "./google-play/routes";
import { startGooglePlayReconciliationPoller } from "./google-play/service";
import { nodeConnectionManager } from "./websocket/manager"; import { nodeConnectionManager } from "./websocket/manager";
import { MetadataService } from "./metadata/service"; import { MetadataService } from "./metadata/service";
import { toErrorResponse } from "./security/errors"; import { toErrorResponse } from "./security/errors";
@ -77,6 +79,8 @@ async function main() {
registerSettingsRoutes(app, config); registerSettingsRoutes(app, config);
const downloads = registerDownloadRoutes(app, config); const downloads = registerDownloadRoutes(app, config);
registerViewerRoutes(app, config, downloads); 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) => { app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
void nodeConnectionManager.handleConnection(socket); void nodeConnectionManager.handleConnection(socket);
@ -381,6 +385,46 @@ async function main() {
CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id") 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) { } catch (err) {
console.warn("Could not ensure viewer client tables:", err); console.warn("Could not ensure viewer client tables:", err);
} }

View file

@ -18,6 +18,18 @@ const configSchema = z.object({
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90), NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90),
HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(true), 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<typeof configSchema>; export type Config = z.infer<typeof configSchema>;

View file

@ -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,
},
});
}

View file

@ -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<string> {
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<T>(
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<void> {
const name = membershipName.replace(/^\//, "");
const { ok, status, raw } = await this.request<unknown>(name, { method: "DELETE" });
if (status === 404 || ok) return;
throw new Error(`Membership verwijderen mislukt (${status}): ${raw.slice(0, 200)}`);
}
async deleteMembershipByEmail(email: string): Promise<void> {
const found = await this.lookupMembership(email);
if (!found) return;
await this.deleteMembership(found.name);
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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<string, string>();
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);
});

View file

@ -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<SyncResult> {
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<ReturnType<GooglePlayAccessService["getViewerOrThrow"]>>,
email: string,
admin: AdminContext | undefined,
auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE"
): Promise<SyncResult> {
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<ReturnType<GooglePlayAccessService["getViewerOrThrow"]>>,
email: string,
admin: AdminContext | undefined,
auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE"
): Promise<SyncResult> {
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<boolean> {
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<SyncResult> {
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<BulkSyncSummary> {
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<BulkSyncSummary> {
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<SyncResult>
): Promise<BulkSyncSummary> {
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<void> {
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);
}

View file

@ -1,11 +1,12 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { requireAdmin } from "../auth/routes"; import { requireAdmin, type AuthenticatedRequest } from "../auth/routes";
import { PlaybackService } from "../playback/service"; import { PlaybackService } from "../playback/service";
import type { Config } from "../config"; import type { Config } from "../config";
import { ViewerService } from "./service"; import { ViewerService } from "./service";
import type { DownloadService } from "../downloads/service"; import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client"; import type { SubtitleSource } from "../opensubtitles/client";
import { GooglePlayAccessService } from "../google-play/service";
export function registerViewerRoutes( export function registerViewerRoutes(
app: FastifyInstance, app: FastifyInstance,
@ -14,6 +15,7 @@ export function registerViewerRoutes(
) { ) {
const playback = new PlaybackService(config); const playback = new PlaybackService(config);
const viewers = new ViewerService(config, playback, downloads); const viewers = new ViewerService(config, playback, downloads);
const googlePlay = new GooglePlayAccessService(config);
// ——— Admin: kijkers ——— // ——— Admin: kijkers ———
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
@ -45,6 +47,13 @@ export function registerViewerRoutes(
password?: string; password?: string;
}; };
const viewer = await viewers.updateViewer(id, body); 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 }; return { viewer };
}); });

View file

@ -97,6 +97,13 @@ export class ViewerService {
name: v.name, name: v.name,
enabled: v.enabled, enabled: v.enabled,
appAccess: v.appAccess, 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, createdAt: v.createdAt,
deviceCount: v._count.devices, deviceCount: v._count.devices,
devices: v.devices, devices: v.devices,

View file

@ -21,3 +21,13 @@ PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90 NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=true 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

View file

@ -62,9 +62,9 @@ importers:
dotenv: dotenv:
specifier: ^16.4.7 specifier: ^16.4.7
version: 16.6.1 version: 16.6.1
fastify: google-auth-library:
specifier: ^5.2.1 specifier: ^9.15.1
version: 5.12.1 version: 9.15.1(supports-color@5.5.0)
ioredis: ioredis:
specifier: ^5.4.2 specifier: ^5.4.2
version: 5.11.1(supports-color@5.5.0) version: 5.11.1(supports-color@5.5.0)
@ -274,30 +274,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@fastify/ajv-compiler@4.0.6':
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
'@fastify/cookie@11.1.2': '@fastify/cookie@11.1.2':
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
'@fastify/cors@10.1.0': '@fastify/cors@10.1.0':
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} 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': '@fastify/rate-limit@10.3.0':
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
@ -523,9 +505,6 @@ packages:
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@prisma/client@6.19.3': '@prisma/client@6.19.3':
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
@ -576,23 +555,13 @@ packages:
'@types/ws@8.18.1': '@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
accepts@1.3.8: accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
ajv-formats@3.0.1: agent-base@7.1.4:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
peerDependencies: engines: {node: '>= 14'}
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-escapes@3.2.0: ansi-escapes@3.2.0:
resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==} resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==}
@ -620,17 +589,19 @@ packages:
array-flatten@3.0.0: array-flatten@3.0.0:
resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==} resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==}
atomic-sleep@1.0.0: base64-js@1.5.1:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
engines: {node: '>=8.0.0'}
avvio@9.3.0: bignumber.js@9.3.1:
resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
body-parser@1.20.6: body-parser@1.20.6:
resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} 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: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -713,10 +684,6 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cookie@2.0.1: cookie@2.0.1:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'} engines: {node: '>=22'}
@ -760,10 +727,6 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destr@2.0.5: destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
@ -786,6 +749,9 @@ packages:
duplexify@4.1.3: duplexify@4.1.3:
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -838,6 +804,9 @@ packages:
exsolve@1.1.1: exsolve@1.1.1:
resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
external-editor@3.1.0: external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -846,36 +815,12 @@ packages:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'} 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: fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify-plugin@6.0.0: fastify-plugin@6.0.0:
resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} 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: figures@2.0.0:
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -884,10 +829,6 @@ packages:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
find-my-way@9.9.0:
resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==}
engines: {node: '>=20'}
forwarded@0.2.0: forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -904,6 +845,14 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} 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: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -916,10 +865,22 @@ packages:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true 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: gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
gtoken@7.1.0:
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
engines: {node: '>=14.0.0'}
has-flag@3.0.0: has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -936,6 +897,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.4.24: iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -955,14 +920,14 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} 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: is-fullwidth-code-point@2.0.0:
resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==}
engines: {node: '>=4'} engines: {node: '>=4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-wsl@1.1.0: is-wsl@1.1.0:
resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -971,14 +936,14 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
json-schema-ref-resolver@3.0.0: json-bigint@1.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
json-schema-traverse@1.0.0: jwa@2.0.1:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
light-my-request@6.6.0: jws@4.0.1:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
lodash@4.18.1: lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@ -1108,10 +1073,6 @@ packages:
ohash@2.0.12: ohash@2.0.12:
resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} 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: on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -1150,16 +1111,6 @@ packages:
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 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: pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
@ -1177,12 +1128,6 @@ packages:
typescript: typescript:
optional: true 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: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -1194,9 +1139,6 @@ packages:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
range-parser@1.2.1: range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -1225,13 +1167,6 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} 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: redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1240,25 +1175,10 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'} 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: restore-cursor@2.0.0:
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
engines: {node: '>=4'} 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: router@1.3.8:
resolution: {integrity: sha512-461UFH44NtSfIlS83PUg2N7OZo86BC/kB3dY77gJdsODsBhhw7+2uE0tzTINxrY9CahCUVk1VhpWCA5i1yoIEg==} resolution: {integrity: sha512-461UFH44NtSfIlS83PUg2N7OZo86BC/kB3dY77gJdsODsBhhw7+2uE0tzTINxrY9CahCUVk1VhpWCA5i1yoIEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -1274,23 +1194,12 @@ packages:
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 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: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} 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: semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true hasBin: true
@ -1308,9 +1217,6 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@ -1337,17 +1243,10 @@ packages:
signal-exit@3.0.7: signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
standard-as-callback@2.1.0: standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
@ -1397,10 +1296,6 @@ packages:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'} engines: {node: '>=4'}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
through@2.3.8: through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@ -1457,6 +1352,11 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} 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: vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -1570,12 +1470,6 @@ snapshots:
'@esbuild/win32-x64@0.28.2': '@esbuild/win32-x64@0.28.2':
optional: true 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': '@fastify/cookie@11.1.2':
dependencies: dependencies:
cookie: 2.0.1 cookie: 2.0.1
@ -1586,23 +1480,6 @@ snapshots:
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
mnemonist: 0.40.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': '@fastify/rate-limit@10.3.0':
dependencies: dependencies:
'@lukeed/ms': 2.0.2 '@lukeed/ms': 2.0.2
@ -1747,8 +1624,6 @@ snapshots:
'@phc/format@1.0.0': {} '@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)': '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)':
optionalDependencies: optionalDependencies:
prisma: 6.19.3(typescript@5.9.3) prisma: 6.19.3(typescript@5.9.3)
@ -1806,23 +1681,12 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.20.1 '@types/node': 22.20.1
abstract-logging@2.0.1: {}
accepts@1.3.8: accepts@1.3.8:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
negotiator: 0.6.3 negotiator: 0.6.3
ajv-formats@3.0.1(ajv@8.20.0): agent-base@7.1.4: {}
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
ansi-escapes@3.2.0: {} ansi-escapes@3.2.0: {}
@ -1844,12 +1708,9 @@ snapshots:
array-flatten@3.0.0: {} array-flatten@3.0.0: {}
atomic-sleep@1.0.0: {} base64-js@1.5.1: {}
avvio@9.3.0: bignumber.js@9.3.1: {}
dependencies:
'@fastify/error': 4.2.0
fastq: 1.20.1
body-parser@1.20.6(supports-color@5.5.0): body-parser@1.20.6(supports-color@5.5.0):
dependencies: dependencies:
@ -1868,6 +1729,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
buffer-equal-constant-time@1.0.1: {}
bytes@3.1.2: {} bytes@3.1.2: {}
c12@3.1.0: c12@3.1.0:
@ -1945,8 +1808,6 @@ snapshots:
cookie@0.7.2: {} cookie@0.7.2: {}
cookie@1.1.1: {}
cookie@2.0.1: {} cookie@2.0.1: {}
cors@2.8.6: cors@2.8.6:
@ -1976,8 +1837,6 @@ snapshots:
depd@2.0.0: {} depd@2.0.0: {}
dequal@2.0.3: {}
destr@2.0.5: {} destr@2.0.5: {}
destroy@1.2.0: {} destroy@1.2.0: {}
@ -2000,6 +1859,10 @@ snapshots:
readable-stream: 3.6.2 readable-stream: 3.6.2
stream-shift: 1.0.3 stream-shift: 1.0.3
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {} ee-first@1.1.1: {}
effect@3.21.0: effect@3.21.0:
@ -2096,6 +1959,8 @@ snapshots:
exsolve@1.1.1: {} exsolve@1.1.1: {}
extend@3.0.2: {}
external-editor@3.1.0: external-editor@3.1.0:
dependencies: dependencies:
chardet: 0.7.0 chardet: 0.7.0
@ -2106,53 +1971,10 @@ snapshots:
dependencies: dependencies:
pure-rand: 6.1.0 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@5.1.0: {}
fastify-plugin@6.0.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: figures@2.0.0:
dependencies: dependencies:
escape-string-regexp: 1.0.5 escape-string-regexp: 1.0.5
@ -2169,12 +1991,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: {} forwarded@0.2.0: {}
fresh@0.5.2: {} fresh@0.5.2: {}
@ -2184,6 +2000,26 @@ snapshots:
function-bind@1.1.2: {} 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: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@ -2211,8 +2047,30 @@ snapshots:
nypm: 0.6.9 nypm: 0.6.9
pathe: 2.0.3 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: {} 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-flag@3.0.0: {}
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
@ -2229,6 +2087,13 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 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: iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@ -2265,25 +2130,28 @@ snapshots:
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
ipaddr.js@2.5.0: {}
is-fullwidth-code-point@2.0.0: {} is-fullwidth-code-point@2.0.0: {}
is-stream@2.0.1: {}
is-wsl@1.1.0: {} is-wsl@1.1.0: {}
jiti@2.7.0: {} jiti@2.7.0: {}
json-schema-ref-resolver@3.0.0: json-bigint@1.0.0:
dependencies: dependencies:
dequal: 2.0.3 bignumber.js: 9.3.1
json-schema-traverse@1.0.0: {} jwa@2.0.1:
light-my-request@6.6.0:
dependencies: dependencies:
cookie: 1.1.1 buffer-equal-constant-time: 1.0.1
process-warning: 4.0.1 ecdsa-sig-formatter: 1.0.11
set-cookie-parser: 2.7.2 safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
lodash@4.18.1: {} lodash@4.18.1: {}
@ -2374,8 +2242,6 @@ snapshots:
ohash@2.0.12: {} ohash@2.0.12: {}
on-exit-leak-free@2.1.2: {}
on-finished@2.4.1: on-finished@2.4.1:
dependencies: dependencies:
ee-first: 1.1.1 ee-first: 1.1.1
@ -2406,26 +2272,6 @@ snapshots:
picocolors@1.1.1: {} 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: pkg-types@2.3.1:
dependencies: dependencies:
confbox: 0.2.4 confbox: 0.2.4
@ -2447,10 +2293,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - magicast
process-warning@4.0.1: {}
process-warning@5.1.0: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@ -2463,8 +2305,6 @@ snapshots:
es-define-property: 1.0.1 es-define-property: 1.0.1
side-channel: 1.1.1 side-channel: 1.1.1
quick-format-unescaped@4.0.4: {}
range-parser@1.2.1: {} range-parser@1.2.1: {}
raw-body@2.5.3: raw-body@2.5.3:
@ -2494,29 +2334,17 @@ snapshots:
readdirp@4.1.2: {} readdirp@4.1.2: {}
real-require@0.2.0: {}
real-require@1.0.0: {}
redis-errors@1.2.0: {} redis-errors@1.2.0: {}
redis-parser@3.0.0: redis-parser@3.0.0:
dependencies: dependencies:
redis-errors: 1.2.0 redis-errors: 1.2.0
require-from-string@2.0.2: {}
restore-cursor@2.0.0: restore-cursor@2.0.0:
dependencies: dependencies:
onetime: 2.0.1 onetime: 2.0.1
signal-exit: 3.0.7 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): router@1.3.8(supports-color@5.5.0):
dependencies: dependencies:
array-flatten: 3.0.0 array-flatten: 3.0.0
@ -2537,21 +2365,14 @@ snapshots:
safe-buffer@5.2.1: {} 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: {} safer-buffer@2.1.2: {}
scheduler@0.27.0: {} scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
semver@5.7.2: {} semver@5.7.2: {}
semver@7.8.5: {} semver@7.8.5:
optional: true
send@0.19.2(supports-color@5.5.0): send@0.19.2(supports-color@5.5.0):
dependencies: dependencies:
@ -2580,8 +2401,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
set-cookie-parser@2.7.2: {}
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sharp@0.34.5: sharp@0.34.5:
@ -2646,14 +2465,8 @@ snapshots:
signal-exit@3.0.7: {} signal-exit@3.0.7: {}
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
split2@4.2.0: {}
standard-as-callback@2.1.0: {} standard-as-callback@2.1.0: {}
statuses@2.0.2: {} statuses@2.0.2: {}
@ -2705,10 +2518,6 @@ snapshots:
dependencies: dependencies:
has-flag: 3.0.0 has-flag: 3.0.0
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
through@2.3.8: {} through@2.3.8: {}
tinyexec@1.3.0: {} tinyexec@1.3.0: {}
@ -2748,6 +2557,8 @@ snapshots:
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
uuid@9.0.1: {}
vary@1.1.2: {} vary@1.1.2: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}