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:
parent
04d6a38234
commit
0eb0c5d367
18 changed files with 1599 additions and 329 deletions
187
GOOGLE_PLAY_ACCESS_SETUP.md
Normal file
187
GOOGLE_PLAY_ACCESS_SETUP.md
Normal 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.
|
||||
|
|
@ -11,36 +11,85 @@ type Device = {
|
|||
createdAt: string;
|
||||
};
|
||||
|
||||
type GooglePlaySyncStatus =
|
||||
| "NOT_ENABLED"
|
||||
| "PENDING_ADD"
|
||||
| "ACTIVE"
|
||||
| "PENDING_REMOVE"
|
||||
| "REMOVED"
|
||||
| "ERROR";
|
||||
|
||||
type Viewer = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
enabled: boolean;
|
||||
appAccess: boolean;
|
||||
googlePlayEmail: string | null;
|
||||
googlePlayAccess: boolean;
|
||||
googlePlayMembershipId: string | null;
|
||||
googlePlaySyncStatus: GooglePlaySyncStatus;
|
||||
googlePlayLastSyncedAt: string | null;
|
||||
googlePlaySyncError: string | null;
|
||||
googlePlayResolvedEmail: string;
|
||||
createdAt: string;
|
||||
deviceCount: number;
|
||||
devices: Device[];
|
||||
};
|
||||
|
||||
type PlayConfig = {
|
||||
configured: boolean;
|
||||
groupEmail: string | null;
|
||||
groupId: string | null;
|
||||
optInUrl: string | null;
|
||||
};
|
||||
|
||||
const SYNC_LABELS: Record<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() {
|
||||
useAuth();
|
||||
const [viewers, setViewers] = useState<Viewer[]>([]);
|
||||
const [playConfig, setPlayConfig] = useState<PlayConfig | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [playEmails, setPlayEmails] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setViewers(d.viewers ?? []))
|
||||
.then((d) => {
|
||||
const list: Viewer[] = d.viewers ?? [];
|
||||
setViewers(list);
|
||||
setPlayEmails((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const v of list) {
|
||||
if (next[v.id] === undefined) {
|
||||
next[v.id] = v.googlePlayEmail ?? "";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => setErr("Kon kijkers niet laden"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
fetch("/api/v1/admin/google-play-access/config", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setPlayConfig(d.config ?? null))
|
||||
.catch(() => undefined);
|
||||
}, [load]);
|
||||
|
||||
async function createViewer(e: FormEvent) {
|
||||
|
|
@ -80,6 +129,64 @@ export default function ViewersPage() {
|
|||
load();
|
||||
}
|
||||
|
||||
async function setPlayAccess(v: Viewer, enabled: boolean) {
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
email: playEmails[v.id]?.trim() || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error?.message ?? "Google Play-toegang bijwerken mislukt");
|
||||
} else {
|
||||
setMsg(data.message ?? (enabled ? "Google Play access enabled." : "Google Play access removed."));
|
||||
if (data.syncError) setErr(data.syncError);
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
async function retryPlaySync(v: Viewer) {
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access/retry`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error?.message ?? "Synchronisatie mislukt");
|
||||
} else {
|
||||
setMsg(data.message ?? "Synchronization completed.");
|
||||
if (data.syncError) setErr(data.syncError);
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
async function bulkAction(path: "sync-all" | "reconcile") {
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
const res = await fetch(`/api/v1/admin/google-play-access/${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error?.message ?? "Actie mislukt");
|
||||
return;
|
||||
}
|
||||
const s = data.summary;
|
||||
setMsg(
|
||||
`Scanned: ${s.scanned} · Correct: ${s.correct} · Added: ${s.added} · Removed: ${s.removed} · Errors: ${s.errors}`
|
||||
);
|
||||
load();
|
||||
}
|
||||
|
||||
async function revokeDevice(deviceId: string) {
|
||||
if (!confirm("Apparaat intrekken?")) return;
|
||||
await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, {
|
||||
|
|
@ -104,6 +211,27 @@ export default function ViewersPage() {
|
|||
</div>
|
||||
</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">
|
||||
<h2>Nieuwe kijker</h2>
|
||||
<form className="form-grid" onSubmit={createViewer}>
|
||||
|
|
@ -148,7 +276,7 @@ export default function ViewersPage() {
|
|||
<article key={v.id} className="viewer-row">
|
||||
<div>
|
||||
<strong>{v.name || v.email}</strong>
|
||||
<div className="muted">{v.email}</div>
|
||||
<div className="muted">Account email: {v.email}</div>
|
||||
<div className="muted">
|
||||
{v.enabled ? "Actief" : "Uit"} · app {v.appAccess ? "aan" : "uit"} ·{" "}
|
||||
{v.deviceCount} apparaat(en)
|
||||
|
|
@ -162,6 +290,74 @@ export default function ViewersPage() {
|
|||
{v.appAccess ? "App uit" : "App aan"}
|
||||
</button>
|
||||
</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 && (
|
||||
<ul className="device-list">
|
||||
{v.devices.map((d) => (
|
||||
|
|
@ -198,6 +394,41 @@ export default function ViewersPage() {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.play-section {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.play-section h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.play-field {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
max-width: 360px;
|
||||
}
|
||||
.play-field input {
|
||||
width: 100%;
|
||||
}
|
||||
.play-meta {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.play-banner .viewer-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.status-err {
|
||||
color: #ff8b8b;
|
||||
}
|
||||
.btn-link {
|
||||
color: #8ec5ff;
|
||||
text-decoration: underline;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.device-list {
|
||||
list-style: none;
|
||||
|
|
|
|||
|
|
@ -19,3 +19,13 @@ PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
|||
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
||||
HIDE_UNAVAILABLE_TITLES=true
|
||||
|
||||
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
|
||||
# GOOGLE_CLOUD_PROJECT_ID=
|
||||
# GOOGLE_SERVICE_ACCOUNT_EMAIL=
|
||||
# GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY=
|
||||
# GOOGLE_WORKSPACE_ADMIN_EMAIL=
|
||||
# PLAY_ACCESS_GROUP_ID=
|
||||
# PLAY_ACCESS_GROUP_EMAIL=
|
||||
# GOOGLE_PLAY_OPT_IN_URL=
|
||||
# GOOGLE_PLAY_SYNC_INTERVAL_MS=86400000
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js",
|
||||
"test": "tsx src/metadata/filename-parser.test.ts",
|
||||
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/google-play/service.test.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"@prisma/client": "^6.3.1",
|
||||
"argon2": "^0.41.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.2.1",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"nanoid": "^5.0.9",
|
||||
"prisma": "^6.3.1",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -43,6 +43,15 @@ enum DownloadJobStatus {
|
|||
FAILED
|
||||
}
|
||||
|
||||
enum GooglePlaySyncStatus {
|
||||
NOT_ENABLED
|
||||
PENDING_ADD
|
||||
ACTIVE
|
||||
PENDING_REMOVE
|
||||
REMOVED
|
||||
ERROR
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
|
|
@ -65,6 +74,14 @@ model ViewerUser {
|
|||
name String?
|
||||
enabled Boolean @default(true)
|
||||
appAccess Boolean @default(true) @map("app_access")
|
||||
/** Google-account voor Play Closed Testing; fallback = email */
|
||||
googlePlayEmail String? @map("google_play_email")
|
||||
/** Gewenste Play-distributietoegang (sync met Google Group) */
|
||||
googlePlayAccess Boolean @default(false) @map("google_play_access")
|
||||
googlePlayMembershipId String? @map("google_play_membership_id")
|
||||
googlePlaySyncStatus GooglePlaySyncStatus @default(NOT_ENABLED) @map("google_play_sync_status")
|
||||
googlePlayLastSyncedAt DateTime? @map("google_play_last_synced_at")
|
||||
googlePlaySyncError String? @map("google_play_sync_error") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
|
@ -72,6 +89,7 @@ model ViewerUser {
|
|||
progress WatchProgress[]
|
||||
shelfPrefs ViewerShelfPreference[]
|
||||
linkCodes DeviceLinkCode[]
|
||||
googlePlayAuditLogs GooglePlayAccessAuditLog[]
|
||||
|
||||
@@map("viewer_users")
|
||||
}
|
||||
|
|
@ -568,3 +586,22 @@ model LibrarySubtitle {
|
|||
@@index([imdbId])
|
||||
@@map("library_subtitles")
|
||||
}
|
||||
|
||||
/** Audit trail voor Google Play group-lidmaatschap (geen secrets). */
|
||||
model GooglePlayAccessAuditLog {
|
||||
id String @id @default(uuid())
|
||||
adminUserId String? @map("admin_user_id")
|
||||
adminEmail String? @map("admin_email")
|
||||
viewerUserId String @map("viewer_user_id")
|
||||
targetEmail String @map("target_email")
|
||||
action String
|
||||
result String
|
||||
membershipId String? @map("membership_id")
|
||||
errorMessage String? @map("error_message") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([viewerUserId, createdAt])
|
||||
@@map("google_play_access_audit_logs")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { registerInstallRoutes } from "./install/routes";
|
|||
import { registerDownloadRoutes } from "./downloads/routes";
|
||||
import { registerSettingsRoutes } from "./settings/routes";
|
||||
import { registerViewerRoutes } from "./viewer/routes";
|
||||
import { registerGooglePlayRoutes } from "./google-play/routes";
|
||||
import { startGooglePlayReconciliationPoller } from "./google-play/service";
|
||||
import { nodeConnectionManager } from "./websocket/manager";
|
||||
import { MetadataService } from "./metadata/service";
|
||||
import { toErrorResponse } from "./security/errors";
|
||||
|
|
@ -77,6 +79,8 @@ async function main() {
|
|||
registerSettingsRoutes(app, config);
|
||||
const downloads = registerDownloadRoutes(app, config);
|
||||
registerViewerRoutes(app, config, downloads);
|
||||
const googlePlay = registerGooglePlayRoutes(app, config);
|
||||
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
||||
|
||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||
void nodeConnectionManager.handleConnection(socket);
|
||||
|
|
@ -381,6 +385,46 @@ async function main() {
|
|||
CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "GooglePlaySyncStatus" AS ENUM (
|
||||
'NOT_ENABLED', 'PENDING_ADD', 'ACTIVE', 'PENDING_REMOVE', 'REMOVED', 'ERROR'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_email" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_access" BOOLEAN NOT NULL DEFAULT false`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_membership_id" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_sync_status" "GooglePlaySyncStatus" NOT NULL DEFAULT 'NOT_ENABLED'`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_last_synced_at" TIMESTAMP(3)`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_sync_error" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "google_play_access_audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"admin_user_id" TEXT,
|
||||
"admin_email" TEXT,
|
||||
"viewer_user_id" TEXT NOT NULL,
|
||||
"target_email" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"result" TEXT NOT NULL,
|
||||
"membership_id" TEXT,
|
||||
"error_message" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "google_play_access_audit_logs_pkey" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
} catch (err) {
|
||||
console.warn("Could not ensure viewer client tables:", err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,18 @@ const configSchema = z.object({
|
|||
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
|
||||
NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90),
|
||||
HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(true),
|
||||
/** Google Cloud Identity Groups — Closed Testing access control */
|
||||
GOOGLE_CLOUD_PROJECT_ID: z.string().optional(),
|
||||
GOOGLE_SERVICE_ACCOUNT_EMAIL: z.string().email().optional(),
|
||||
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: z.string().optional(),
|
||||
GOOGLE_WORKSPACE_CUSTOMER_ID: z.string().optional(),
|
||||
GOOGLE_WORKSPACE_ADMIN_EMAIL: z.string().email().optional(),
|
||||
PLAY_ACCESS_GROUP_ID: z.string().optional(),
|
||||
PLAY_ACCESS_GROUP_EMAIL: z.string().email().optional(),
|
||||
/** Public Closed Testing opt-in URL (shown in admin UI) */
|
||||
GOOGLE_PLAY_OPT_IN_URL: z.string().url().optional(),
|
||||
/** Periodic reconciliation interval (ms); 0 = disabled. Default: 24h */
|
||||
GOOGLE_PLAY_SYNC_INTERVAL_MS: z.coerce.number().default(86_400_000),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
|
|
|
|||
28
apps/master-api/src/google-play/audit.ts
Normal file
28
apps/master-api/src/google-play/audit.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
148
apps/master-api/src/google-play/client.ts
Normal file
148
apps/master-api/src/google-play/client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
23
apps/master-api/src/google-play/email.ts
Normal file
23
apps/master-api/src/google-play/email.ts
Normal 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);
|
||||
}
|
||||
84
apps/master-api/src/google-play/routes.ts
Normal file
84
apps/master-api/src/google-play/routes.ts
Normal 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;
|
||||
}
|
||||
108
apps/master-api/src/google-play/service.test.ts
Normal file
108
apps/master-api/src/google-play/service.test.ts
Normal 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);
|
||||
});
|
||||
482
apps/master-api/src/google-play/service.ts
Normal file
482
apps/master-api/src/google-play/service.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
import { AppError } from "../security/errors";
|
||||
import { requireAdmin } from "../auth/routes";
|
||||
import { requireAdmin, type AuthenticatedRequest } from "../auth/routes";
|
||||
import { PlaybackService } from "../playback/service";
|
||||
import type { Config } from "../config";
|
||||
import { ViewerService } from "./service";
|
||||
import type { DownloadService } from "../downloads/service";
|
||||
import type { SubtitleSource } from "../opensubtitles/client";
|
||||
import { GooglePlayAccessService } from "../google-play/service";
|
||||
|
||||
export function registerViewerRoutes(
|
||||
app: FastifyInstance,
|
||||
|
|
@ -14,6 +15,7 @@ export function registerViewerRoutes(
|
|||
) {
|
||||
const playback = new PlaybackService(config);
|
||||
const viewers = new ViewerService(config, playback, downloads);
|
||||
const googlePlay = new GooglePlayAccessService(config);
|
||||
|
||||
// ——— Admin: kijkers ———
|
||||
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
|
||||
|
|
@ -45,6 +47,13 @@ export function registerViewerRoutes(
|
|||
password?: string;
|
||||
};
|
||||
const viewer = await viewers.updateViewer(id, body);
|
||||
if (body.enabled === false) {
|
||||
const admin = (request as AuthenticatedRequest).user;
|
||||
await googlePlay.onViewerDisabled(id, {
|
||||
adminUserId: admin.id,
|
||||
adminEmail: admin.email,
|
||||
});
|
||||
}
|
||||
return { viewer };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ export class ViewerService {
|
|||
name: v.name,
|
||||
enabled: v.enabled,
|
||||
appAccess: v.appAccess,
|
||||
googlePlayEmail: v.googlePlayEmail,
|
||||
googlePlayAccess: v.googlePlayAccess,
|
||||
googlePlayMembershipId: v.googlePlayMembershipId,
|
||||
googlePlaySyncStatus: v.googlePlaySyncStatus,
|
||||
googlePlayLastSyncedAt: v.googlePlayLastSyncedAt,
|
||||
googlePlaySyncError: v.googlePlaySyncError,
|
||||
googlePlayResolvedEmail: v.googlePlayEmail?.trim() || v.email,
|
||||
createdAt: v.createdAt,
|
||||
deviceCount: v._count.devices,
|
||||
devices: v.devices,
|
||||
|
|
|
|||
|
|
@ -21,3 +21,13 @@ PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
|
|||
NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
||||
HIDE_UNAVAILABLE_TITLES=true
|
||||
|
||||
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
|
||||
# GOOGLE_CLOUD_PROJECT_ID=
|
||||
# GOOGLE_SERVICE_ACCOUNT_EMAIL=
|
||||
# GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY=
|
||||
# GOOGLE_WORKSPACE_ADMIN_EMAIL=
|
||||
# PLAY_ACCESS_GROUP_ID=
|
||||
# PLAY_ACCESS_GROUP_EMAIL=
|
||||
# GOOGLE_PLAY_OPT_IN_URL=
|
||||
# GOOGLE_PLAY_SYNC_INTERVAL_MS=86400000
|
||||
|
|
|
|||
459
pnpm-lock.yaml
459
pnpm-lock.yaml
|
|
@ -62,9 +62,9 @@ importers:
|
|||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.6.1
|
||||
fastify:
|
||||
specifier: ^5.2.1
|
||||
version: 5.12.1
|
||||
google-auth-library:
|
||||
specifier: ^9.15.1
|
||||
version: 9.15.1(supports-color@5.5.0)
|
||||
ioredis:
|
||||
specifier: ^5.4.2
|
||||
version: 5.11.1(supports-color@5.5.0)
|
||||
|
|
@ -274,30 +274,12 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fastify/ajv-compiler@4.0.6':
|
||||
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
|
||||
|
||||
'@fastify/cookie@11.1.2':
|
||||
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
|
||||
|
||||
'@fastify/cors@10.1.0':
|
||||
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
|
||||
|
||||
'@fastify/error@4.2.0':
|
||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@5.1.0':
|
||||
resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==}
|
||||
|
||||
'@fastify/forwarded@3.0.2':
|
||||
resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==}
|
||||
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
'@fastify/rate-limit@10.3.0':
|
||||
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
|
||||
|
||||
|
|
@ -523,9 +505,6 @@ packages:
|
|||
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@prisma/client@6.19.3':
|
||||
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
|
@ -576,23 +555,13 @@ packages:
|
|||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ansi-escapes@3.2.0:
|
||||
resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==}
|
||||
|
|
@ -620,17 +589,19 @@ packages:
|
|||
array-flatten@3.0.0:
|
||||
resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
avvio@9.3.0:
|
||||
resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
body-parser@1.20.6:
|
||||
resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -713,10 +684,6 @@ packages:
|
|||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookie@2.0.1:
|
||||
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
|
||||
engines: {node: '>=22'}
|
||||
|
|
@ -760,10 +727,6 @@ packages:
|
|||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
|
|
@ -786,6 +749,9 @@ packages:
|
|||
duplexify@4.1.3:
|
||||
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
|
|
@ -838,6 +804,9 @@ packages:
|
|||
exsolve@1.1.1:
|
||||
resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
external-editor@3.1.0:
|
||||
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -846,36 +815,12 @@ packages:
|
|||
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
fast-decode-uri-component@1.0.1:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-json-stringify@7.0.1:
|
||||
resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==}
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
fast-uri@3.1.6:
|
||||
resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==}
|
||||
|
||||
fast-uri@4.1.3:
|
||||
resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==}
|
||||
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify-plugin@6.0.0:
|
||||
resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==}
|
||||
|
||||
fastify@5.12.1:
|
||||
resolution: {integrity: sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
figures@2.0.0:
|
||||
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -884,10 +829,6 @@ packages:
|
|||
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
find-my-way@9.9.0:
|
||||
resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -904,6 +845,14 @@ packages:
|
|||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
gaxios@6.7.1:
|
||||
resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
gcp-metadata@6.1.1:
|
||||
resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -916,10 +865,22 @@ packages:
|
|||
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
|
||||
hasBin: true
|
||||
|
||||
google-auth-library@9.15.1:
|
||||
resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
google-logging-utils@0.0.2:
|
||||
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
gtoken@7.1.0:
|
||||
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
has-flag@3.0.0:
|
||||
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -936,6 +897,10 @@ packages:
|
|||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -955,14 +920,14 @@ packages:
|
|||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
ipaddr.js@2.5.0:
|
||||
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
is-fullwidth-code-point@2.0.0:
|
||||
resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-wsl@1.1.0:
|
||||
resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -971,14 +936,14 @@ packages:
|
|||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
light-my-request@6.6.0:
|
||||
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
|
@ -1108,10 +1073,6 @@ packages:
|
|||
ohash@2.0.12:
|
||||
resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -1150,16 +1111,6 @@ packages:
|
|||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
pino-abstract-transport@3.0.0:
|
||||
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||
|
||||
pino-std-serializers@7.1.0:
|
||||
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||
|
||||
pino@10.3.1:
|
||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||
hasBin: true
|
||||
|
||||
pkg-types@2.3.1:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
|
|
@ -1177,12 +1128,6 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
process-warning@4.0.1:
|
||||
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||
|
||||
process-warning@5.1.0:
|
||||
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
|
@ -1194,9 +1139,6 @@ packages:
|
|||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -1225,13 +1167,6 @@ packages:
|
|||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
real-require@1.0.0:
|
||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -1240,25 +1175,10 @@ packages:
|
|||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
restore-cursor@2.0.0:
|
||||
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ret@0.5.0:
|
||||
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
router@1.3.8:
|
||||
resolution: {integrity: sha512-461UFH44NtSfIlS83PUg2N7OZo86BC/kB3dY77gJdsODsBhhw7+2uE0tzTINxrY9CahCUVk1VhpWCA5i1yoIEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -1274,23 +1194,12 @@ packages:
|
|||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safe-regex2@5.1.1:
|
||||
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
||||
hasBin: true
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
semver@5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
|
|
@ -1308,9 +1217,6 @@ packages:
|
|||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
|
|
@ -1337,17 +1243,10 @@ packages:
|
|||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
|
|
@ -1397,10 +1296,6 @@ packages:
|
|||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
thread-stream@4.2.0:
|
||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
|
|
@ -1457,6 +1352,11 @@ packages:
|
|||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -1570,12 +1470,6 @@ snapshots:
|
|||
'@esbuild/win32-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@fastify/ajv-compiler@4.0.6':
|
||||
dependencies:
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 4.1.3
|
||||
|
||||
'@fastify/cookie@11.1.2':
|
||||
dependencies:
|
||||
cookie: 2.0.1
|
||||
|
|
@ -1586,23 +1480,6 @@ snapshots:
|
|||
fastify-plugin: 5.1.0
|
||||
mnemonist: 0.40.0
|
||||
|
||||
'@fastify/error@4.2.0': {}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@5.1.0':
|
||||
dependencies:
|
||||
fast-json-stringify: 7.0.1
|
||||
|
||||
'@fastify/forwarded@3.0.2': {}
|
||||
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
dependencies:
|
||||
'@fastify/forwarded': 3.0.2
|
||||
ipaddr.js: 2.5.0
|
||||
|
||||
'@fastify/rate-limit@10.3.0':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
|
|
@ -1747,8 +1624,6 @@ snapshots:
|
|||
|
||||
'@phc/format@1.0.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)':
|
||||
optionalDependencies:
|
||||
prisma: 6.19.3(typescript@5.9.3)
|
||||
|
|
@ -1806,23 +1681,12 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.6
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ansi-escapes@3.2.0: {}
|
||||
|
||||
|
|
@ -1844,12 +1708,9 @@ snapshots:
|
|||
|
||||
array-flatten@3.0.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
avvio@9.3.0:
|
||||
dependencies:
|
||||
'@fastify/error': 4.2.0
|
||||
fastq: 1.20.1
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
body-parser@1.20.6(supports-color@5.5.0):
|
||||
dependencies:
|
||||
|
|
@ -1868,6 +1729,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
c12@3.1.0:
|
||||
|
|
@ -1945,8 +1808,6 @@ snapshots:
|
|||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cookie@2.0.1: {}
|
||||
|
||||
cors@2.8.6:
|
||||
|
|
@ -1976,8 +1837,6 @@ snapshots:
|
|||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
|
|
@ -2000,6 +1859,10 @@ snapshots:
|
|||
readable-stream: 3.6.2
|
||||
stream-shift: 1.0.3
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
effect@3.21.0:
|
||||
|
|
@ -2096,6 +1959,8 @@ snapshots:
|
|||
|
||||
exsolve@1.1.1: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
external-editor@3.1.0:
|
||||
dependencies:
|
||||
chardet: 0.7.0
|
||||
|
|
@ -2106,53 +1971,10 @@ snapshots:
|
|||
dependencies:
|
||||
pure-rand: 6.1.0
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-json-stringify@7.0.1:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 4.1.3
|
||||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
fast-uri@3.1.6: {}
|
||||
|
||||
fast-uri@4.1.3: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify-plugin@6.0.0: {}
|
||||
|
||||
fastify@5.12.1:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.6
|
||||
'@fastify/error': 4.2.0
|
||||
'@fastify/fast-json-stringify-compiler': 5.1.0
|
||||
'@fastify/proxy-addr': 5.1.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 9.3.0
|
||||
fast-json-stringify: 7.0.1
|
||||
find-my-way: 9.9.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 10.3.1
|
||||
process-warning: 5.1.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.8.5
|
||||
toad-cache: 3.7.4
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
figures@2.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
|
|
@ -2169,12 +1991,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
find-my-way@9.9.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-querystring: 1.1.2
|
||||
safe-regex2: 5.1.1
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
|
@ -2184,6 +2000,26 @@ snapshots:
|
|||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
gaxios@6.7.1(supports-color@5.5.0):
|
||||
dependencies:
|
||||
extend: 3.0.2
|
||||
https-proxy-agent: 7.0.6(supports-color@5.5.0)
|
||||
is-stream: 2.0.1
|
||||
node-fetch: 2.7.0
|
||||
uuid: 9.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
gcp-metadata@6.1.1(supports-color@5.5.0):
|
||||
dependencies:
|
||||
gaxios: 6.7.1(supports-color@5.5.0)
|
||||
google-logging-utils: 0.0.2
|
||||
json-bigint: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
|
@ -2211,8 +2047,30 @@ snapshots:
|
|||
nypm: 0.6.9
|
||||
pathe: 2.0.3
|
||||
|
||||
google-auth-library@9.15.1(supports-color@5.5.0):
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
gaxios: 6.7.1(supports-color@5.5.0)
|
||||
gcp-metadata: 6.1.1(supports-color@5.5.0)
|
||||
gtoken: 7.1.0(supports-color@5.5.0)
|
||||
jws: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
google-logging-utils@0.0.2: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
gtoken@7.1.0(supports-color@5.5.0):
|
||||
dependencies:
|
||||
gaxios: 6.7.1(supports-color@5.5.0)
|
||||
jws: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
has-flag@3.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
|
@ -2229,6 +2087,13 @@ snapshots:
|
|||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
https-proxy-agent@7.0.6(supports-color@5.5.0):
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
|
@ -2265,25 +2130,28 @@ snapshots:
|
|||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
ipaddr.js@2.5.0: {}
|
||||
|
||||
is-fullwidth-code-point@2.0.0: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-wsl@1.1.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
bignumber.js: 9.3.1
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
light-my-request@6.6.0:
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
process-warning: 4.0.1
|
||||
set-cookie-parser: 2.7.2
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
|
|
@ -2374,8 +2242,6 @@ snapshots:
|
|||
|
||||
ohash@2.0.12: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
|
@ -2406,26 +2272,6 @@ snapshots:
|
|||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
pino-abstract-transport@3.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-std-serializers@7.1.0: {}
|
||||
|
||||
pino@10.3.1:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.1.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 4.2.0
|
||||
|
||||
pkg-types@2.3.1:
|
||||
dependencies:
|
||||
confbox: 0.2.4
|
||||
|
|
@ -2447,10 +2293,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
process-warning@4.0.1: {}
|
||||
|
||||
process-warning@5.1.0: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
|
|
@ -2463,8 +2305,6 @@ snapshots:
|
|||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@2.5.3:
|
||||
|
|
@ -2494,29 +2334,17 @@ snapshots:
|
|||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
real-require@1.0.0: {}
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
restore-cursor@2.0.0:
|
||||
dependencies:
|
||||
onetime: 2.0.1
|
||||
signal-exit: 3.0.7
|
||||
|
||||
ret@0.5.0: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
router@1.3.8(supports-color@5.5.0):
|
||||
dependencies:
|
||||
array-flatten: 3.0.0
|
||||
|
|
@ -2537,21 +2365,14 @@ snapshots:
|
|||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-regex2@5.1.1:
|
||||
dependencies:
|
||||
ret: 0.5.0
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
semver@5.7.2: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
semver@7.8.5:
|
||||
optional: true
|
||||
|
||||
send@0.19.2(supports-color@5.5.0):
|
||||
dependencies:
|
||||
|
|
@ -2580,8 +2401,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
|
|
@ -2646,14 +2465,8 @@ snapshots:
|
|||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
|
@ -2705,10 +2518,6 @@ snapshots:
|
|||
dependencies:
|
||||
has-flag: 3.0.0
|
||||
|
||||
thread-stream@4.2.0:
|
||||
dependencies:
|
||||
real-require: 1.0.0
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
tinyexec@1.3.0: {}
|
||||
|
|
@ -2748,6 +2557,8 @@ snapshots:
|
|||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue