Build API v2

This commit is contained in:
NeilClack
2026-08-07 14:06:20 -05:00
parent b2091deae0
commit 64f7070ad7
50 changed files with 25534 additions and 26502 deletions
+1
View File
@@ -6,3 +6,4 @@ GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=
EMAIL_FROM=
RESEND_TEMPLATE_ID=
API_V2_ONBOARDING_SECRET=
+22
View File
@@ -30,3 +30,25 @@ jobs:
run: |
docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:${{ gitea.sha }}
docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest
migrate:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install dependencies
run: npm ci
- name: Run database migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NODE_EXTRA_CA_CERTS: ./digital-ocean.crt
run: npm run deploy
+756
View File
@@ -0,0 +1,756 @@
# RadiantWave Portal API v2 Schema
This document defines the **required** API surface for the RadiantWave kiosk
application and serves as the implementation contract for the portal
(`../portal`).
It supersedes `payloads.md` and `ApiUpdates.md`, which described a v1 API that
does not meet the application's requirements.
---
## Background and constraints
- The kiosk application (`this repo`) talks to
`https://portal.radiantwavetech.com/api/v1`.
- The **existing v1 routes must not change**: previously shipped kiosk binaries
depend on them. The v1 routes and their exact request/response shapes stay
frozen.
- To bring the API into compliance with this application's expectations we
introduce a **new, versioned API under `/api/v2`**, and migrate the kiosk
client to call `/api/v2` instead of `/api/v1`.
- The kiosk client currently uses a single base URL
(`src/network/api_client.h` → `API_BASE_URL`). All endpoint paths are
relative to that base, so migrating the base URL is the only client-side
change required.
Compliance means: every endpoint the kiosk parses must return exactly the
fields the kiosk expects, in the shapes documented below. Deviations break
provisioning, content sync, or rendering.
---
## Versioning strategy
| Route space | Status | Consumer |
|-------------|---------------------------|-------------------------------------------------------|
| `/api/v1/*` | Frozen, never modified | Old/shipped kiosk binaries, GoHighLevel automation |
| `/api/v2/*` | New (this document) | Current kiosk application (after base-URL migration) |
Rules:
1. Never change the behavior or response shape of an existing `/api/v1/*`
route. If a v1 fix is required, fix it in v2.
2. New capabilities ship under `/api/v2` only.
3. A consumer may pin to the major version it was built against. The kiosk
pins to `/api/v2`.
---
## General conventions (v2)
- Base URL: `https://portal.radiantwavetech.com/api/v2`.
- All routes require HTTPS (TLS 1.2+; TLS 1.3 recommended).
- Success responses are JSON with `content-type: application/json`.
- Error bodies use the shape `{"message": "..."}` for route-handler errors and
`{"error": "..."}` for the authentication layer (see Security).
- Any response code outside the documented success code is a failure; the
kiosk surfaces `message`/`error` text when present.
- API keys are sent as `Authorization: Bearer sk_<hex>` and are **required on
every v2 route** except the signed onboarding route (below).
- Credentials (license keys, emails) must **never** be passed in the query
string. v1 accepted `?email=&key=`; v2 must not.
- Request body sizes are capped (see Security); oversized bodies are rejected
with `413`.
---
## Canonical data model: solfeggio frequencies
The kiosk uses a fixed 9-preset frequency scheme. Frequency ids must be
**canonical** — they appear on proclamations and color palettes and must match
the kiosk's own `frequencyIdForHz()` mapping:
| Canonical id | Hz | Frequency (kiosk label) |
|--------------|------|------------------------------|
| 1 | 174 | Grounding / Relief (285 Hz is an alias of id 1) |
| 2 | 396 | Root |
| 3 | 417 | Sacral |
| 4 | 432 | Clarity / Wellbeing |
| 5 | 528 | Solar Plexus (fallback palette) |
| 6 | 639 | Heart |
| 7 | 741 | Throat |
| 8 | 852 | 3rd Eye |
| 9 | 963 | Crown |
**Compliance gap in v1:** the portal's `SolfeggioFrequency` table stores 7
rows whose ids do **not** match this scheme (id 1 = 936 Hz, id 5 = 528 Hz,
id 7 = 396 Hz, etc.). Because proclamations and color palettes are keyed by
frequency id, returning portal ids would make the kiosk look up the wrong
palette (e.g. a 396 Hz proclamation would render with the 741 Hz palette).
**v2 requirement:** every `solfeggioFrequencyId` emitted by v2 must be a
canonical id (1–9) whose `value` resolves through the kiosk map above.
Frequencies that have no canonical id (e.g. 936 Hz) must not be referenced by
any v2 proclamation or palette. See Implementation notes for the reconciling
strategy.
---
## Endpoints
### 1. `GET /api/v2/frequencies`
Lists the canonical solfeggio frequencies.
Request:
```
GET /api/v2/frequencies
Authorization: Bearer sk_<hex>
```
Response (200):
```json
[
{ "id": 1, "name": "Grounding / Relief", "value": 174 },
{ "id": 2, "name": "Root", "value": 396 },
{ "id": 3, "name": "Sacral", "value": 417 },
{ "id": 4, "name": "Clarity / Wellbeing", "value": 432 },
{ "id": 5, "name": "Solar Plexus", "value": 528 },
{ "id": 6, "name": "Heart", "value": 639 },
{ "id": 7, "name": "Throat", "value": 741 },
{ "id": 8, "name": "3rd Eye", "value": 852 },
{ "id": 9, "name": "Crown", "value": 963 }
]
```
Contract (kiosk `parseFrequenciesJson`):
- `id`: int, 1–9, **non-zero** (entries with id 0 are skipped).
- `name`: string.
- `value`: int, Hz. Should be a value the kiosk's `frequencyIdForHz()` map
recognizes (174, 285, 396, 417, 432, 528, 639, 741, 852, 963).
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of frequency objects | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 2. `GET /api/v2/categories`
Lists all proclamation categories (identical shape to v1).
Request:
```
GET /api/v2/categories
Authorization: Bearer sk_<hex>
```
Response (200):
```json
[
{ "id": 4, "name": "Spiritual", "sortOrder": 0, "parentId": null },
{ "id": 5, "name": "General", "sortOrder": 0, "parentId": 4 }
]
```
Contract (kiosk `parseCategoriesJson`):
- `id`: int, **non-zero** (id 0 entries are skipped).
- `name`: string.
- `parentId`: int for children, **`null` for root** categories (kiosk maps
null → 0).
- `sortOrder`: int.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of category objects | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 3. `GET /api/v2/proclamations`
Returns the proclamation library. This is the kiosk's primary content sync.
Request (default: full library):
```
GET /api/v2/proclamations
Authorization: Bearer sk_<hex>
```
Optional entitlement filter (full records, see below):
```
GET /api/v2/proclamations
Authorization: Bearer sk_<hex>
X-License-Key: XXXX-XXXX-XXXX-XXXX
X-Email: user@example.com
```
> Header-based credentials replace the v1 `?email=&key=` query form. Do not
> read credentials from the URL.
Response (200) — every item is a **full proclamation record**:
```json
[
{
"id": "b40fdcb9-703c-420e-af32-a081964c6f08",
"name": "Peace & Relaxation",
"content": "<p>The text content, HTML formatted</p>",
"categoryId": 5,
"solfeggioFrequencyId": 5,
"subscriptionLevelId": 1,
"colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
}
]
```
Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`):
- `id`: string UUID, **required non-empty** (empty ids are skipped).
- `name`: string, used as the display title. Kiosk strips HTML from it.
- `content`: string, **HTML** (the kiosk strips tags and unescapes entities
before rendering). Plain text is also acceptable but HTML is the convention.
- `categoryId`: int, maps to a `/categories` id.
- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)** — see the
canonical model above.
- `subscriptionLevelId`: int or `null`. The kiosk treats missing/null/≤0 as
level 1 (Basic).
- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings, indexed
color 0 (primary) through color 6 (tertiary). When present, the kiosk can
color a proclamation directly from this payload (first proclamation per
frequency wins for palette derivation). When absent, the kiosk falls back to
`/color-palettes`, then to its own 528 Hz seed.
With entitlement headers (`X-License-Key` + `X-Email`):
- Validate the key exists, is active, and belongs to the email's user.
- Return the **full proclamation records** the user may access (matching the
record shape above), **not** raw `UserProclamations` join-table rows.
(v1 returned join rows, which the kiosk parser cannot consume — this is the
primary v1 compliance defect this endpoint fixes.)
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of proclamation records | Success |
| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist |
| 400 | `{"message": "this key doesn't appear to belong to you"}` | Key exists but owned by a different user |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 4. `POST /api/v2/license`
Validates a license key / email pair and returns the full subscription level.
Request:
```
POST /api/v2/license
Content-Type: application/json
Authorization: Bearer sk_<hex>
```
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com"
}
```
Response (200):
```json
{
"key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com",
"subscription_level": {
"id": 2,
"name": "Advanced",
"description": "General + Specific"
}
}
```
`subscription_level` is **always an object** `{ id, name, description }`. If
the license's level name does not match any `SubscriptionLevel` row, fall back
to a structurally consistent object:
```json
{
"key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com",
"subscription_level": { "id": null, "name": "SomeCustomLevel", "description": null }
}
```
Contract (kiosk `parseLicenseJson`):
- `key`: string, **required non-empty**.
- `email_address`: string.
- `subscription_level.name`: string, **required** for a successful validation
(the kiosk persists id/name/description into its local `system_kv` table and
uses the level for proclamation gating and the Home footer).
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | License valid and owned by the email |
| 400 | `{"message": "license_key is required"}` | Missing `license_key` |
| 400 | `{"message": "email_address is required"}` | Missing `email_address` |
| 400 | `{"message": "license key not found"}` | No key with that value |
| 400 | `{"message": "license key is not active"}` | Key exists but inactive |
| 400 | `{"message": "user does not exist"}` | No user with that email |
| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user |
| 401 | `{"error": "..."}` | Missing/invalid API key |
> The kiosk client also tolerates the v1 string form and flat
> `subscription_level_id` / `subscription_level_description` fields. v2 should
> always send the object form; the fallbacks exist only for transitional
> compatibility.
---
### 5. `GET /api/v2/subscription-levels`
Lists the subscription levels.
Request:
```
GET /api/v2/subscription-levels
Authorization: Bearer sk_<hex>
```
Response (200):
```json
[
{ "id": 1, "name": "Basic", "description": "General only" },
{ "id": 2, "name": "Advanced", "description": "General + Specific" },
{ "id": 3, "name": "Elite", "description": "General + Specific + Precise" }
]
```
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of `{id, name, description}` | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 6. `GET /api/v2/color-palettes`
Exposes the 7-color palette for every canonical frequency so the portal admin
can update colors without a kiosk code release.
Request:
```
GET /api/v2/color-palettes
Authorization: Bearer sk_<hex>
```
Response (200) — one entry per canonical frequency (1–9):
```json
[
{
"solfeggioFrequencyId": 1,
"colors": ["#8B6914", "#B8860B", "#DAA520", "#E8B64C", "#FFD700", "#FFE9A8", "#FFF4CC"]
},
{
"solfeggioFrequencyId": 5,
"colors": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
}
]
```
Contract (kiosk `parseColorPalettesJson`):
- `solfeggioFrequencyId`: int, **non-zero**, canonical id.
- `colors`: array of `"#RRGGBB"` (or `"RRGGBB"`) strings, exactly 7.
Kiosk fallback: `db::getPaletteForFrequencyId()` falls back to the 528 Hz
palette for any frequency id with no rows; the startup seed backfills missing
ids with `INSERT OR IGNORE`, so portal values win after a successful sync.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of `{solfeggioFrequencyId, colors}` | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 7. `POST /api/v2/automations/create/account`
Creates (or reuses) a user account and issues a brand-new active license key.
The key is **not returned** — it is emailed to the user. This is the only v2
route that does not require a bearer API key; instead it requires an
**HMAC-SHA256 signature** (see Security). It is used for first-run provisioning
and by the GoHighLevel automation.
Request:
```
POST /api/v2/automations/create/account
Content-Type: application/json
X-Timestamp: <unix seconds>
X-Nonce: <random uuid>
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
```
```json
{
"email": "user@example.com",
"name": "Optional Display Name",
"subscription_level": "Basic"
}
```
- `email` (required, string).
- `name` (optional, string; defaults to the local part of `email`).
- `subscription_level` (required, string) — one of `Basic`, `Advanced`,
`Elite` (must match a `SubscriptionLevel.name`).
Response (200):
```json
{
"success": true,
"message": "Account and license key created",
"user_id": "uuid"
}
```
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | Account (+ license key) created |
| 400 | `{"message": "email is required"}` | Missing/empty `email` |
| 400 | `{"message": "subscription_level is required"}` | Missing/empty `subscription_level` |
| 400 | `{"message": "subscription_level \"<name>\" not found"}` | Unknown level name |
| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature |
| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email |
| 500 | `{"message": "Failed to create account"}` | User creation failed |
| 500 | `{"message": "Failed to find or create user"}` | User could not be located |
Behavior (unchanged from v1):
- If the email already belongs to a user, no new user is created — only a new
license key for that user.
- A new user receives a temporary-password/verify email and a license-key
email. Sending up to two emails, so this endpoint must be rate-limited
aggressively.
---
## Compliance contract (kiosk → v2)
| Kiosk consumer | v2 endpoint | Required fields / shape |
|----------------|-------------|-------------------------|
| `sync::syncProclamationsFromApi` (Home + ProclamationOptions sync) | `GET /proclamations`, `GET /categories` | Full proclamation records; `id`, `name`, `content`, `categoryId`, `solfeggioFrequencyId` (canonical), `subscriptionLevelId` |
| `license::validateStoredLicense` (License Key provisioning page) | `POST /license` | `subscription_level` as `{id, name, description}` object |
| `api::fetchColorPalettes` / `sync::syncColorPalettesFromApi` (readiness, to be wired) | `GET /color-palettes` | `[{solfeggioFrequencyId, colors[7]}]` |
| `sync::syncProclamationPalettesFromApi` (readiness, to be wired) | `GET /proclamations` | Embedded `colorPalette` array per proclamation |
| `api::fetchFrequencies` (readiness) | `GET /frequencies` | Canonical id 1–9 + `value` in Hz |
| Future level-picker UI | `GET /subscription-levels` | `{id, name, description}` |
| Provisioning / GHL automation | `POST /automations/create/account` | `email`, `subscription_level`, optional `name` |
---
## Implementation instructions (portal, `../portal`)
### Route layout
Create the v2 route tree alongside the frozen v1 tree:
```
src/routes/api/v2/
frequencies/+server.ts
categories/+server.ts
proclamations/+server.ts
license/+server.ts
subscription-levels/+server.ts
color-palettes/+server.ts
automations/create/account/+server.ts
```
### Auth gate (`src/hooks.server.ts`)
Extend the existing handler so v2 auth is **always enforced** (do not honor
`API_KEYS_ACTIVE` for v2):
```ts
const isV2 = event.url.pathname.startsWith('/api/v2/');
const isSignedOnboarding =
event.url.pathname.startsWith('/api/v2/automations/');
if (isV2 && !isSignedOnboarding) {
// validateApiKey() ALWAYS runs for v2 — no env-var bypass.
const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
const record = await validateApiKey(authHeader.slice(7));
if (!record) {
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
return resolve(event);
}
```
Keep the existing v1 branch untouched.
### Shared serializer (`src/lib/server/v2-serializers.ts`)
Add one module that maps DB rows to the v2 wire format. The critical piece is
frequency-id reconciliation:
```ts
// Canonical id <- Hz (kiosk frequencyIdForHz map).
const CANONICAL_ID_FOR_HZ: Record<number, number> = {
174: 1, 285: 1, 396: 2, 417: 3, 432: 4,
528: 5, 639: 6, 741: 7, 852: 8, 963: 9
};
function canonicalIdForFrequency(freq: { id: number; value: number; name: string }): number | null {
const id = CANONICAL_ID_FOR_HZ[freq.value];
return id ?? null; // e.g. 936 Hz has no canonical id -> excluded from v2
}
export function serializeProclamation(p: Proclamation & { solfeggioFrequency: { value: number } }) {
const solfeggioFrequencyId = CANONICAL_ID_FOR_HZ[p.solfeggioFrequency.value];
if (!solfeggioFrequencyId) return null; // drop proclamations with no canonical frequency
return {
id: p.id,
name: p.name,
content: p.content,
categoryId: p.categoryId,
solfeggioFrequencyId,
subscriptionLevelId: p.subscriptionLevelId,
// optional; read from the palette source below
colorPalette: paletteFor(p.solfeggioFrequency.value) // 7x "#RRGGBB" or omit
};
}
```
Key points:
- Derive the emitted `solfeggioFrequencyId` from the joined frequency's
**Hz value**, never from the DB id. This keeps v1 rows (and their ids)
untouched while v2 emits canonical ids.
- Proclamations whose frequency has no canonical id (e.g. 936 Hz) are excluded
from v2 responses (or re-assigned a canonical frequency in data).
- Add the missing frequencies to the DB so content can be authored for them:
`174, 285, 432, 963` (by `value`). Do not delete or renumber the existing
rows that v1 depends on.
### Color palettes
Add a portal-editable palette source keyed by canonical frequency, for example
a `colorPalette` JSON column on `SolfeggioFrequency` or a
`ColorPalette`/`ColorPaletteColor` table:
```prisma
model ColorPalette {
solfeggioFrequencyId Int @id
colors Json // ["#RRGGBB", ... x7]
}
```
- Seed all 9 canonical palettes to match the kiosk's current seed
(`src/db/database_manager.cpp` → `seedColorPalettes()`).
- `GET /color-palettes` returns one entry per canonical id.
- `GET /proclamations` embeds the palette for the proclamation's canonical
frequency.
### License endpoint
In `license/+server.ts`, resolve the level object like v1 change #1 suggested:
```ts
const level = await prisma.subscriptionLevel.findFirst({
where: { name: storedKey.level }
});
subscription_level = level
? { id: level.id, name: level.name, description: level.description }
: { id: null, name: storedKey.level, description: null };
```
### Proclamations entitlement filter
When `X-License-Key` / `X-Email` headers are present, validate them exactly as
v1 does, but return **full proclamation records** (the user's entitled set via
`UserProclamations` + `Proclamation` include), never join-table rows.
### Tests
- Unit-test the serializers with fixtures matching the kiosk's offline tests
in `tests/test_api_client.cpp` (string vs object `subscription_level`,
`subscriptionLevelId: null`, `parentId: null`, 7-color palettes, canonical
frequency mapping incl. 936 → dropped).
- Live smoke tests against `/api/v2` mirroring the existing v1 live tests.
---
## Security: restricting v2 to the kiosk application
These endpoints should be reachable only from enrolled kiosk devices. v1's
auth could be disabled with `API_KEYS_ACTIVE=false`; v2 must not offer that
bypass. Suggestions below are ordered by impact.
### 1. Mandatory bearer-key auth on every content route
- Every v2 route except the signed onboarding route requires
`Authorization: Bearer sk_<hex>`.
- Enforce it in `hooks.server.ts` unconditionally (no env toggle).
- Keys are generated with `generateApiKey()`, stored **SHA-256 hashed**, and
only the `sk_xxxxxxxxxx` prefix is ever displayed. Never log or return the
plaintext key.
- Use constant-time comparison / lookup by hash (already the case via
`validateApiKey`).
### 2. Per-device credentials (enroll each kiosk)
One shared API key is a single point of failure. Instead:
- Add a `Device` record (id, serial, model, owner, enrolledAt, apiKeyId) and
issue **one unique key per kiosk** at first boot (or at manufacturing).
- Bind the key to the device: include the device id in a custom header
(`X-Device-Id`) and verify the key belongs to that device.
- A compromised key is then revoked per-device without a global rotation, and
the portal can tell which device made each request.
### 3. Least privilege / scoped keys
- Kiosk keys are read-only for content (`/frequencies`, `/categories`,
`/proclamations`, `/color-palettes`, `/subscription-levels`) and may POST to
`/license`.
- Reject any other verb/route combination with 403. Admin and data-mutation
keys are issued separately to portal staff only.
### 4. Rate limiting per key and per IP
- Content GETs: ~60/min per key/IP.
- `/license`: ~10/min per key/IP.
- `/automations/create/account`: ~5/min per IP and ~3/day per email.
- Return `429` with `Retry-After`; the kiosk client should back off and retry.
- Slippery handling for repeated failures (rate-limit on failures too).
### 5. Replay protection / request signing
- The bearer key alone is replayable if captured. Add to the high-risk routes
(and the onboarding route) an HMAC-SHA256 signature over the canonical
request (method, path, `X-Timestamp`, `X-Nonce`, raw body) using a secret
known only to the device and the server.
- Reject when `|now − X-Timestamp| > 300s` or when a nonce has been seen
before. This defeats capture-and-replay attacks over the network.
- Implement the same scheme for the onboarding route using a shared secret
configured on the portal and the GoHighLevel automation (replacing the
current entirely unauthenticated `/automations/*` exemption).
### 6. Never put credentials in URLs
- v1 accepted license keys and emails in the query string, which land in
access logs, proxies, and referrers. v2 reads credentials from the
`Authorization` header and JSON body only.
- Redact `Authorization` values, license keys, and emails in application and
proxy logs.
### 7. Client fingerprinting and bot rejection
- The kiosk sends `User-Agent: RadiantWave-Kiosk/0.1`. Require it (optionally
with a signed `X-Client-Id`) and return `403` for generic browsers, `curl`,
and headless scanners.
- Reject request bodies that are not `application/json` where JSON is
expected; enforce request-size limits (reject with `413`).
- At the proxy layer: require TLS 1.2+, prefer 1.3, set HSTS; consider
restricting v2 to expected deployment geographies and blocking TOR/exit
nodes and known hosting/bot ASNs.
### 8. Input validation and uniform errors
- Validate each body against a strict schema: email ≤ 254 chars and a valid
shape, `license_key` must match `^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`,
`subscription_level` must equal a known level name, `name` ≤ 120 chars.
- Reject embedded HTML/script in string fields.
- Return uniform `{"message": ...}` bodies; never leak stack traces or SQL
errors. Avoid verbose error differences that enable credential enumeration
(see below).
### 9. License / onboarding abuse resistance
- `/license` success/failure is enumerable by design (a fixed key format). At
minimum rate-limit aggressively. Consider returning a generic
`{"message": "invalid credentials"}` (possibly plus a structured `code`
field for the kiosk) so attackers cannot distinguish "user exists" from
"key exists". Keep the specific messages only behind authenticated admin
tooling if they are needed for support.
- `/automations/create/account` mints real license keys and sends email, so
protect it hardest: HMAC signature (above), per-IP and per-email rate
limits, an email-domain/typo check, and a honeypot field. Never expose it
without signature as an unauthenticated "create account" funnel.
### 10. Monitoring, logging, alerting
- Log every v2 request as structured data: timestamp, endpoint, method, status,
key prefix, `X-Device-Id`, IP, latency. Never log full keys, emails, or
bodies.
- Alert on: error-rate spikes, repeated 401s, unexpected `/license` volume,
signup bursts, single-device request floods, and revoked-key usage.
- Automatically suspend a device that shows abusive patterns; require
re-enrollment.
### 11. Key lifecycle
- Rotate keys on a schedule; support the existing `rotateApiKey` 48-hour grace
and emergency revocation per device.
- Auto-expire keys that have not authenticated in N days.
- Consider an authenticated `/api/v2/auth/refresh` (or device re-enrollment)
so a kiosk can obtain a fresh key, turning a leaked key into a self-healing
problem.
---
## Rollout checklist
1. Implement the v2 routes and serializers; add unit + live smoke tests.
2. Ship v2 alongside v1 (both live; v1 frozen).
3. Flip the kiosk base URL: `src/network/api_client.h` →
`https://portal.radiantwavetech.com/api/v2` (already done by this change).
4. Run the kiosk live tests with `RW_LIVE_API_KEY` set:
- `./build/test_api_client`
- `./build/test_license_service`
5. Update portal-admin color palettes and verify a fresh kiosk sync picks them
up (now wired via `/color-palettes`).
6. Keep `/api/v1` online until all deployed kiosks are on the v2 build;
monitor v1 traffic and sunset it only after the fleet is migrated.
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "ColorPalette" (
"solfeggioFrequencyId" INTEGER NOT NULL,
"colors" JSONB NOT NULL,
CONSTRAINT "ColorPalette_pkey" PRIMARY KEY ("solfeggioFrequencyId")
);
+10
View File
@@ -194,3 +194,13 @@ model SubscriptionLevel {
proclamations Proclamation[]
categoryLevels ProclamationCategorySubscriptionLevel[]
}
// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).
// `solfeggioFrequencyId` is the canonical id the kiosk uses (see
// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.
model ColorPalette {
solfeggioFrequencyId Int @id
colors Json // ["#RRGGBB", ... x7]
@@map("ColorPalette")
}
+58 -1
View File
@@ -21,7 +21,55 @@ const solfeggioFrequencies = [
{ id: 4, name: 'Heart', value: 639 },
{ id: 5, name: 'Solar Plexus', value: 528 },
{ id: 6, name: 'Sacral', value: 417 },
{ id: 7, name: 'Root', value: 396 }
{ id: 7, name: 'Root', value: 396 },
// Canonical frequencies missing from the original 7-row set. Kept as
// separate rows (with distinct unique names) so the canonical id mapping
// in v2-serializers.ts can resolve 1-9 without renumbering v1 rows.
{ id: 8, name: 'Grounding / Relief', value: 174 },
{ id: 9, name: 'Relief', value: 285 },
{ id: 10, name: 'Clarity / Wellbeing', value: 432 },
{ id: 11, name: 'Crown 963', value: 963 }
];
// Canonical id -> 7-color palette, matching the kiosk's
// DatabaseManager::seedColorPalettes() (radiantwave-rebuild).
const colorPalettes: { solfeggioFrequencyId: number; colors: string[] }[] = [
{
solfeggioFrequencyId: 1,
colors: ['#8B6914', '#B8860B', '#DAA520', '#E8B64C', '#FFD700', '#FFE9A8', '#FFF4CC']
},
{
solfeggioFrequencyId: 2,
colors: ['#8B0000', '#B22222', '#DC143C', '#FF6347', '#FF8C69', '#FFA500', '#FFDAB9']
},
{
solfeggioFrequencyId: 3,
colors: ['#FF4500', '#FF7F50', '#FF8C00', '#FFA500', '#FFC87F', '#FFDFBA', '#FFEBCD']
},
{
solfeggioFrequencyId: 4,
colors: ['#8B2252', '#CD3278', '#DB7093', '#FF69B4', '#FFB6C1', '#FFC0CB', '#FFE4E1']
},
{
solfeggioFrequencyId: 5,
colors: ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0']
},
{
solfeggioFrequencyId: 6,
colors: ['#00FF7F', '#00CED1', '#40E0D0', '#5F9EA0', '#7FFFD4', '#AFEEEE', '#E0FFFF']
},
{
solfeggioFrequencyId: 7,
colors: ['#00BFFF', '#1E90FF', '#4169E1', '#6495ED', '#87CEEB', '#ADD8E6', '#F0F8FF']
},
{
solfeggioFrequencyId: 8,
colors: ['#4B0082', '#9370DB', '#8A2BE2', '#7B68EE', '#DA70D6', '#D8BFD8', '#E6E6FA']
},
{
solfeggioFrequencyId: 9,
colors: ['#9400D3', '#BA55D3', '#C71585', '#DDA0DD', '#EE82EE', '#FFF0F5', '#FAF0FA']
}
];
const subscriptionLevels = [
@@ -100,6 +148,15 @@ async function main() {
});
}
console.log('Seeding ColorPalette...');
for (const palette of colorPalettes) {
await prisma.colorPalette.upsert({
where: { solfeggioFrequencyId: palette.solfeggioFrequencyId },
create: palette,
update: palette
});
}
console.log('Seed complete.');
}
+2
View File
@@ -1,10 +1,12 @@
import type { Session, User } from 'better-auth';
import type { ApiKeyRecord } from '$lib/server/api-key';
declare global {
namespace App {
interface Locals {
session: Session | null;
user: User | null;
apiKey?: ApiKeyRecord;
}
}
}
+103 -8
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { RequestEvent } from '@sveltejs/kit';
const { getSession } = vi.hoisted(() => ({
getSession: vi.fn()
@@ -8,10 +9,19 @@ const { svelteKitHandler } = vi.hoisted(() => ({
svelteKitHandler: vi.fn()
}));
const { validateApiKeyMock, envMock } = vi.hoisted(() => ({
validateApiKeyMock: vi.fn(),
envMock: { API_KEYS_ACTIVE: undefined as string | undefined }
}));
vi.mock('$app/environment', () => ({
building: false
}));
vi.mock('$env/dynamic/private', () => ({
env: envMock
}));
vi.mock('better-auth/svelte-kit', () => ({
svelteKitHandler
}));
@@ -25,24 +35,33 @@ vi.mock('$lib/auth', () => ({
}));
vi.mock('$lib/server/api-key', () => ({
validateApiKey: vi.fn()
validateApiKey: validateApiKeyMock
}));
import { handle } from './hooks.server';
function createMockEvent(path = '/dashboard') {
type MockRequestEvent = RequestEvent & { resolve: ReturnType<typeof vi.fn> };
function createMockEvent(path = '/dashboard'): MockRequestEvent {
return {
request: new Request(`http://localhost${path}`),
url: new URL(`http://localhost${path}`),
locals: {} as Record<string, unknown>,
resolve: vi.fn()
} as any;
} as unknown as MockRequestEvent;
}
function runHandle(event: MockRequestEvent) {
type HandleArgs = Parameters<typeof handle>[0];
return handle({ event, resolve: event.resolve as unknown as HandleArgs['resolve'] });
}
describe('hooks.server handle', () => {
beforeEach(() => {
getSession.mockReset();
svelteKitHandler.mockReset();
validateApiKeyMock.mockReset();
envMock.API_KEYS_ACTIVE = undefined;
});
it('sets session and user on locals when authenticated', async () => {
@@ -53,7 +72,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(sessionData);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
await runHandle(event);
expect(event.locals.session).toEqual(sessionData.session);
expect(event.locals.user).toEqual(sessionData.user);
@@ -70,7 +89,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(null);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
await runHandle(event);
expect(event.locals.session).toBeUndefined();
expect(event.locals.user).toBeUndefined();
@@ -80,7 +99,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(undefined);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
await runHandle(event);
expect(event.locals.session).toBeUndefined();
expect(event.locals.user).toBeUndefined();
@@ -90,7 +109,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(null);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
await runHandle(event);
expect(svelteKitHandler).toHaveBeenCalledWith(expect.objectContaining({ building: false }));
});
@@ -99,7 +118,7 @@ describe('hooks.server handle', () => {
getSession.mockResolvedValue(null);
const event = createMockEvent();
await handle({ event, resolve: event.resolve });
await runHandle(event);
expect(svelteKitHandler).toHaveBeenCalledWith(
expect.objectContaining({
@@ -109,4 +128,80 @@ describe('hooks.server handle', () => {
})
);
});
describe('v2 auth gate', () => {
it('returns 401 for v2 routes without an Authorization header', async () => {
const event = createMockEvent('/api/v2/proclamations');
const res = await runHandle(event);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'API key required' });
expect(validateApiKeyMock).not.toHaveBeenCalled();
expect(event.resolve).not.toHaveBeenCalled();
});
it('returns 401 for v2 routes with an invalid bearer key', async () => {
validateApiKeyMock.mockResolvedValue(null);
const event = createMockEvent('/api/v2/proclamations');
event.request = new Request('http://localhost/api/v2/proclamations', {
headers: { authorization: 'Bearer sk_invalid' }
});
const res = await runHandle(event);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'Invalid or expired API key' });
expect(validateApiKeyMock).toHaveBeenCalledWith('sk_invalid');
expect(event.resolve).not.toHaveBeenCalled();
});
it('resolves v2 routes with a valid bearer key and sets locals.apiKey', async () => {
validateApiKeyMock.mockResolvedValue({ id: 'key-1', keyPrefix: 'sk_abc123' });
const event = createMockEvent('/api/v2/proclamations');
event.request = new Request('http://localhost/api/v2/proclamations', {
headers: { authorization: 'Bearer sk_abc123def' }
});
await runHandle(event);
expect(event.resolve).toHaveBeenCalled();
expect(event.locals.apiKey).toEqual({ id: 'key-1', keyPrefix: 'sk_abc123' });
expect(validateApiKeyMock).toHaveBeenCalledWith('sk_abc123def');
});
it('always validates the v2 API key even when API_KEYS_ACTIVE would bypass v1', async () => {
envMock.API_KEYS_ACTIVE = 'false';
validateApiKeyMock.mockResolvedValue(null);
const event = createMockEvent('/api/v2/frequencies');
event.request = new Request('http://localhost/api/v2/frequencies', {
headers: { authorization: 'Bearer sk_abc' }
});
const res = await runHandle(event);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'Invalid or expired API key' });
expect(validateApiKeyMock).toHaveBeenCalledWith('sk_abc');
});
it('does not require an API key for signed v2 onboarding routes', async () => {
getSession.mockResolvedValue(null);
const event = createMockEvent('/api/v2/automations/create/account');
await runHandle(event);
expect(validateApiKeyMock).not.toHaveBeenCalled();
expect(svelteKitHandler).toHaveBeenCalled();
});
it('does not apply the v2 gate to non-API routes', async () => {
getSession.mockResolvedValue(null);
const event = createMockEvent('/dashboard');
await runHandle(event);
expect(validateApiKeyMock).not.toHaveBeenCalled();
expect(svelteKitHandler).toHaveBeenCalled();
});
});
});
+28
View File
@@ -5,6 +5,34 @@ import { validateApiKey } from '$lib/server/api-key';
import { env } from '$env/dynamic/private';
export async function handle({ event, resolve }) {
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
// exceptions are the signed onboarding routes (HMAC-protected).
const isV2 = event.url.pathname.startsWith('/api/v2/');
const isSignedOnboarding = event.url.pathname.startsWith('/api/v2/automations/');
if (isV2 && !isSignedOnboarding) {
const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
const key = authHeader.slice(7);
const record = await validateApiKey(key);
if (!record) {
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
event.locals.apiKey = record;
return resolve(event);
}
if (
event.url.pathname.startsWith('/api/v1/') &&
!event.url.pathname.startsWith('/api/v1/automations/')
+24 -19
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -12,83 +13,87 @@
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.ts';
export { Prisma };
export * as $Enums from './enums.ts';
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }
export * as $Enums from './enums.ts'
export * from './enums.ts';
/**
* Model User
*
*/
export type User = Prisma.UserModel;
export type User = Prisma.UserModel
/**
* Model Profile
*
*/
export type Profile = Prisma.ProfileModel;
export type Profile = Prisma.ProfileModel
/**
* Model Address
*
*/
export type Address = Prisma.AddressModel;
export type Address = Prisma.AddressModel
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel;
export type Session = Prisma.SessionModel
/**
* Model Account
*
*/
export type Account = Prisma.AccountModel;
export type Account = Prisma.AccountModel
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel;
export type UserRole = Prisma.UserRoleModel
/**
* Model Verification
*
*/
export type Verification = Prisma.VerificationModel;
export type Verification = Prisma.VerificationModel
/**
* Model Proclamation
*
*/
export type Proclamation = Prisma.ProclamationModel;
export type Proclamation = Prisma.ProclamationModel
/**
* Model UserProclamations
*
*/
export type UserProclamations = Prisma.UserProclamationsModel;
export type UserProclamations = Prisma.UserProclamationsModel
/**
* Model SolfeggioFrequency
*
*/
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
/**
* Model ProclamationCategory
*
*/
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
export type ProclamationCategory = Prisma.ProclamationCategoryModel
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel =
Prisma.ProclamationCategorySubscriptionLevelModel;
export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategorySubscriptionLevelModel
/**
* Model LicenseKey
*
*/
export type LicenseKey = Prisma.LicenseKeyModel;
export type LicenseKey = Prisma.LicenseKeyModel
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel;
export type ApiKey = Prisma.ApiKeyModel
/**
* Model SubscriptionLevel
*
*/
export type SubscriptionLevel = Prisma.SubscriptionLevelModel;
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
/**
* Model ColorPalette
*
*/
export type ColorPalette = Prisma.ColorPaletteModel
+34 -33
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -9,18 +10,18 @@
* 🟢 You can import this file directly.
*/
import * as process from 'node:process';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url));
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from '@prisma/client/runtime/client';
import * as $Enums from './enums.ts';
import * as $Class from './internal/class.ts';
import * as Prisma from './internal/prismaNamespace.ts';
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import * as $Class from "./internal/class.ts"
import * as Prisma from "./internal/prismaNamespace.ts"
export * as $Enums from './enums.ts';
export * from './enums.ts';
export * as $Enums from './enums.ts'
export * from "./enums.ts"
/**
* ## Prisma Client
*
@@ -36,87 +37,87 @@ export * from './enums.ts';
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass();
export type PrismaClient<
LogOpts extends Prisma.LogLevel = never,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>;
export { Prisma };
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel;
export type User = Prisma.UserModel
/**
* Model Profile
*
*/
export type Profile = Prisma.ProfileModel;
export type Profile = Prisma.ProfileModel
/**
* Model Address
*
*/
export type Address = Prisma.AddressModel;
export type Address = Prisma.AddressModel
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel;
export type Session = Prisma.SessionModel
/**
* Model Account
*
*/
export type Account = Prisma.AccountModel;
export type Account = Prisma.AccountModel
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel;
export type UserRole = Prisma.UserRoleModel
/**
* Model Verification
*
*/
export type Verification = Prisma.VerificationModel;
export type Verification = Prisma.VerificationModel
/**
* Model Proclamation
*
*/
export type Proclamation = Prisma.ProclamationModel;
export type Proclamation = Prisma.ProclamationModel
/**
* Model UserProclamations
*
*/
export type UserProclamations = Prisma.UserProclamationsModel;
export type UserProclamations = Prisma.UserProclamationsModel
/**
* Model SolfeggioFrequency
*
*/
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel;
export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
/**
* Model ProclamationCategory
*
*/
export type ProclamationCategory = Prisma.ProclamationCategoryModel;
export type ProclamationCategory = Prisma.ProclamationCategoryModel
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel =
Prisma.ProclamationCategorySubscriptionLevelModel;
export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategorySubscriptionLevelModel
/**
* Model LicenseKey
*
*/
export type LicenseKey = Prisma.LicenseKeyModel;
export type LicenseKey = Prisma.LicenseKeyModel
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel;
export type ApiKey = Prisma.ApiKeyModel
/**
* Model SubscriptionLevel
*
*/
export type SubscriptionLevel = Prisma.SubscriptionLevelModel;
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
/**
* Model ColorPalette
*
*/
export type ColorPalette = Prisma.ColorPaletteModel
+409 -330
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -8,395 +9,473 @@
* 🟢 You can import this file directly.
*/
import type * as runtime from '@prisma/client/runtime/client';
import * as $Enums from './enums.ts';
import type * as Prisma from './internal/prismaNamespace.ts';
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import type * as Prisma from "./internal/prismaNamespace.ts"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
};
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder;
nulls?: Prisma.NullsOrder;
};
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedStringFilter<$PrismaModel>;
_max?: Prisma.NestedStringFilter<$PrismaModel>;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
};
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
mode?: Prisma.QueryMode;
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedIntFilter<$PrismaModel>;
_max?: Prisma.NestedIntFilter<$PrismaModel>;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type JsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
export type JsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type JsonWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedJsonFilter<$PrismaModel>
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringFilter<$PrismaModel> | string;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean;
};
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedStringFilter<$PrismaModel>;
_max?: Prisma.NestedStringFilter<$PrismaModel>;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntFilter<$PrismaModel> | number;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>;
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedBoolFilter<$PrismaModel>;
_max?: Prisma.NestedBoolFilter<$PrismaModel>;
};
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null;
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null;
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>;
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>;
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>;
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>;
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>;
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>;
};
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>;
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null;
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null;
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null;
};
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null;
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null;
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>;
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null;
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>;
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>;
};
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>;
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>;
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>;
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number;
_count?: Prisma.NestedIntFilter<$PrismaModel>;
_avg?: Prisma.NestedFloatFilter<$PrismaModel>;
_sum?: Prisma.NestedIntFilter<$PrismaModel>;
_min?: Prisma.NestedIntFilter<$PrismaModel>;
_max?: Prisma.NestedIntFilter<$PrismaModel>;
};
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>;
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>;
not?: Prisma.NestedFloatFilter<$PrismaModel> | number;
};
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedJsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
+8 -5
View File
@@ -1,12 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {};
export {}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -14,266 +15,297 @@
* model files in the `model` directory!
*/
import * as runtime from '@prisma/client/runtime/index-browser';
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.ts';
export type * from './prismaNamespace.ts';
export type * from '../models.ts'
export type * from './prismaNamespace.ts'
export const Decimal = runtime.Decimal
export const Decimal = runtime.Decimal;
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as new (secret: never) => typeof runtime.DbNull,
JsonNull: runtime.NullTypes.JsonNull as new (secret: never) => typeof runtime.JsonNull,
AnyNull: runtime.NullTypes.AnyNull as new (secret: never) => typeof runtime.AnyNull
};
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull;
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull;
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull;
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Profile: 'Profile',
Address: 'Address',
Session: 'Session',
Account: 'Account',
UserRole: 'UserRole',
Verification: 'Verification',
Proclamation: 'Proclamation',
UserProclamations: 'UserProclamations',
SolfeggioFrequency: 'SolfeggioFrequency',
ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel'
} as const;
User: 'User',
Profile: 'Profile',
Address: 'Address',
Session: 'Session',
Account: 'Account',
UserRole: 'UserRole',
Verification: 'Verification',
Proclamation: 'Proclamation',
UserProclamations: 'UserProclamations',
SolfeggioFrequency: 'SolfeggioFrequency',
ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel',
ColorPalette: 'ColorPalette'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName];
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const);
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export type TransactionIsolationLevel =
(typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel];
export const UserScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
image: 'image',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
roleId: 'roleId'
} as const;
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
image: 'image',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
roleId: 'roleId'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum];
export const ProfileScalarFieldEnum = {
id: 'id',
userId: 'userId',
firstName: 'firstName',
lastName: 'lastName',
phoneNumber: 'phoneNumber',
ghlContactId: 'ghlContactId'
} as const;
id: 'id',
userId: 'userId',
firstName: 'firstName',
lastName: 'lastName',
phoneNumber: 'phoneNumber',
ghlContactId: 'ghlContactId'
} as const
export type ProfileScalarFieldEnum = (typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum]
export type ProfileScalarFieldEnum =
(typeof ProfileScalarFieldEnum)[keyof typeof ProfileScalarFieldEnum];
export const AddressScalarFieldEnum = {
id: 'id',
profileId: 'profileId',
street: 'street',
city: 'city',
state: 'state',
postalCode: 'postalCode',
country: 'country',
type: 'type',
isDefaultMailing: 'isDefaultMailing',
isDefaultBilling: 'isDefaultBilling',
isDefaultShipping: 'isDefaultShipping'
} as const;
id: 'id',
profileId: 'profileId',
street: 'street',
city: 'city',
state: 'state',
postalCode: 'postalCode',
country: 'country',
type: 'type',
isDefaultMailing: 'isDefaultMailing',
isDefaultBilling: 'isDefaultBilling',
isDefaultShipping: 'isDefaultShipping'
} as const
export type AddressScalarFieldEnum = (typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum]
export type AddressScalarFieldEnum =
(typeof AddressScalarFieldEnum)[keyof typeof AddressScalarFieldEnum];
export const SessionScalarFieldEnum = {
id: 'id',
expiresAt: 'expiresAt',
token: 'token',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
ipAddress: 'ipAddress',
userAgent: 'userAgent',
userId: 'userId'
} as const;
id: 'id',
expiresAt: 'expiresAt',
token: 'token',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
ipAddress: 'ipAddress',
userAgent: 'userAgent',
userId: 'userId'
} as const
export type SessionScalarFieldEnum = (typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum]
export type SessionScalarFieldEnum =
(typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum];
export const AccountScalarFieldEnum = {
id: 'id',
accountId: 'accountId',
providerId: 'providerId',
userId: 'userId',
accessToken: 'accessToken',
refreshToken: 'refreshToken',
idToken: 'idToken',
accessTokenExpiresAt: 'accessTokenExpiresAt',
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
scope: 'scope',
password: 'password',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const;
id: 'id',
accountId: 'accountId',
providerId: 'providerId',
userId: 'userId',
accessToken: 'accessToken',
refreshToken: 'refreshToken',
idToken: 'idToken',
accessTokenExpiresAt: 'accessTokenExpiresAt',
refreshTokenExpiresAt: 'refreshTokenExpiresAt',
scope: 'scope',
password: 'password',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type AccountScalarFieldEnum = (typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum]
export type AccountScalarFieldEnum =
(typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum];
export const UserRoleScalarFieldEnum = {
id: 'id',
name: 'name'
} as const;
id: 'id',
name: 'name'
} as const
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
export type UserRoleScalarFieldEnum =
(typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum];
export const VerificationScalarFieldEnum = {
id: 'id',
identifier: 'identifier',
value: 'value',
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const;
id: 'id',
identifier: 'identifier',
value: 'value',
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type VerificationScalarFieldEnum = (typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum]
export type VerificationScalarFieldEnum =
(typeof VerificationScalarFieldEnum)[keyof typeof VerificationScalarFieldEnum];
export const ProclamationScalarFieldEnum = {
id: 'id',
name: 'name',
content: 'content',
solfeggioFrequencyId: 'solfeggioFrequencyId',
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const;
id: 'id',
name: 'name',
content: 'content',
solfeggioFrequencyId: 'solfeggioFrequencyId',
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const
export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
export type ProclamationScalarFieldEnum =
(typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum];
export const UserProclamationsScalarFieldEnum = {
userId: 'userId',
proclamationId: 'proclamationId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const;
userId: 'userId',
proclamationId: 'proclamationId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type UserProclamationsScalarFieldEnum = (typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum]
export type UserProclamationsScalarFieldEnum =
(typeof UserProclamationsScalarFieldEnum)[keyof typeof UserProclamationsScalarFieldEnum];
export const SolfeggioFrequencyScalarFieldEnum = {
id: 'id',
name: 'name',
value: 'value'
} as const;
id: 'id',
name: 'name',
value: 'value'
} as const
export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum]
export type SolfeggioFrequencyScalarFieldEnum =
(typeof SolfeggioFrequencyScalarFieldEnum)[keyof typeof SolfeggioFrequencyScalarFieldEnum];
export const ProclamationCategoryScalarFieldEnum = {
id: 'id',
name: 'name',
sortOrder: 'sortOrder',
parentId: 'parentId'
} as const;
id: 'id',
name: 'name',
sortOrder: 'sortOrder',
parentId: 'parentId'
} as const
export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
export type ProclamationCategoryScalarFieldEnum =
(typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum];
export const ProclamationCategorySubscriptionLevelScalarFieldEnum = {
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const;
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const
export type ProclamationCategorySubscriptionLevelScalarFieldEnum = (typeof ProclamationCategorySubscriptionLevelScalarFieldEnum)[keyof typeof ProclamationCategorySubscriptionLevelScalarFieldEnum]
export type ProclamationCategorySubscriptionLevelScalarFieldEnum =
(typeof ProclamationCategorySubscriptionLevelScalarFieldEnum)[keyof typeof ProclamationCategorySubscriptionLevelScalarFieldEnum];
export const LicenseKeyScalarFieldEnum = {
id: 'id',
key: 'key',
active: 'active',
level: 'level',
userId: 'userId'
} as const;
id: 'id',
key: 'key',
active: 'active',
level: 'level',
userId: 'userId'
} as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
export type LicenseKeyScalarFieldEnum =
(typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum];
export const ApiKeyScalarFieldEnum = {
id: 'id',
keyPrefix: 'keyPrefix',
keyHash: 'keyHash',
createdAt: 'createdAt',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt'
} as const;
id: 'id',
keyPrefix: 'keyPrefix',
keyHash: 'keyHash',
createdAt: 'createdAt',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt'
} as const
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
export type ApiKeyScalarFieldEnum =
(typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum];
export const SubscriptionLevelScalarFieldEnum = {
id: 'id',
name: 'name',
description: 'description'
} as const;
id: 'id',
name: 'name',
description: 'description'
} as const
export type SubscriptionLevelScalarFieldEnum = (typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum]
export const ColorPaletteScalarFieldEnum = {
solfeggioFrequencyId: 'solfeggioFrequencyId',
colors: 'colors'
} as const
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof ColorPaletteScalarFieldEnum]
export type SubscriptionLevelScalarFieldEnum =
(typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum];
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const;
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const JsonNullValueInput = {
JsonNull: JsonNull
} as const
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder];
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
} as const;
default: 'default',
insensitive: 'insensitive'
} as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode];
export const NullsOrder = {
first: 'first',
last: 'last'
} as const;
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const JsonNullValueFilter = {
DbNull: DbNull,
JsonNull: JsonNull,
AnyNull: AnyNull
} as const
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder];
+18 -16
View File
@@ -1,3 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
@@ -7,19 +8,20 @@
*
* 🟢 You can import this file directly.
*/
export type * from './models/User.ts';
export type * from './models/Profile.ts';
export type * from './models/Address.ts';
export type * from './models/Session.ts';
export type * from './models/Account.ts';
export type * from './models/UserRole.ts';
export type * from './models/Verification.ts';
export type * from './models/Proclamation.ts';
export type * from './models/UserProclamations.ts';
export type * from './models/SolfeggioFrequency.ts';
export type * from './models/ProclamationCategory.ts';
export type * from './models/ProclamationCategorySubscriptionLevel.ts';
export type * from './models/LicenseKey.ts';
export type * from './models/ApiKey.ts';
export type * from './models/SubscriptionLevel.ts';
export type * from './commonInputTypes.ts';
export type * from './models/User.ts'
export type * from './models/Profile.ts'
export type * from './models/Address.ts'
export type * from './models/Session.ts'
export type * from './models/Account.ts'
export type * from './models/UserRole.ts'
export type * from './models/Verification.ts'
export type * from './models/Proclamation.ts'
export type * from './models/UserProclamations.ts'
export type * from './models/SolfeggioFrequency.ts'
export type * from './models/ProclamationCategory.ts'
export type * from './models/ProclamationCategorySubscriptionLevel.ts'
export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts'
export type * from './models/SubscriptionLevel.ts'
export type * from './models/ColorPalette.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
import { error } from '@sveltejs/kit';
export const MAX_JSON_BODY_BYTES = 64 * 1024;
export function jsonOk(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'content-type': 'application/json' }
});
}
export function jsonError(
status: number,
message: string,
headers?: Record<string, string>
): Response {
return new Response(JSON.stringify({ message }), {
status,
headers: { 'content-type': 'application/json', ...headers }
});
}
/** Read and parse a JSON request body with a size cap. Throws 413 for
* oversized bodies and 400 for invalid JSON / wrong content type. */
export async function readJsonBody(request: Request): Promise<unknown> {
const contentType = request.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
throw error(400, 'content-type must be application/json');
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (contentLength > MAX_JSON_BODY_BYTES) {
throw error(413, 'request body too large');
}
const raw = await request.text();
if (raw.length === 0) {
throw error(400, 'request body is required');
}
if (raw.length > MAX_JSON_BODY_BYTES) {
throw error(413, 'request body too large');
}
try {
return JSON.parse(raw);
} catch {
throw error(400, 'invalid JSON body');
}
}
/** Build a 429 response with Retry-After set in seconds. */
export function rateLimited(retryAfterMs: number): Response {
const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000));
return jsonError(429, 'rate limit exceeded', { 'retry-after': String(retryAfterSeconds) });
}
export function clientIp(request: Request): string {
const fwd = request.headers.get('x-forwarded-for');
if (fwd) {
const first = fwd.split(',')[0]?.trim();
if (first) return first;
}
return request.headers.get('x-real-ip') ?? 'unknown';
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { rateLimit, _clearRateLimits } from './v2-rate-limit';
describe('rateLimit', () => {
beforeEach(() => {
_clearRateLimits();
});
it('allows requests up to the limit', () => {
for (let i = 0; i < 3; i++) {
const r = rateLimit({ key: 'k', limit: 3, windowMs: 60_000, now: 1000 });
expect(r.ok).toBe(true);
}
});
it('rejects once the limit is exceeded and reports Retry-After', () => {
for (let i = 0; i < 3; i++) {
rateLimit({ key: 'k', limit: 3, windowMs: 60_000, now: 1000 });
}
const r = rateLimit({ key: 'k', limit: 3, windowMs: 60_000, now: 1000 });
expect(r.ok).toBe(false);
expect(r.retryAfterMs).toBeGreaterThan(0);
});
it('treats keys independently', () => {
rateLimit({ key: 'a', limit: 1, windowMs: 60_000, now: 1000 });
expect(rateLimit({ key: 'a', limit: 1, windowMs: 60_000, now: 1000 }).ok).toBe(false);
expect(rateLimit({ key: 'b', limit: 1, windowMs: 60_000, now: 1000 }).ok).toBe(true);
});
it('lets the window expire before allowing again', () => {
for (let i = 0; i < 2; i++) {
rateLimit({ key: 'k', limit: 2, windowMs: 10_000, now: 1000 });
}
expect(rateLimit({ key: 'k', limit: 2, windowMs: 10_000, now: 1000 }).ok).toBe(false);
expect(rateLimit({ key: 'k', limit: 2, windowMs: 10_000, now: 1000 + 10_000 + 1 }).ok).toBe(
true
);
});
});
+36
View File
@@ -0,0 +1,36 @@
// Simple in-memory sliding-window rate limiter, keyed per process. Suitable
// for per-IP / per-email limits on v2 routes; for multi-instance deployments
// this should move to shared storage (e.g. the database or Redis).
type Bucket = { timestamps: number[] };
const buckets = new Map<string, Bucket>();
export function rateLimit(opts: { key: string; limit: number; windowMs: number; now?: number }): {
ok: boolean;
retryAfterMs: number;
} {
const now = opts.now ?? Date.now();
let bucket = buckets.get(opts.key);
if (!bucket) {
bucket = { timestamps: [] };
buckets.set(opts.key, bucket);
}
bucket.timestamps = bucket.timestamps.filter((t) => now - t < opts.windowMs);
if (bucket.timestamps.length >= opts.limit) {
const oldest = bucket.timestamps[0];
const retryAfterMs = Math.max(0, oldest + opts.windowMs - now);
return { ok: false, retryAfterMs };
}
bucket.timestamps.push(now);
return { ok: true, retryAfterMs: 0 };
}
/** Test hook: clears all rate-limit buckets. */
export function _clearRateLimits(): void {
buckets.clear();
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest';
import {
CANONICAL_FREQUENCIES,
CANONICAL_PALETTES,
canonicalIdForFrequency,
serializeCategory,
serializeColorPalette,
serializeLicense,
serializeProclamation,
serializeSubscriptionLevel
} from './v2-serializers';
describe('canonicalIdForFrequency', () => {
it('maps Hz values to canonical ids via the kiosk frequencyIdForHz map', () => {
expect(canonicalIdForFrequency({ value: 174 })).toBe(1);
expect(canonicalIdForFrequency({ value: 285 })).toBe(1);
expect(canonicalIdForFrequency({ value: 396 })).toBe(2);
expect(canonicalIdForFrequency({ value: 417 })).toBe(3);
expect(canonicalIdForFrequency({ value: 432 })).toBe(4);
expect(canonicalIdForFrequency({ value: 528 })).toBe(5);
expect(canonicalIdForFrequency({ value: 639 })).toBe(6);
expect(canonicalIdForFrequency({ value: 741 })).toBe(7);
expect(canonicalIdForFrequency({ value: 852 })).toBe(8);
expect(canonicalIdForFrequency({ value: 963 })).toBe(9);
});
it('returns null for frequencies with no canonical id (e.g. 936 Hz)', () => {
expect(canonicalIdForFrequency({ value: 936 })).toBeNull();
});
});
describe('CANONICAL_FREQUENCIES', () => {
it('emits exactly the 9 canonical frequencies with non-zero ids', () => {
expect(CANONICAL_FREQUENCIES).toHaveLength(9);
for (const freq of CANONICAL_FREQUENCIES) {
expect(freq.id).toBeGreaterThan(0);
expect(typeof freq.name).toBe('string');
expect(canonicalIdForFrequency({ value: freq.value })).toBe(freq.id);
}
});
it('ships a 7-color palette for every canonical id', () => {
for (const freq of CANONICAL_FREQUENCIES) {
const colors = CANONICAL_PALETTES[freq.id];
expect(colors).toBeDefined();
expect(colors).toHaveLength(7);
for (const c of colors) {
expect(c).toMatch(/^#([0-9A-Fa-f]{6})$/);
}
}
});
});
describe('serializeProclamation', () => {
const root396 = { id: 7, name: 'Root', value: 396 };
const crown936 = { id: 1, name: 'Crown', value: 936 };
const solarPlexus528 = { id: 5, name: 'Solar Plexus', value: 528 };
const base = {
id: 'b40fdcb9-703c-420e-af32-a081964c6f08',
name: 'Peace & Relaxation',
content: '<p>The text content, HTML formatted</p>',
categoryId: 5,
subscriptionLevelId: 1
};
it('derives solfeggioFrequencyId from the Hz value, not the DB id', () => {
// DB row id 7 is 396 Hz -> canonical id 2.
const record = serializeProclamation({
...base,
solfeggioFrequency: root396
});
expect(record).toEqual({
...base,
solfeggioFrequencyId: 2
});
});
it('returns null and drops proclamations whose frequency has no canonical id (936 Hz)', () => {
const record = serializeProclamation({
...base,
solfeggioFrequency: crown936
});
expect(record).toBeNull();
});
it('passes through a null subscriptionLevelId (kiosk treats as level 1)', () => {
const record = serializeProclamation({
...base,
subscriptionLevelId: null,
solfeggioFrequency: solarPlexus528
});
expect(record?.subscriptionLevelId).toBeNull();
});
it('embeds a 7-color palette when provided', () => {
const palette = ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0'];
const record = serializeProclamation(
{
...base,
solfeggioFrequency: solarPlexus528
},
palette
);
expect(record?.colorPalette).toEqual(palette);
});
it('omits colorPalette when not provided', () => {
const record = serializeProclamation({
...base,
solfeggioFrequency: solarPlexus528
});
expect(record?.colorPalette).toBeUndefined();
expect(Object.keys(record ?? {})).not.toContain('colorPalette');
});
});
describe('serializeCategory', () => {
it('keeps parentId null for root categories', () => {
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0, parentId: null })).toEqual({
id: 4,
name: 'Spiritual',
sortOrder: 0,
parentId: null
});
});
it('keeps parentId int for children', () => {
expect(serializeCategory({ id: 5, name: 'General', sortOrder: 0, parentId: 4 })).toEqual({
id: 5,
name: 'General',
sortOrder: 0,
parentId: 4
});
});
});
describe('serializeLicense', () => {
it('returns subscription_level as an object when the level row exists', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'Advanced',
level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
});
it('falls back to a structurally consistent object when no level row matches', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'SomeCustomLevel',
level: null
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
});
});
});
describe('serializeSubscriptionLevel', () => {
it('maps rows to {id, name, description}', () => {
expect(
serializeSubscriptionLevel({ id: 1, name: 'Basic', description: 'General only' })
).toEqual({
id: 1,
name: 'Basic',
description: 'General only'
});
});
});
describe('serializeColorPalette', () => {
it('maps rows to {solfeggioFrequencyId, colors}', () => {
const colors = ['#8B6914', '#B8860B', '#DAA520', '#E8B64C', '#FFD700', '#FFE9A8', '#FFF4CC'];
expect(serializeColorPalette({ solfeggioFrequencyId: 1, colors })).toEqual({
solfeggioFrequencyId: 1,
colors
});
});
});
+161
View File
@@ -0,0 +1,161 @@
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
// canonical ids (1-9), never the portal DB autoincrement ids, because
// proclamations and color palettes on the kiosk are keyed by them.
export const CANONICAL_ID_FOR_HZ: Record<number, number> = {
174: 1,
285: 1,
396: 2,
417: 3,
432: 4,
528: 5,
639: 6,
741: 7,
852: 8,
963: 9
};
// The 9 canonical frequencies exactly as the kiosk expects from
// GET /frequencies (id 1-9, non-zero, `value` recognized by frequencyIdForHz).
export const CANONICAL_FREQUENCIES: { id: number; name: string; value: number }[] = [
{ id: 1, name: 'Grounding / Relief', value: 174 },
{ id: 2, name: 'Root', value: 396 },
{ id: 3, name: 'Sacral', value: 417 },
{ id: 4, name: 'Clarity / Wellbeing', value: 432 },
{ id: 5, name: 'Solar Plexus', value: 528 },
{ id: 6, name: 'Heart', value: 639 },
{ id: 7, name: 'Throat', value: 741 },
{ id: 8, name: '3rd Eye', value: 852 },
{ id: 9, name: 'Crown', value: 963 }
];
// Seed palettes per canonical id, matching the kiosk's
// DatabaseManager::seedColorPalettes(). Used to backfill /color-palettes
// when the DB has not been seeded for an id yet.
export const CANONICAL_PALETTES: Record<number, string[]> = {
1: ['#8B6914', '#B8860B', '#DAA520', '#E8B64C', '#FFD700', '#FFE9A8', '#FFF4CC'],
2: ['#8B0000', '#B22222', '#DC143C', '#FF6347', '#FF8C69', '#FFA500', '#FFDAB9'],
3: ['#FF4500', '#FF7F50', '#FF8C00', '#FFA500', '#FFC87F', '#FFDFBA', '#FFEBCD'],
4: ['#8B2252', '#CD3278', '#DB7093', '#FF69B4', '#FFB6C1', '#FFC0CB', '#FFE4E1'],
5: ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0'],
6: ['#00FF7F', '#00CED1', '#40E0D0', '#5F9EA0', '#7FFFD4', '#AFEEEE', '#E0FFFF'],
7: ['#00BFFF', '#1E90FF', '#4169E1', '#6495ED', '#87CEEB', '#ADD8E6', '#F0F8FF'],
8: ['#4B0082', '#9370DB', '#8A2BE2', '#7B68EE', '#DA70D6', '#D8BFD8', '#E6E6FA'],
9: ['#9400D3', '#BA55D3', '#C71585', '#DDA0DD', '#EE82EE', '#FFF0F5', '#FAF0FA']
};
/** Map a SolfeggioFrequency row to its canonical id, or null if it has no
* canonical id (e.g. 936 Hz). */
export function canonicalIdForFrequency(freq: { value: number }): number | null {
return CANONICAL_ID_FOR_HZ[freq.value] ?? null;
}
export type V2Proclamation = {
id: string;
name: string;
content: string;
categoryId: number;
solfeggioFrequencyId: number;
subscriptionLevelId: number | null;
colorPalette?: string[];
};
export type ProclamationWithFrequency = {
id: string;
name: string;
content: string;
categoryId: number;
subscriptionLevelId: number | null;
solfeggioFrequency: { value: number };
};
/** Serialize a DB proclamation row to the v2 wire format. Returns null when
* the proclamation's frequency has no canonical id (dropped from v2). */
export function serializeProclamation(
p: ProclamationWithFrequency,
palette?: string[]
): V2Proclamation | null {
const solfeggioFrequencyId = CANONICAL_ID_FOR_HZ[p.solfeggioFrequency.value];
if (!solfeggioFrequencyId) return null;
const record: V2Proclamation = {
id: p.id,
name: p.name,
content: p.content,
categoryId: p.categoryId,
solfeggioFrequencyId,
subscriptionLevelId: p.subscriptionLevelId
};
if (palette && palette.length > 0) {
record.colorPalette = palette;
}
return record;
}
export type V2Category = {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
};
export function serializeCategory(c: {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
}): V2Category {
return { id: c.id, name: c.name, sortOrder: c.sortOrder, parentId: c.parentId };
}
export type V2SubscriptionLevel = {
id: number;
name: string;
description: string | null;
};
export function serializeSubscriptionLevel(level: {
id: number;
name: string;
description: string | null;
}): V2SubscriptionLevel {
return { id: level.id, name: level.name, description: level.description };
}
export type V2License = {
key: string;
email_address: string;
subscription_level: { id: number | null; name: string; description: string | null };
};
/** Build the POST /license response. `subscription_level` is always an object;
* when no SubscriptionLevel row matches the license's level name, fall back to
* a structurally consistent object with a null id/description. */
export function serializeLicense(opts: {
key: string;
emailAddress: string;
levelName: string;
level: { id: number; name: string; description: string | null } | null;
}): V2License {
return {
key: opts.key,
email_address: opts.emailAddress,
subscription_level: opts.level
? { id: opts.level.id, name: opts.level.name, description: opts.level.description }
: { id: null, name: opts.levelName, description: null }
};
}
export type V2ColorPalette = {
solfeggioFrequencyId: number;
colors: string[];
};
export function serializeColorPalette(palette: {
solfeggioFrequencyId: number;
colors: string[];
}): V2ColorPalette {
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
computeSignature,
buildCanonicalRequest,
verifyRequestSignature,
_clearNonces
} from './v2-signature';
const SECRET = 'test-onboarding-secret';
function signedRequest(opts: {
now?: Date;
timestampOffsetSec?: number;
tamper?: Partial<Record<'signature' | 'nonce' | 'timestamp' | 'body', string>>;
}) {
const method = 'POST';
const path = '/api/v2/automations/create/account';
const timestamp = String(Math.floor((opts.now ?? new Date()).getTime() / 1000));
const nonce = 'a48c0f2e-5f34-4b0b-9a12-3c7d6b8e1f00';
const body = '{"email":"user@example.com","subscription_level":"Basic"}';
let ts = timestamp;
let nonceValue = nonce;
if (opts.tamper?.timestamp) ts = opts.tamper.timestamp;
if (opts.tamper?.nonce) nonceValue = opts.tamper.nonce;
let sig = computeSignature(SECRET, buildCanonicalRequest(method, path, ts, nonceValue, body));
if (opts.tamper?.body)
sig = computeSignature(
SECRET,
buildCanonicalRequest(method, path, ts, nonceValue, opts.tamper.body)
);
if (opts.tamper?.signature) sig = opts.tamper.signature;
const headers = new Headers({
'content-type': 'application/json',
'x-timestamp': ts,
'x-nonce': nonceValue,
'x-signature': sig
});
return { method, path, headers, rawBody: body };
}
describe('verifyRequestSignature', () => {
beforeEach(() => {
_clearNonces();
});
it('accepts a correctly signed request', () => {
const { method, path, headers, rawBody } = signedRequest({});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(result.ok).toBe(true);
});
it('rejects a request with a wrong signature', () => {
const { method, path, headers, rawBody } = signedRequest({
tamper: { signature: 'deadbeef'.repeat(8) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('invalid signature');
});
it('rejects a request signed with the wrong secret', () => {
const { method, path, headers, rawBody } = signedRequest({});
const result = verifyRequestSignature({
method,
path,
headers,
rawBody,
secret: 'other-secret'
});
expect(result.ok).toBe(false);
});
it('rejects missing signature headers', () => {
const { method, path, rawBody } = signedRequest({});
const result = verifyRequestSignature({
method,
path,
headers: new Headers(),
rawBody,
secret: SECRET
});
expect(result.ok).toBe(false);
});
it('rejects an expired timestamp (>300s skew)', () => {
const now = new Date();
const { method, path, headers, rawBody } = signedRequest({
tamper: { timestamp: String(Math.floor(now.getTime() / 1000) - 600) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET, now });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe('expired timestamp');
});
it('accepts a timestamp within the 300s skew', () => {
const now = new Date();
const { method, path, headers, rawBody } = signedRequest({
tamper: { timestamp: String(Math.floor(now.getTime() / 1000) - 120) }
});
const result = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET, now });
expect(result.ok).toBe(true);
});
it('rejects a replayed nonce', () => {
const { method, path, headers, rawBody } = signedRequest({});
expect(verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET }).ok).toBe(
true
);
const replay = verifyRequestSignature({ method, path, headers, rawBody, secret: SECRET });
expect(replay.ok).toBe(false);
if (!replay.ok) expect(replay.reason).toBe('replayed nonce');
});
});
+96
View File
@@ -0,0 +1,96 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export const SIGNATURE_MAX_SKEW_SECONDS = 300;
export function computeSignature(secret: string, canonicalRequest: string): string {
return createHmac('sha256', secret).update(canonicalRequest, 'utf8').digest('hex');
}
/** Canonical request string covered by the HMAC: method, path, timestamp,
* nonce, and the raw body, newline-separated. */
export function buildCanonicalRequest(
method: string,
path: string,
timestamp: string,
nonce: string,
body: string
): string {
return [method, path, timestamp, nonce, body].join('\n');
}
export function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
// In-memory nonce store. Prunes entries older than twice the allowed skew.
const seenNonces = new Map<string, number>();
export function isNonceReplayed(nonce: string, now = Date.now()): boolean {
const seenAt = seenNonces.get(nonce);
if (seenAt === undefined) return false;
// Keep treating it as replayed only while within the replay window; beyond
// that the entry is pruned and the nonce could in theory be reused, but the
// timestamp skew check still bounds it.
return now - seenAt < SIGNATURE_MAX_SKEW_SECONDS * 2 * 1000;
}
export function recordNonce(nonce: string, now = Date.now()): void {
seenNonces.set(nonce, now);
// Opportunistically prune stale entries.
const cutoff = now - SIGNATURE_MAX_SKEW_SECONDS * 2 * 1000;
for (const [key, seenAt] of seenNonces) {
if (seenAt < cutoff) seenNonces.delete(key);
}
}
export type SignatureVerification = { ok: true } | { ok: false; reason: string };
export function verifyRequestSignature(opts: {
method: string;
path: string;
headers: Headers;
rawBody: string;
secret: string;
now?: Date;
}): SignatureVerification {
const { method, path, headers, rawBody, secret } = opts;
const timestamp = headers.get('x-timestamp');
const nonce = headers.get('x-nonce');
const signature = headers.get('x-signature');
if (!timestamp || !nonce || !signature) {
return { ok: false, reason: 'missing signature headers' };
}
const ts = Number(timestamp);
if (!Number.isFinite(ts)) {
return { ok: false, reason: 'invalid timestamp' };
}
const now = (opts.now ?? new Date()).getTime() / 1000;
if (Math.abs(now - ts) > SIGNATURE_MAX_SKEW_SECONDS) {
return { ok: false, reason: 'expired timestamp' };
}
if (isNonceReplayed(nonce)) {
return { ok: false, reason: 'replayed nonce' };
}
const canonical = buildCanonicalRequest(method, path, timestamp, nonce, rawBody);
const expected = computeSignature(secret, canonical);
if (!safeEqual(expected, signature)) {
return { ok: false, reason: 'invalid signature' };
}
recordNonce(nonce);
return { ok: true };
}
/** Test hook: clears the in-memory nonce store. */
export function _clearNonces(): void {
seenNonces.clear();
}
@@ -0,0 +1,175 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { auth } from '$lib/auth';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { env } from '$env/dynamic/private';
import { clientIp, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { verifyRequestSignature } from '$lib/server/v2-signature';
import { MAX_JSON_BODY_BYTES } from '$lib/server/v2-helpers';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function generateLicenseKey(): string {
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
}
function unauthorized(): Response {
return new Response(JSON.stringify({ error: 'invalid signature' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
export const POST: RequestHandler = async ({ request, url }) => {
const secret = env.API_V2_ONBOARDING_SECRET;
if (!secret) {
return unauthorized();
}
const rawBody = await request.text();
if (rawBody.length === 0) {
throw error(400, 'request body is required');
}
if (rawBody.length > MAX_JSON_BODY_BYTES) {
throw error(413, 'request body too large');
}
const verification = verifyRequestSignature({
method: 'POST',
path: url.pathname,
headers: request.headers,
rawBody,
secret
});
if (!verification.ok) {
return unauthorized();
}
const ip = clientIp(request);
const ipLimit = rateLimit({ key: `onboarding:ip:${ip}`, limit: 5, windowMs: 60_000 });
if (!ipLimit.ok) {
return rateLimited(ipLimit.retryAfterMs);
}
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
throw error(400, 'invalid JSON body');
}
const { email, name, subscription_level } = body as Record<string, unknown>;
if (typeof email !== 'string' || !email.trim()) {
error(400, 'email is required');
}
const cleanEmail = email.trim().toLowerCase();
if (cleanEmail.length > 254 || !EMAIL_RE.test(cleanEmail)) {
error(400, 'email is required');
}
if (typeof subscription_level !== 'string' || !subscription_level.trim()) {
error(400, 'subscription_level is required');
}
const emailLimit = rateLimit({
key: `onboarding:email:${cleanEmail}`,
limit: 3,
windowMs: 24 * 60 * 60 * 1000
});
if (!emailLimit.ok) {
return rateLimited(emailLimit.retryAfterMs);
}
const levelName = subscription_level.trim();
const level = await prisma.subscriptionLevel.findFirst({ where: { name: levelName } });
if (!level) {
error(400, `subscription_level "${levelName}" not found`);
}
const displayName =
typeof name === 'string' && name.trim() ? name.trim().slice(0, 120) : cleanEmail.split('@')[0];
let user = await prisma.user.findUnique({ where: { email: cleanEmail } });
if (!user) {
const tempPassword = crypto.randomUUID().replace(/-/g, '').slice(0, 20);
try {
await auth.api.signUpEmail({
body: {
email: cleanEmail,
password: tempPassword,
name: displayName
}
});
user = await prisma.user.findUnique({ where: { email: cleanEmail } });
const verification = await prisma.verification.findFirst({
where: {
identifier: cleanEmail,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' }
});
const verifyUrl = verification
? `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
: `${env.BETTER_AUTH_URL}/login`;
await sendMail({
to: cleanEmail,
subject: 'Your account is ready',
templateId: 'automated-user-registration',
data: {
firstName: displayName,
tempPassword,
verifyUrl,
loginUrl: `${env.BETTER_AUTH_URL}/login`
}
});
} catch (err) {
console.error('Failed to create user:', err);
error(500, 'Failed to create account');
}
}
if (!user) {
error(500, 'Failed to find or create user');
}
const key = generateLicenseKey();
await prisma.licenseKey.create({
data: {
key,
active: true,
level: levelName,
userId: user.id
}
});
try {
await sendMail({
to: cleanEmail,
subject: 'Your Rhemawave License Key',
templateId: 'license-key-delivery',
data: {
firstName: displayName,
license_key: key
}
});
} catch (e) {
console.error('Failed to send license key email:', e);
}
return json({
success: true,
message: 'Account and license key created',
user_id: user.id
});
};
@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { computeSignature, buildCanonicalRequest, _clearNonces } from '$lib/server/v2-signature';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
const { authApi, sendMailMock } = vi.hoisted(() => ({
authApi: { signUpEmail: vi.fn() },
sendMailMock: vi.fn()
}));
vi.mock('$env/dynamic/private', () => ({
env: {
API_V2_ONBOARDING_SECRET: 'test-onboarding-secret',
BETTER_AUTH_URL: 'http://localhost:5173'
}
}));
vi.mock('$lib/auth', () => ({
auth: { api: authApi }
}));
vi.mock('$lib/server/resend', () => ({
sendMail: sendMailMock
}));
vi.mock('$lib/db', () => ({
prisma: {
subscriptionLevel: { findFirst: vi.fn() },
user: { findUnique: vi.fn() },
verification: { findFirst: vi.fn() },
licenseKey: { create: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { POST } from './+server';
type POSTEvent = Parameters<typeof POST>[0];
const SECRET = 'test-onboarding-secret';
const PATH = '/api/v2/automations/create/account';
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockVerification = prisma.verification.findFirst as ReturnType<typeof vi.fn>;
const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
function signedOnboardingRequest(body: object, secret = SECRET): Request {
const method = 'POST';
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomUUID();
const rawBody = JSON.stringify(body);
const signature = computeSignature(
secret,
buildCanonicalRequest(method, PATH, timestamp, nonce, rawBody)
);
return new Request(`http://localhost${PATH}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-timestamp': timestamp,
'x-nonce': nonce,
'x-signature': signature
},
body: rawBody
});
}
async function expectHttpError(promise: unknown, status: number, message: string) {
try {
await promise;
} catch (e) {
expect(isHttpError(e)).toBe(true);
const err = e as { status: number; body: { message: string } };
expect(err.status).toBe(status);
expect(err.body.message).toBe(message);
return;
}
throw new Error('expected handler to throw an HttpError');
}
describe('POST /api/v2/automations/create/account', () => {
beforeEach(() => {
vi.clearAllMocks();
_clearNonces();
_clearRateLimits();
});
it('creates a license key for an existing user without a new signup', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
sendMailMock.mockResolvedValue({});
const res = await POST({
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
success: true,
message: 'Account and license key created',
user_id: 'user-1'
});
expect(authApi.signUpEmail).not.toHaveBeenCalled();
expect(mockLicenseCreate).toHaveBeenCalledTimes(1);
expect(sendMailMock).toHaveBeenCalledTimes(1);
});
it('signs up a new user and sends registration + license emails', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'user-2', email: 'new@example.com' });
mockVerification.mockResolvedValue({ value: 'verify-token' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-2' });
sendMailMock.mockResolvedValue({});
authApi.signUpEmail.mockResolvedValue({});
const res = await POST({
request: signedOnboardingRequest({ email: 'new@example.com', subscription_level: 'Elite' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
success: true,
message: 'Account and license key created',
user_id: 'user-2'
});
expect(authApi.signUpEmail).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ email: 'new@example.com', name: 'new' })
})
);
expect(sendMailMock).toHaveBeenCalledTimes(2);
});
it('rejects a request without a valid signature', async () => {
const req = new Request(`http://localhost${PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', subscription_level: 'Basic' })
});
const res = await POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'invalid signature' });
});
it('rejects a request signed with the wrong secret', async () => {
const res = await POST({
request: signedOnboardingRequest(
{ email: 'user@example.com', subscription_level: 'Basic' },
'wrong-secret'
),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'invalid signature' });
});
it('rejects a missing email', async () => {
await expectHttpError(
POST({
request: signedOnboardingRequest({ subscription_level: 'Basic' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent),
400,
'email is required'
);
});
it('rejects an oversized body with 413', async () => {
const rawBody = JSON.stringify({
email: 'user@example.com',
subscription_level: 'Basic',
name: 'x'.repeat(64 * 1024 + 1)
});
expect(rawBody.length).toBeGreaterThan(64 * 1024);
const req = new Request(`http://localhost${PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: rawBody
});
await expectHttpError(
POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent),
413,
'request body too large'
);
});
it('rejects an unknown subscription level', async () => {
mockLevelFindFirst.mockResolvedValue(null);
await expectHttpError(
POST({
request: signedOnboardingRequest({
email: 'user@example.com',
subscription_level: 'Titanium'
}),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent),
400,
'subscription_level "Titanium" not found'
);
});
it('rate limits repeated requests for the same email', async () => {
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
sendMailMock.mockResolvedValue({});
for (let i = 0; i < 3; i++) {
const res = await POST({
request: signedOnboardingRequest({
email: 'user@example.com',
subscription_level: 'Basic'
}),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(200);
}
const res = await POST({
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
url: new URL(`http://localhost${PATH}`)
} as POSTEvent);
expect(res.status).toBe(429);
expect(await res.json()).toEqual({ message: 'rate limit exceeded' });
});
});
+9
View File
@@ -0,0 +1,9 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { serializeCategory } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const categories = await prisma.proclamationCategory.findMany();
return jsonOk(categories.map(serializeCategory));
};
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { CANONICAL_FREQUENCIES, CANONICAL_PALETTES } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const rows = await prisma.colorPalette.findMany();
const byId = new Map(rows.map((r) => [r.solfeggioFrequencyId, r.colors as unknown as string[]]));
const palettes = CANONICAL_FREQUENCIES.map((f) => ({
solfeggioFrequencyId: f.id,
colors: byId.get(f.id) ?? CANONICAL_PALETTES[f.id]
}));
return jsonOk(palettes);
};
+7
View File
@@ -0,0 +1,7 @@
import type { RequestHandler } from './$types';
import { jsonOk } from '$lib/server/v2-helpers';
import { CANONICAL_FREQUENCIES } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
return jsonOk(CANONICAL_FREQUENCIES);
};
+65
View File
@@ -0,0 +1,65 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { serializeLicense } from '$lib/server/v2-serializers';
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
export const POST: RequestHandler = async ({ request }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `license:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const body = (await readJsonBody(request)) as Record<string, unknown>;
const licenseKey = typeof body.license_key === 'string' ? body.license_key.trim() : '';
const email =
typeof body.email_address === 'string' ? body.email_address.trim().toLowerCase() : '';
if (!licenseKey) {
throw error(400, 'license_key is required');
}
if (!email) {
throw error(400, 'email_address is required');
}
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw error(400, 'email_address is required');
}
if (!LICENSE_KEY_RE.test(licenseKey)) {
throw error(400, 'license key not found');
}
const storedKey = await prisma.licenseKey.findFirst({
where: { key: licenseKey }
});
if (!storedKey) {
throw error(400, 'license key not found');
}
if (!storedKey.active) {
throw error(400, 'license key is not active');
}
const userAccount = await prisma.user.findUnique({ where: { email } });
if (!userAccount) {
throw error(400, 'user does not exist');
}
if (storedKey.userId !== userAccount.id) {
throw error(400, 'key does not belong to specified user');
}
const level = await prisma.subscriptionLevel.findFirst({
where: { name: storedKey.level }
});
return jsonOk(
serializeLicense({
key: storedKey.key,
emailAddress: email,
levelName: storedKey.level,
level
})
);
};
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findFirst: vi.fn() },
user: { findUnique: vi.fn() },
subscriptionLevel: { findFirst: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { POST } from './+server';
type POSTEvent = Parameters<typeof POST>[0];
const mockKeyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
function licenseRequest(body: unknown): Request {
return new Request('http://localhost/api/v2/license', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
}
async function expectHttpError(promise: unknown, status: number, message: string) {
try {
await promise;
} catch (e) {
expect(isHttpError(e)).toBe(true);
const err = e as { status: number; body: { message: string } };
expect(err.status).toBe(status);
expect(err.body.message).toBe(message);
return;
}
throw new Error('expected handler to throw an HttpError');
}
const storedKey = {
id: 'key-1',
key: 'ABCD-EFGH-IJKL-MNOP',
active: true,
level: 'Advanced',
userId: 'user-1'
};
describe('POST /api/v2/license', () => {
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
});
it('returns the subscription_level object for a valid key', async () => {
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLevelFindFirst.mockResolvedValue({
id: 2,
name: 'Advanced',
description: 'General + Specific'
});
const res = await POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
key: storedKey.key,
email_address: 'user@example.com',
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
});
it('falls back to a structurally consistent subscription_level for an unknown level name', async () => {
mockKeyFindFirst.mockResolvedValue({ ...storedKey, level: 'SomeCustomLevel' });
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
mockLevelFindFirst.mockResolvedValue(null);
const res = await POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
key: storedKey.key,
email_address: 'user@example.com',
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
});
});
it('rejects a missing license_key', async () => {
await expectHttpError(
POST({ request: licenseRequest({ email_address: 'user@example.com' }) } as POSTEvent),
400,
'license_key is required'
);
});
it('rejects a missing email_address', async () => {
await expectHttpError(
POST({ request: licenseRequest({ license_key: storedKey.key }) } as POSTEvent),
400,
'email_address is required'
);
});
it('rejects an unknown key', async () => {
mockKeyFindFirst.mockResolvedValue(null);
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent),
400,
'license key not found'
);
});
it('rejects an inactive key', async () => {
mockKeyFindFirst.mockResolvedValue({ ...storedKey, active: false });
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
} as POSTEvent),
400,
'license key is not active'
);
});
it('rejects a nonexistent user', async () => {
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue(null);
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'nobody@example.com' })
} as POSTEvent),
400,
'user does not exist'
);
});
it('rejects a key owned by a different user', async () => {
mockKeyFindFirst.mockResolvedValue(storedKey);
mockUserFindUnique.mockResolvedValue({ id: 'user-2', email: 'other@example.com' });
await expectHttpError(
POST({
request: licenseRequest({ license_key: storedKey.key, email_address: 'other@example.com' })
} as POSTEvent),
400,
'key does not belong to specified user'
);
});
it('rejects a malformed license key format', async () => {
await expectHttpError(
POST({
request: licenseRequest({ license_key: 'not-a-key', email_address: 'user@example.com' })
} as POSTEvent),
400,
'license key not found'
);
expect(mockKeyFindFirst).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,72 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import {
canonicalIdForFrequency,
serializeProclamation,
type ProclamationWithFrequency
} from '$lib/server/v2-serializers';
const PROCLAMATION_INCLUDE = { solfeggioFrequency: true } as const;
export const GET: RequestHandler = async ({ request }) => {
const licenseKey = request.headers.get('x-license-key') ?? '';
const email = request.headers.get('x-email') ?? '';
const hasEntitlementHeaders = licenseKey !== '' || email !== '';
let proclamations: ProclamationWithFrequency[];
if (hasEntitlementHeaders) {
if (licenseKey === '' || email === '') {
throw error(400, 'not-authorized');
}
const verifiedKey = await prisma.licenseKey.findUnique({
where: { key: licenseKey }
});
if (!verifiedKey) {
throw error(400, 'not-authorized');
}
const existingUser = await prisma.user.findUnique({
where: { email },
include: { licenseKeys: true }
});
if (!existingUser) {
throw error(400, 'not-authorized');
}
const owned = existingUser.licenseKeys.some((k) => k.key === verifiedKey.key);
if (!owned) {
throw error(400, "this key doesn't appear to belong to you");
}
const userProclamations = await prisma.userProclamations.findMany({
where: { userId: existingUser.id },
include: { proclamation: { include: PROCLAMATION_INCLUDE } }
});
proclamations = userProclamations.map((up) => up.proclamation);
} else {
proclamations = await prisma.proclamation.findMany({
include: PROCLAMATION_INCLUDE
});
}
const paletteRows = await prisma.colorPalette.findMany();
const palettesByCanonicalId = new Map(
paletteRows.map((p) => [p.solfeggioFrequencyId, p.colors as unknown as string[]])
);
const records = proclamations
.map((p) => {
const canonicalId = canonicalIdForFrequency(p.solfeggioFrequency);
const palette = canonicalId ? palettesByCanonicalId.get(canonicalId) : undefined;
return serializeProclamation(p, palette);
})
.filter((r) => r !== null);
return jsonOk(records);
};
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
vi.mock('$lib/db', () => ({
prisma: {
proclamation: { findMany: vi.fn() },
licenseKey: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
userProclamations: { findMany: vi.fn() },
colorPalette: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { GET } from './+server';
type GETEvent = Parameters<typeof GET>[0];
const mockProclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const mockLicenseKey = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockUser = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockUserProclamations = prisma.userProclamations.findMany as ReturnType<typeof vi.fn>;
const mockColorPalette = prisma.colorPalette.findMany as ReturnType<typeof vi.fn>;
const palettes = [
{
solfeggioFrequencyId: 5,
colors: ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0']
},
{
solfeggioFrequencyId: 7,
colors: ['#00BFFF', '#1E90FF', '#4169E1', '#6495ED', '#87CEEB', '#ADD8E6', '#F0F8FF']
}
];
const peaceProclamation = {
id: 'b40fdcb9-703c-420e-af32-a081964c6f08',
name: 'Peace & Relaxation',
content: '<p>The text content</p>',
categoryId: 5,
subscriptionLevelId: 1,
solfeggioFrequencyId: 5,
solfeggioFrequency: { id: 5, name: 'Solar Plexus', value: 528 }
};
const crown936 = {
id: '936-hz-proclamation',
name: 'Crown 936',
content: '<p>no canonical id</p>',
categoryId: 4,
subscriptionLevelId: 1,
solfeggioFrequencyId: 1,
solfeggioFrequency: { id: 1, name: 'Crown', value: 936 }
};
function getRequest(headers: Record<string, string> = {}): Request {
return new Request('http://localhost/api/v2/proclamations', { headers });
}
async function expectHttpError(promise: unknown, status: number, message: string) {
try {
await promise;
} catch (e) {
expect(isHttpError(e)).toBe(true);
const err = e as { status: number; body: { message: string } };
expect(err.status).toBe(status);
expect(err.body.message).toBe(message);
return;
}
throw new Error('expected handler to throw an HttpError');
}
describe('GET /api/v2/proclamations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns full proclamation records with canonical ids and embedded palettes', async () => {
mockProclamationFindMany.mockResolvedValue([peaceProclamation]);
mockColorPalette.mockResolvedValue(palettes);
const res = await GET({ request: getRequest() } as GETEvent);
expect(res.status).toBe(200);
expect(await res.json()).toEqual([
{
id: peaceProclamation.id,
name: peaceProclamation.name,
content: peaceProclamation.content,
categoryId: 5,
solfeggioFrequencyId: 5,
subscriptionLevelId: 1,
colorPalette: palettes[0].colors
}
]);
});
it('drops proclamations whose frequency has no canonical id (936 Hz)', async () => {
mockProclamationFindMany.mockResolvedValue([peaceProclamation, crown936]);
mockColorPalette.mockResolvedValue(palettes);
const res = await GET({ request: getRequest() } as GETEvent);
const body = (await res.json()) as { id: string }[];
expect(body).toHaveLength(1);
expect(body[0].id).toBe(peaceProclamation.id);
});
it('returns the entitled proclamations as full records when headers are supplied', async () => {
mockLicenseKey.mockResolvedValue({ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' });
mockUser.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
licenseKeys: [{ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' }]
});
mockUserProclamations.mockResolvedValue([
{ userId: 'user-1', proclamationId: peaceProclamation.id, proclamation: peaceProclamation }
]);
mockColorPalette.mockResolvedValue(palettes);
const res = await GET({
request: getRequest({
'x-license-key': 'ABCD-EFGH-IJKL-MNOP',
'x-email': 'user@example.com'
})
} as GETEvent);
expect(res.status).toBe(200);
const body = (await res.json()) as { id: string }[];
expect(body).toHaveLength(1);
expect(body[0].id).toBe(peaceProclamation.id);
expect(mockUserProclamations).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: 'user-1' } })
);
});
it('rejects when only one entitlement header is supplied', async () => {
await expectHttpError(
GET({ request: getRequest({ 'x-license-key': 'ABCD-EFGH-IJKL-MNOP' }) } as GETEvent),
400,
'not-authorized'
);
});
it('rejects an unknown license key', async () => {
mockLicenseKey.mockResolvedValue(null);
await expectHttpError(
GET({
request: getRequest({
'x-license-key': 'UNKNOWN-KEY',
'x-email': 'user@example.com'
})
} as GETEvent),
400,
'not-authorized'
);
});
it('rejects a key that does not belong to the user', async () => {
mockLicenseKey.mockResolvedValue({ id: 'key-2', key: 'OTHER-KEY' });
mockUser.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
licenseKeys: [{ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' }]
});
await expectHttpError(
GET({
request: getRequest({
'x-license-key': 'OTHER-KEY',
'x-email': 'user@example.com'
})
} as GETEvent),
400,
"this key doesn't appear to belong to you"
);
});
});
@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { jsonOk } from '$lib/server/v2-helpers';
import { serializeSubscriptionLevel } from '$lib/server/v2-serializers';
export const GET: RequestHandler = async () => {
const levels = await prisma.subscriptionLevel.findMany({
orderBy: { id: 'asc' }
});
return jsonOk(levels.map(serializeSubscriptionLevel));
};