feat: bring the control plane up to date

Squashes the current state of the internal working tree onto this history.
The two trees had grown apart with no common ancestor, so this is a content
sync rather than a merge, and the published history is preserved rather than
rewritten — a force-push here would break every existing fork and clone to no
benefit.

What lands:

- Waitlist: API route, core module, and migration 0006 alongside game aliases.
- User verification.
- CI, oxfmt config, editor settings.
- Assorted fixes across the API routes and core modules.

The repository's own README, the wordmark and the per-package READMEs are kept
from this side; the internal tree had dropped them and they are what a stranger
arriving here reads first.

The marketing site in the internal tree is deliberately not here. It is a
separate product with its own repo and its own licence, and this repo is the
open one — a closed component does not belong in it regardless of how convenient
the directory looked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wanjohi
2026-08-26 17:48:46 +03:00
parent cb5b6ed1a2
commit 0143849129
26 changed files with 3128 additions and 477 deletions

View File

@@ -1,8 +1,6 @@
import type { Api } from '../../../alchemy.run.ts';
import type { InferEnv } from 'alchemy/Cloudflare';
import { Env } from '@nestri/core/env';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import type { InferEnv } from 'alchemy/Cloudflare';
import { Hono } from 'hono';
import { openAPISpecs } from 'hono-openapi';
import { cors } from 'hono/cors';
@@ -10,6 +8,7 @@ import { HTTPException } from 'hono/http-exception';
import { logger } from 'hono/logger';
import { type ContentfulStatusCode } from 'hono/utils/http-status';
import type { Api } from '../../../alchemy.run.ts';
import { auth } from './middleware/auth.js';
import { AccessTokenApi } from './routes/access-token.js';
import { GameApi } from './routes/game.js';
@@ -19,6 +18,7 @@ import { MachineApi } from './routes/machine.js';
import { PairingCodeApi } from './routes/pairing-code.js';
import { SteamApi } from './routes/steam.js';
import { UserApi } from './routes/user.js';
import { WaitlistApi } from './routes/waitlist.js';
export const app = new Hono();
@@ -30,7 +30,7 @@ app
})
.use(
cors({
origin: () => Env.get().FRONTEND_URL || 'http://localhost:5173',
origin: () => 'http://localhost:5173',
credentials: true
})
)
@@ -45,6 +45,7 @@ const routes = app
.route('/pairing-code', PairingCodeApi.route)
.route('/machine', MachineApi.route)
.route('/access-token', AccessTokenApi.route)
.route('/waitlist', WaitlistApi.route)
.onError((error, c) => {
if (error instanceof VisibleError) {
// eslint-disable-next-line no-console

View File

@@ -16,6 +16,7 @@ import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator
const SyncGameSchema = z.object({
steamAppId: z.number().int(),
name: z.string(),
aliases: z.string().optional(),
type: z.string().optional(),
clientIcon: z.string().optional(),
icon: z.string().optional(),
@@ -62,7 +63,6 @@ const SyncLibrarySchema = z.object({
export namespace GameApi {
export const route = new Hono()
.use(notPublic)
.get(
'/',
describeRoute({
@@ -150,6 +150,7 @@ export namespace GameApi {
)
.post(
'/sync',
notPublic,
adminOnly,
describeRoute({
tags: ['Games'],
@@ -216,6 +217,7 @@ export namespace GameApi {
steamAppId: g.steamAppId,
slug,
name: g.name,
aliases: g.aliases ?? null,
type: g.type ?? null,
clientIcon: g.clientIcon ?? null,
icon: g.icon ?? null,
@@ -341,6 +343,7 @@ export namespace GameApi {
)
.get(
'/:id/download-state',
notPublic,
describeRoute({
tags: ['Games'],
summary: 'Get download states for a game',
@@ -406,6 +409,7 @@ export namespace GameApi {
)
.post(
'/download-state',
notPublic,
machineOrAdmin,
describeRoute({
tags: ['Games'],
@@ -517,6 +521,7 @@ export namespace GameApi {
)
.post(
'/',
notPublic,
adminOnly,
describeRoute({
tags: ['Games'],

View File

@@ -24,6 +24,36 @@ import { adminOnly, ErrorResponses, notPublic, Result, validator } from '../util
*/
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,

View File

@@ -2,6 +2,7 @@ import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Steam } from '@nestri/core/steam/index';
import { LinkedAccount } from '@nestri/core/user/linked-account';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
@@ -9,8 +10,76 @@ import { z } from 'zod';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
export namespace SteamApi {
export const route = new Hono().use(notPublic).post(
'/link',
export const route = new Hono()
.use(notPublic)
.get(
'/linked',
describeRoute({
tags: ['Steam'],
summary: 'Get your linked Steam account',
description: 'The Steam account linked to the authenticated user, or null if none.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z
.union([LinkedAccount.Info, z.null()])
.meta({
description: 'The linked Steam account, or null',
example: Examples.LinkedAccount
})
)
}
},
description: 'Linked Steam account'
},
401: ErrorResponses[401],
429: ErrorResponses[429]
}
}),
async (c) => {
const linked = await LinkedAccount.findSteamByUser(Actor.userID);
return c.json({ data: linked ? LinkedAccount.serialize(linked) : null });
}
)
.post(
'/unlink',
describeRoute({
tags: ['Steam'],
summary: 'Unlink your Steam account',
description: 'Detach the Steam account from the authenticated user.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({ unlinked: z.boolean() })
)
}
},
description: 'Steam account unlinked'
},
401: ErrorResponses[401],
404: ErrorResponses[404],
429: ErrorResponses[429]
}
}),
async (c) => {
const linked = await LinkedAccount.findSteamByUser(Actor.userID);
if (!linked) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No Steam account is linked to this user'
);
}
await LinkedAccount.remove(linked.id);
return c.json({ data: { unlinked: true } });
}
)
.post(
'/link',
describeRoute({
tags: ['Steam'],
summary: 'Link a Steam account',

View File

@@ -1,13 +1,35 @@
import { Actor } from '@nestri/core/actor';
import { Env } from '@nestri/core/env';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { User } from '@nestri/core/user/index';
import { Fingerprint } from '@nestri/core/user/fingerprint';
import { VERIFICATION_TTL_MINUTES, Verification } from '@nestri/core/user/verification';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
const userResponses = {
200: {
content: {
'application/json': {
schema: Result(
User.Info.meta({
description: 'Current user profile',
example: Examples.User
})
)
}
},
description: 'Current user'
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429]
} as const;
export namespace UserApi {
export const route = new Hono()
.use(notPublic)
@@ -17,24 +39,7 @@ export namespace UserApi {
tags: ['User'],
summary: 'Get current user',
description: "Get the authenticated user's profile",
responses: {
200: {
content: {
'application/json': {
schema: Result(
User.Info.meta({
description: 'Current user profile',
example: Examples.User
})
)
}
},
description: 'Current user'
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429]
}
responses: userResponses
}),
async (c) => {
const user = await User.fromID(Actor.userID);
@@ -50,29 +55,247 @@ export namespace UserApi {
return c.json({ data: user });
}
)
.post(
'/email',
describeRoute({
tags: ['User'],
summary: 'Set your email address',
description:
'Attach (or replace) the authenticated users email. Verification is reset until the new address is verified.',
responses: {
...userResponses,
200: {
content: {
'application/json': {
schema: Result(
User.Info.meta({
description: 'The updated user profile',
example: Examples.User
})
)
}
},
description: 'Email updated'
}
}
}),
validator(
'json',
z.object({
email: z.email().meta({
description: 'The new email address',
example: Examples.User.email
})
})
),
async (c) => {
const { email } = c.req.valid('json');
const user = await User.setEmail({
id: Actor.userID,
email,
emailVerified: false
});
return c.json({ data: user });
}
)
.post(
'/email/send-code',
describeRoute({
tags: ['User'],
summary: 'Send an email verification code',
description:
'Create a fresh verification code for the users email. In development and test the code is returned as devCode; production will deliver it by email.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
expiresInMinutes: z.number(),
devCode: z.string().optional().meta({
description: 'The code itself — only in development and test environments'
})
})
)
}
},
description: 'Verification code created'
},
400: ErrorResponses[400],
429: ErrorResponses[429]
}
}),
async (c) => {
const user = await User.fromID(Actor.userID);
if (!user?.email) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_STATE,
'Set an email address before requesting a verification code'
);
}
const code = await Verification.create({
userId: Actor.userID,
kind: 'email'
});
const isProd = Env.get().NODE_ENV === 'production';
return c.json({
data: {
expiresInMinutes: VERIFICATION_TTL_MINUTES,
...(isProd ? {} : { devCode: code })
}
});
}
)
.post(
'/email/verify',
describeRoute({
tags: ['User'],
summary: 'Verify your email address',
description: 'Redeem a verification code to mark the email address as verified.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({ verified: z.boolean() })
)
}
},
description: 'Email verified'
},
400: ErrorResponses[400],
429: ErrorResponses[429]
}
}),
validator(
'json',
z.object({
code: z.string().min(6).max(6).meta({
description: 'The 6-digit code from the email',
example: '123456'
})
})
),
async (c) => {
const { code } = c.req.valid('json');
const result = await Verification.verifyEmail({ userId: Actor.userID, code });
if (result.reason === 'no_active_code') {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_STATE,
'No active verification code — request a new one first'
);
}
if (result.reason === 'wrong_code') {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
'That verification code is not correct'
);
}
return c.json({ data: { verified: true } });
}
)
.get(
'/devices',
describeRoute({
tags: ['User'],
summary: 'List your devices',
description: 'Every SSH key (fingerprint) enrolled to the authenticated user.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.array(Fingerprint.Info).meta({
description: 'Enrolled devices',
example: [Examples.Fingerprint]
})
)
}
},
description: 'Devices'
},
429: ErrorResponses[429]
}
}),
async (c) => {
const rows = await Fingerprint.listByUser(Actor.userID);
return c.json({ data: rows.map((row) => Fingerprint.serialize(row)) });
}
)
.patch(
'/devices/:id',
describeRoute({
tags: ['User'],
summary: 'Rename a device',
description: 'Give one of your SSH devices a human-readable name.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
Fingerprint.Info.meta({
description: 'The renamed device',
example: Examples.Fingerprint
})
)
}
},
description: 'Device renamed'
},
400: ErrorResponses[400],
404: ErrorResponses[404]
}
}),
validator(
'param',
z.object({
id: z.string().meta({
description: 'ID of the device to rename',
example: Examples.Fingerprint.id
})
})
),
validator(
'json',
z.object({
name: z.string().min(1).max(64).nullable().meta({
description: 'The new name (or null to clear it)',
example: 'MacBook Air'
})
})
),
async (c) => {
const { id } = c.req.valid('param');
const { name } = c.req.valid('json');
const userId = Actor.userID;
const device = await Fingerprint.fromID(id);
if (!device || device.userId !== userId) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Device ${id} not found`
);
}
await Fingerprint.updateName({ id, name });
const updated = await Fingerprint.fromID(id);
return c.json({ data: updated ? Fingerprint.serialize(updated) : null });
}
)
.get(
'/:id',
describeRoute({
tags: ['User'],
summary: 'Get user',
description: 'Get a user by their ID',
responses: {
200: {
content: {
'application/json': {
schema: Result(
User.Info.meta({
description: 'User details',
example: Examples.User
})
)
}
},
description: 'User details'
},
400: ErrorResponses[400],
429: ErrorResponses[429]
}
responses: userResponses
}),
validator(
'param',

View File

@@ -0,0 +1,86 @@
import { Examples } from '@nestri/core/examples';
import { Waitlist } from '@nestri/core/waitlist/index';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { adminOnly, 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.
*/
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'
},
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);
}
)
.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() })
);
}

View File

@@ -18,22 +18,29 @@ describe('Index', () => {
describe('Auth middleware', () => {
test('public access to a protected route returns 401', async () => {
const res = await app.request('/games');
const res = await app.request('/library');
expect(res.status).toBe(401);
const body = (await res.json()) as any;
expect(body.type).toBe('authentication');
expect(body.code).toBe('unauthorized');
});
test('game catalog search is public', async () => {
// The search-first TUI browses before it logs in, so the catalog
// must not sit behind auth.
const res = await app.request('/games');
expect(res.status).toBe(200);
});
test('admin token gains access to protected routes', async () => {
const res = await app.request('/games', {
const res = await app.request('/waitlist', {
headers: adminHeaders()
});
expect(res.status).toBe(200);
});
test('wrong admin token is treated as public → 401', async () => {
const res = await app.request('/games', {
const res = await app.request('/library', {
headers: { 'x-nestri-admin-token': 'wrong-secret' }
});
expect(res.status).toBe(401);
@@ -46,7 +53,7 @@ describe('Auth middleware', () => {
// not make the request a server fault. `verify` reports a malformed or
// expired token in `err`, but throws when it cannot reach the auth
// service at all, and that throw used to surface as a 500.
const res = await app.request('/games', {
const res = await app.request('/library', {
headers: { authorization: 'Bearer not-a-real-token' }
});
expect(res.status).toBe(401);
@@ -177,7 +184,13 @@ describe('OpenAPI doc', () => {
expect(paths).toContain('/library');
expect(paths).toContain('/library/sync');
expect(paths).toContain('/steam/link');
expect(paths).toContain('/steam/linked');
expect(paths).toContain('/steam/unlink');
expect(paths).toContain('/user');
expect(paths).toContain('/user/email');
expect(paths).toContain('/user/devices');
expect(paths).toContain('/pairing-code');
expect(paths).toContain('/waitlist');
});
test('doc has security schemes defined', async () => {
@@ -340,7 +353,7 @@ describe('Access tokens', () => {
test('an unknown access token is unauthenticated, not a server error', async () => {
// A `pat_` prefix routes to the database rather than JWT verification.
// A miss there must read as "not signed in", the same as a bad JWT.
const res = await app.request('/games', {
const res = await app.request('/library', {
headers: { authorization: 'Bearer pat_nosuchtokenvalue' }
});
expect(res.status).toBe(401);
@@ -539,4 +552,110 @@ describe('Pairing code routes', () => {
});
expect(res.status).toBe(400);
});
test('GET /pairing-code requires auth', async () => {
const res = await app.request('/pairing-code');
expect(res.status).toBe(401);
});
});
describe('Email routes', () => {
test('POST /user/email requires auth', async () => {
const res = await app.request('/user/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'a@b.com' })
});
expect(res.status).toBe(401);
});
test('POST /user/email rejects a malformed address', async () => {
const res = await app.request('/user/email', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ email: 'not-an-email' })
});
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.type).toBe('validation');
});
test('POST /user/email/send-code requires auth', async () => {
const res = await app.request('/user/email/send-code', { method: 'POST' });
expect(res.status).toBe(401);
});
test('POST /user/email/verify requires auth', async () => {
const res = await app.request('/user/email/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: '123456' })
});
expect(res.status).toBe(401);
});
test('POST /user/email/verify requires a 6-digit code', async () => {
const res = await app.request('/user/email/verify', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ code: '12' })
});
expect(res.status).toBe(400);
});
});
describe('Device routes', () => {
test('GET /user/devices requires auth', async () => {
const res = await app.request('/user/devices');
expect(res.status).toBe(401);
});
test('PATCH /user/devices/:id requires auth', async () => {
const res = await app.request('/user/devices/ufp_whatever', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'MacBook Air' })
});
expect(res.status).toBe(401);
});
});
describe('Steam account routes', () => {
test('GET /steam/linked requires auth', async () => {
const res = await app.request('/steam/linked');
expect(res.status).toBe(401);
});
test('POST /steam/unlink requires auth', async () => {
const res = await app.request('/steam/unlink', { method: 'POST' });
expect(res.status).toBe(401);
});
});
describe('Waitlist routes', () => {
test('POST /waitlist joins without auth', async () => {
const res = await app.request('/waitlist', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'waitlist@example.com' })
});
expect(res.status).toBe(201);
const body = (await res.json()) as any;
expect(body.data.email).toBe('waitlist@example.com');
expect(body.data.source).toBe('machines');
});
test('POST /waitlist rejects a malformed email', async () => {
const res = await app.request('/waitlist', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'nope' })
});
expect(res.status).toBe(400);
});
test('GET /waitlist is admin-only', async () => {
const res = await app.request('/waitlist');
expect(res.status).toBe(403);
});
});