mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
refactor(api)!: remove the shared operator secret, and let hosts sync their own
A single secret that turned any request into an operator was the only credential several routes accepted, and it had no caller left: the device pairing it existed for is on hold, and nothing in this tree or any client sent it. What remained was a key that bypassed authentication entirely, required to boot, and checked by nobody. Every route behind it had a better answer available: - Library and game sync move to host credentials. Both took a `userId` in the body, which meant one secret could write into anybody's library. A host now says which of its enrolled users a batch is for, and that claim is checked against the Steam sign-ins it actually holds — one box carries several people's accounts, so the pair is the unit. - Download-state reporting narrows to hosts alone, and the body that could name a different host is gone. Which host is reporting comes from its own credentials, and a body that still names one is refused rather than ignored. - Linking a Steam account is always for the caller. - Creating a game by hand is deleted; syncing already upserts the catalogue. - Reading the waitlist is deleted. Every address on it belongs to someone who has not agreed to anything, and answering it over HTTP made that list something a leaked key could drain. - The pairing-code routes are deleted with the flow they served. The domain module and its table stay, so returning to it is a route file rather than a migration. Nothing in the API now accepts a credential that stands for more than one caller: every request resolves to a specific user or a specific host, which is what lets a route say "the caller's own library" and mean it. BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and `ADMIN_SHARED_SECRET` is no longer read. `POST /games`, `GET /waitlist` and the `/pairing-code` routes are gone; `POST /games/sync` and `POST /library/sync` now require host credentials and take `userId` in the body; `POST /steam/link` no longer accepts `userId`; `POST /games/download-state` no longer accepts `hostId`.
This commit is contained in:
@@ -10,7 +10,7 @@ import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils';
|
||||
import { enrolledUser, ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
|
||||
|
||||
const SyncGameSchema = z.object({
|
||||
steamAppId: z.number().int(),
|
||||
@@ -150,12 +150,12 @@ export namespace GameApi {
|
||||
.post(
|
||||
'/sync',
|
||||
notPublic,
|
||||
adminOnly,
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Batch sync games, library entries, and depots',
|
||||
description:
|
||||
'Bulk upsert games, library entries, and depot info from Steam sync. Admin only.',
|
||||
'Bulk upsert games, library entries and depot info from a Steam sync. Entries land in the caller\u2019s own library; there is no field for naming another user.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -180,13 +180,17 @@ export namespace GameApi {
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
userId: z.string().meta({
|
||||
description: 'Which of the host\u2019s enrolled users this sync is for',
|
||||
example: Examples.User.id
|
||||
}),
|
||||
games: z.array(SyncGameSchema).default([]),
|
||||
library: z.array(SyncLibrarySchema).default([])
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { userId, games, library } = c.req.valid('json');
|
||||
const { games, library } = c.req.valid('json');
|
||||
const userId = await enrolledUser(c.req.valid('json').userId);
|
||||
|
||||
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
|
||||
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
|
||||
@@ -347,7 +351,7 @@ export namespace GameApi {
|
||||
tags: ['Games'],
|
||||
summary: 'Get download states for a game',
|
||||
description:
|
||||
'Returns the per-host download states for a game. Optionally filter by hostId. Protected read route for initial/fallback data; SSH-connected clients use the live SSH snapshot.',
|
||||
'Returns the per-host download states for a game, optionally filtered to one host. This is the recorded state, written by hosts as they report progress; a client holding a live connection to a host has a fresher answer from the host itself.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -409,12 +413,12 @@ export namespace GameApi {
|
||||
.post(
|
||||
'/download-state',
|
||||
notPublic,
|
||||
machineOrAdmin,
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Report a download state change',
|
||||
description:
|
||||
'Update the shared per-host download state for a game. Called by nessh on terminal events (start/verifying/complete/fail). A registered host reports as itself and cannot name another; admin must supply the hostId explicitly.',
|
||||
'Update the shared per-host download state for a game, on terminal events (start/verifying/complete/fail). A host reports as itself \u2014 which host it is comes from its own credentials, and there is no field that could name another.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -437,63 +441,42 @@ export namespace GameApi {
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
hostId: z.string().optional().meta({
|
||||
description:
|
||||
'The nessh host reporting the download. Required for admin callers; ignored for machines, which report as themselves.',
|
||||
example: Examples.GameDownload.hostId
|
||||
}),
|
||||
steamAppId: z.number().int().meta({
|
||||
description: 'Steam application ID',
|
||||
example: Examples.Game.steamAppId
|
||||
}),
|
||||
status: z.enum(GameDownload.Status).meta({
|
||||
description: 'New download status',
|
||||
example: Examples.GameDownload.status
|
||||
}),
|
||||
progressBytes: z.number().int().optional().meta({
|
||||
description: 'Bytes downloaded so far',
|
||||
example: Examples.GameDownload.progressBytes
|
||||
}),
|
||||
totalBytes: z.number().int().optional().meta({
|
||||
description: 'Total bytes to download',
|
||||
example: Examples.GameDownload.totalBytes
|
||||
}),
|
||||
errorMessage: z.string().nullable().optional().meta({
|
||||
description: 'Error message if status is failed',
|
||||
example: null
|
||||
z
|
||||
.object({
|
||||
steamAppId: z.number().int().meta({
|
||||
description: 'Steam application ID',
|
||||
example: Examples.Game.steamAppId
|
||||
}),
|
||||
status: z.enum(GameDownload.Status).meta({
|
||||
description: 'New download status',
|
||||
example: Examples.GameDownload.status
|
||||
}),
|
||||
progressBytes: z.number().int().optional().meta({
|
||||
description: 'Bytes downloaded so far',
|
||||
example: Examples.GameDownload.progressBytes
|
||||
}),
|
||||
totalBytes: z.number().int().optional().meta({
|
||||
description: 'Total bytes to download',
|
||||
example: Examples.GameDownload.totalBytes
|
||||
}),
|
||||
errorMessage: z.string().nullable().optional().meta({
|
||||
description: 'Error message if status is failed',
|
||||
example: null
|
||||
})
|
||||
})
|
||||
})
|
||||
// A body naming a host is refused rather than ignored. It used
|
||||
// to carry one, so a caller that still sends it is saying
|
||||
// something this route no longer honours, and accepting it
|
||||
// quietly would look like it had been.
|
||||
.strict()
|
||||
),
|
||||
async (c) => {
|
||||
const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } =
|
||||
c.req.valid('json');
|
||||
const { steamAppId, status, progressBytes, totalBytes, errorMessage } = c.req.valid('json');
|
||||
|
||||
// A machine reports as itself. Taking the id from the body would
|
||||
// mean any holder of a shared secret could write download state
|
||||
// under any box's id, which is the whole reason boxes register.
|
||||
const actor = Actor.use();
|
||||
let reportingHostId: string;
|
||||
if (actor.type === 'machine') {
|
||||
if (hostId && hostId !== actor.properties.machineID) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'A machine may only report its own download state'
|
||||
);
|
||||
}
|
||||
reportingHostId = actor.properties.machineID;
|
||||
} else {
|
||||
if (!hostId) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||||
'hostId is required when reporting on behalf of a host',
|
||||
'hostId'
|
||||
);
|
||||
}
|
||||
reportingHostId = hostId;
|
||||
}
|
||||
// A host reports as itself, and `machineOnly` is what makes that
|
||||
// the only possibility: with the id read from its credentials
|
||||
// there is no body field to disagree with them.
|
||||
const reportingHostId = Actor.machineID;
|
||||
|
||||
const game = await Game.fromSteamAppID(steamAppId);
|
||||
if (!game) {
|
||||
@@ -517,102 +500,5 @@ export namespace GameApi {
|
||||
data: { downloadId: row.id, download: GameDownload.serialize(row) }
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/',
|
||||
notPublic,
|
||||
adminOnly,
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Create or update a game',
|
||||
description: 'Upsert a game by Steam app ID. Admin only.',
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
Game.Info.meta({
|
||||
description: 'The created or updated game',
|
||||
example: Examples.Game
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Game created or updated'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
steamAppId: z.number().int().meta({
|
||||
description: 'Steam application ID',
|
||||
example: Examples.Game.steamAppId
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'Game title',
|
||||
example: Examples.Game.name
|
||||
}),
|
||||
slug: z.string().optional().meta({
|
||||
description: 'URL-friendly slug',
|
||||
example: Examples.Game.slug
|
||||
}),
|
||||
type: z.string().nullable().optional().meta({
|
||||
description: 'Content type',
|
||||
example: Examples.Game.type
|
||||
}),
|
||||
clientIcon: z.string().nullable().optional().meta({
|
||||
description: 'Steam client icon hash (256×256 square)',
|
||||
example: Examples.Game.clientIcon
|
||||
}),
|
||||
icon: z.string().nullable().optional().meta({
|
||||
description: 'Steam icon hash (32×32)',
|
||||
example: Examples.Game.icon
|
||||
}),
|
||||
shortDescription: z.string().nullable().optional().meta({
|
||||
description: 'Short description',
|
||||
example: Examples.Game.shortDescription
|
||||
}),
|
||||
description: z.string().nullable().optional().meta({
|
||||
description: 'Full description',
|
||||
example: Examples.Game.description
|
||||
}),
|
||||
developers: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Game developers',
|
||||
example: Examples.Game.developers
|
||||
}),
|
||||
publishers: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Game publishers',
|
||||
example: Examples.Game.publishers
|
||||
}),
|
||||
genres: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Game genres',
|
||||
example: Examples.Game.genres
|
||||
}),
|
||||
oslist: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Supported OS list',
|
||||
example: Examples.Game.oslist
|
||||
}),
|
||||
releaseDate: z.string().nullable().optional().meta({
|
||||
description: 'Release date ISO string',
|
||||
example: Examples.Game.releaseDate
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const id = Identifier.ascending('game');
|
||||
const slug =
|
||||
body.slug ??
|
||||
body.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const game = await Game.upsert({ ...body, id, slug });
|
||||
return c.json({ data: game[0] }, 201);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, adminOnly, notPublic, Result, validator } from '../utils';
|
||||
import { enrolledUser, ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
|
||||
|
||||
export namespace LibraryApi {
|
||||
export const route = new Hono()
|
||||
@@ -56,12 +56,12 @@ export namespace LibraryApi {
|
||||
)
|
||||
.post(
|
||||
'/sync',
|
||||
adminOnly,
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Library'],
|
||||
summary: "Sync a user's Steam library",
|
||||
description:
|
||||
'Batch upsert games and library entries for a user from Steam owned games data. Admin only.',
|
||||
'Batch upsert games and library entries from Steam owned games data. The library synced is the caller\u2019s own; there is no field for naming another user.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -86,7 +86,7 @@ export namespace LibraryApi {
|
||||
'json',
|
||||
z.object({
|
||||
userId: z.string().meta({
|
||||
description: 'The user to sync library for',
|
||||
description: 'Which of the host\u2019s enrolled users this library belongs to',
|
||||
example: Examples.User.id
|
||||
}),
|
||||
games: z
|
||||
@@ -121,7 +121,8 @@ export namespace LibraryApi {
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { userId, games } = c.req.valid('json');
|
||||
const { games } = c.req.valid('json');
|
||||
const userId = await enrolledUser(c.req.valid('json').userId);
|
||||
|
||||
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
|
||||
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { PairingCode } from '@nestri/core/pairing-code/index';
|
||||
import { Fingerprint } from '@nestri/core/user/fingerprint';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { adminOnly, ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* Device enrolment.
|
||||
*
|
||||
* A pairing code says "this SSH key is also me". It is deliberately not the
|
||||
* same thing as an invite, which says "you may use my box" — same shape of
|
||||
* secret, completely different authority, and merging them would let one be
|
||||
* redeemed for the other.
|
||||
*
|
||||
* Generating requires an authenticated session; claiming is done by nessh on
|
||||
* behalf of a device that has no identity yet, so it authenticates with the
|
||||
* shared admin token instead.
|
||||
*/
|
||||
export namespace PairingCodeApi {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['PairingCode'],
|
||||
summary: 'List your pairing codes',
|
||||
description: 'Every pairing code the current user has generated, newest first.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(PairingCode.Info).meta({
|
||||
description: 'All pairing codes for the current user',
|
||||
example: [Examples.PairingCode]
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Pairing codes'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const rows = await PairingCode.listByUser(Actor.userID);
|
||||
return c.json({ data: rows.map((row) => PairingCode.serialize(row)) });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['PairingCode'],
|
||||
summary: 'Generate a pairing code',
|
||||
description:
|
||||
'Create a short-lived, single-use code that enrols another SSH key onto the current user.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
code: z.string().meta({ example: Examples.PairingCode.code }),
|
||||
expiresInMinutes: z.number()
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'A freshly generated pairing code'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
ttlMinutes: z.number().int().min(1).max(60).default(10).meta({
|
||||
description: 'How long the code stays valid. Short by design.'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { ttlMinutes } = c.req.valid('json');
|
||||
const code = await PairingCode.create({
|
||||
id: Identifier.ascending('pairingCode'),
|
||||
targetUserId: Actor.userID,
|
||||
ttlMinutes
|
||||
});
|
||||
|
||||
return c.json({ data: { code, expiresInMinutes: ttlMinutes } });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/claim',
|
||||
adminOnly,
|
||||
describeRoute({
|
||||
tags: ['PairingCode'],
|
||||
summary: 'Claim a pairing code for an SSH key',
|
||||
description:
|
||||
'Redeem a code and bind the supplied SSH fingerprint to the user who generated it. Admin only: the calling device has no identity yet, which is the entire point.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
userId: z.string().meta({ example: Examples.PairingCode.targetUserId })
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'The fingerprint now belongs to this user'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
code: z.string().min(1).meta({ example: Examples.PairingCode.code }),
|
||||
fingerprint: z.string().min(1).meta({
|
||||
description: 'SSH public key fingerprint of the device being enrolled'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { code, fingerprint } = c.req.valid('json');
|
||||
|
||||
// Refuse before claiming: a code is single-use, so burning one on
|
||||
// a device that cannot be enrolled would strand the user.
|
||||
const existing = await Fingerprint.findByFingerprint(fingerprint);
|
||||
|
||||
const claimed = await PairingCode.claim({ code, fingerprint });
|
||||
if (!claimed) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'That pairing code is unknown, already used, or expired'
|
||||
);
|
||||
}
|
||||
|
||||
if (existing && existing.userId !== claimed.targetUserId) {
|
||||
// Handing a device between accounts is a different operation
|
||||
// with its own consequences for anything already linked to it;
|
||||
// `Steam.resolveSshIdentity` refuses the same case.
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'That SSH key is already enrolled to another user'
|
||||
);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await Fingerprint.touchLastSeen(existing.id);
|
||||
} else {
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: claimed.targetUserId,
|
||||
fingerprint,
|
||||
name: null
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ data: { userId: claimed.targetUserId } });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export namespace SteamApi {
|
||||
describeRoute({
|
||||
tags: ['Steam'],
|
||||
summary: 'Link a Steam account',
|
||||
description: 'Link a Steam account to a user (admin) or yourself (user)',
|
||||
description: 'Link a Steam account to the calling user.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -113,13 +113,6 @@ export namespace SteamApi {
|
||||
description: 'Steam ID to link',
|
||||
example: '76561197960287930'
|
||||
}),
|
||||
userId: z
|
||||
.string()
|
||||
.optional()
|
||||
.meta({
|
||||
description: 'User ID to link to (admin only; omitted when linking your own account)',
|
||||
example: Examples.Id('user')
|
||||
}),
|
||||
profile: z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
@@ -131,20 +124,14 @@ export namespace SteamApi {
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const actor = Actor.use();
|
||||
|
||||
if (body.userId && actor.type !== 'admin') {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Only admin can link a Steam account for another user'
|
||||
);
|
||||
}
|
||||
|
||||
// Linking is always for the caller. It once accepted a `userId`,
|
||||
// which meant one credential could attach a Steam account to any
|
||||
// user \u2014 and a linked account is how a library is reached.
|
||||
const linkedAccountID = await Steam.link({
|
||||
steamId: body.steamId,
|
||||
profile: body.profile,
|
||||
userId: body.userId
|
||||
userId: Actor.userID
|
||||
});
|
||||
return c.json({
|
||||
data: { linkedAccountId: linkedAccountID, steamId: body.steamId }
|
||||
|
||||
@@ -4,83 +4,58 @@ import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { adminOnly, ErrorResponses, Result, validator } from '../utils';
|
||||
import { ErrorResponses, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* Public signups for not-yet-launched features (the machines waitlist).
|
||||
*
|
||||
* Deliberately unauthenticated: a visitor without an account should be able
|
||||
* to leave an email. The list itself is admin-only so a scraper cannot mine
|
||||
* every address out of the response.
|
||||
* to leave an email. There is no route that reads the list back — every
|
||||
* address on it belongs to someone who has not agreed to anything yet, so it
|
||||
* is answered from the database by whoever is sending the announcement rather
|
||||
* than exposed as a response a scraper could mine.
|
||||
*/
|
||||
export namespace WaitlistApi {
|
||||
export const route = new Hono()
|
||||
.post(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Waitlist'],
|
||||
summary: 'Join the waitlist',
|
||||
description: 'Leave an email to be notified when a feature launches. Public.',
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
Waitlist.Info.meta({
|
||||
description: 'The waitlist entry (the existing one if already joined)',
|
||||
example: Examples.WaitlistEntry
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Joined the waitlist'
|
||||
export const route = new Hono().post(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Waitlist'],
|
||||
summary: 'Join the waitlist',
|
||||
description: 'Leave an email to be notified when a feature launches. Public.',
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
Waitlist.Info.meta({
|
||||
description: 'The waitlist entry (the existing one if already joined)',
|
||||
example: Examples.WaitlistEntry
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
400: ErrorResponses[400]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
email: z.email().meta({
|
||||
description: 'The email to notify',
|
||||
example: Examples.WaitlistEntry.email
|
||||
}),
|
||||
source: z.string().default('machines').meta({
|
||||
description: 'What the signup is for',
|
||||
example: Examples.WaitlistEntry.source
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { email, source } = c.req.valid('json');
|
||||
const entry = await Waitlist.join({ email, source });
|
||||
return c.json({ data: entry }, 201);
|
||||
description: 'Joined the waitlist'
|
||||
},
|
||||
400: ErrorResponses[400]
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/',
|
||||
adminOnly,
|
||||
describeRoute({
|
||||
tags: ['Waitlist'],
|
||||
summary: 'List waitlist entries',
|
||||
description: 'Every email currently on the waitlist. Admin only.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(Waitlist.Info).meta({
|
||||
description: 'All waitlist entries',
|
||||
example: [Examples.WaitlistEntry]
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Waitlist entries'
|
||||
},
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
async (c) => c.json({ data: await Waitlist.list() })
|
||||
);
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
email: z.email().meta({
|
||||
description: 'The email to notify',
|
||||
example: Examples.WaitlistEntry.email
|
||||
}),
|
||||
source: z.string().default('machines').meta({
|
||||
description: 'What the signup is for',
|
||||
example: Examples.WaitlistEntry.source
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { email, source } = c.req.valid('json');
|
||||
const entry = await Waitlist.join({ email, source });
|
||||
return c.json({ data: entry }, 201);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user