Files
Wanjohi 4a2a4412c2 style: apply the formatter across the tree
The formatter had never been run over these files, so `oxfmt` on a couple of
touched files rewrote two dozen others and buried the real change. Running it
everywhere once makes the next diff mean something.

No behaviour changes: import ordering, line joining, and reindented JSON in
the generated migration snapshots. Both test suites and both typechecks give
the same answers as before, including the two type errors this does not fix.
2026-09-18 23:03:08 +03:00

326 lines
7.6 KiB
TypeScript

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 { Fingerprint } from '@nestri/core/user/fingerprint';
import { User } from '@nestri/core/user/index';
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)
.get(
'/',
describeRoute({
tags: ['User'],
summary: 'Get current user',
description: "Get the authenticated user's profile",
responses: userResponses
}),
async (c) => {
const user = await User.fromID(Actor.userID);
if (!user) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'Authenticated user not found'
);
}
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: userResponses
}),
validator(
'param',
z.object({
id: z.string().meta({
description: 'ID of the user to get',
example: Examples.User.id
})
})
),
async (c) => {
const userID = c.req.valid('param').id;
const user = await User.fromID(userID);
if (!user) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} does not exist`
);
}
return c.json({
data: user
});
}
);
}