mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
207
apps/api/app/routes/access-token.ts
Normal file
207
apps/api/app/routes/access-token.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { AccessToken } from '@nestri/core/access-token/index';
|
||||
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 { Member } from '@nestri/core/team/member';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* Personal access tokens.
|
||||
*
|
||||
* The credential for anything that is not a browser: a nessh box registering
|
||||
* itself, or a script driving the API. A session JWT cannot do this job — it
|
||||
* is short-lived and cannot be revoked without rotating signing keys for
|
||||
* everyone, which is wrong for something that sits in a config file for
|
||||
* months.
|
||||
*
|
||||
* Minting requires a *user session*, deliberately. Allowing the admin token to
|
||||
* mint one for an arbitrary user would turn a credential that can read and
|
||||
* write all API data into one that can *become* any user, and that boundary is
|
||||
* the reason `Actor.userID` refuses admin at all.
|
||||
*/
|
||||
/**
|
||||
* Decide what a new token is scoped to.
|
||||
*
|
||||
* Team scope is the default, because a box or a script is nearly always doing
|
||||
* team work and a user-scoped token silently cannot see any of it. But the
|
||||
* default only applies when it is *unambiguous*: with several teams, guessing
|
||||
* would hand out a token reaching resources the caller did not have in mind.
|
||||
*
|
||||
* Note team scope is broader than user scope, never narrower — hence `null` as
|
||||
* an explicit way to ask for the narrow one. It still cannot exceed what the
|
||||
* user themselves may do: the grant is re-checked against live membership on
|
||||
* every request, with their own role.
|
||||
*
|
||||
* @param requested `undefined` to take the default, `null` to force user
|
||||
* scope, or a team id to name one.
|
||||
*/
|
||||
async function resolveTeamScope(requested: string | null | undefined): Promise<string | null> {
|
||||
if (requested === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requested !== undefined) {
|
||||
// Verified here rather than trusted from the body: a token is only ever
|
||||
// as scoped as what was checked at the moment it was made.
|
||||
const membership = await Member.findByTeamAndUser({
|
||||
teamId: requested,
|
||||
userId: Actor.userID
|
||||
});
|
||||
if (!membership) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'You are not a member of that team'
|
||||
);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
const memberships = await Member.listByUser(Actor.userID);
|
||||
if (memberships.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (memberships.length > 1) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
'You belong to several teams — name the one this token is for, or pass teamId: null to scope it to yourself',
|
||||
'teamId'
|
||||
);
|
||||
}
|
||||
return memberships[0]!.teamId;
|
||||
}
|
||||
|
||||
export namespace AccessTokenApi {
|
||||
export const route = new Hono()
|
||||
.post(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['AccessToken'],
|
||||
summary: 'Create a personal access token',
|
||||
description:
|
||||
'Mint a long-lived, revocable token for the calling user. The token is returned once and never again — only its digest is stored.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
id: z.string().meta({ example: Examples.AccessToken.id }),
|
||||
token: z.string().meta({
|
||||
description: 'Shown once. Store it now; it cannot be retrieved.'
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'A freshly minted token'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
name: z.string().min(1).max(64).meta({
|
||||
description: 'What this token is for, so it can be recognised in a list',
|
||||
example: Examples.AccessToken.name
|
||||
}),
|
||||
teamId: z.string().nullable().optional().meta({
|
||||
description:
|
||||
'Team to scope the token to. Omit to default to your team when you have exactly one; pass null to force a token scoped to you alone.'
|
||||
}),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional().meta({
|
||||
description: 'Optional lifetime. Omit for a token that does not expire.'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { name, teamId, expiresInDays } = c.req.valid('json');
|
||||
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Creating an access token requires a user session'
|
||||
);
|
||||
}
|
||||
|
||||
const scopedTeamId = await resolveTeamScope(teamId);
|
||||
|
||||
const created = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: Actor.userID,
|
||||
teamId: scopedTeamId,
|
||||
name,
|
||||
expiresInDays
|
||||
});
|
||||
|
||||
return c.json({ data: { id: created.id, token: created.token } });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['AccessToken'],
|
||||
summary: 'List your access tokens',
|
||||
description: 'Returns metadata only. The token values are not stored and cannot be shown.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(AccessToken.Info)) } },
|
||||
description: 'Tokens belonging to the caller'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ data: await AccessToken.listByOwner(Actor.userID) });
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/:id',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['AccessToken'],
|
||||
summary: 'Revoke an access token',
|
||||
description:
|
||||
'Revokes immediately. Revocation is the reason these exist rather than long-lived JWTs.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.object({ id: z.string() })) } },
|
||||
description: 'The token no longer works'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
// Scoped to the owner in the query itself, so revoking someone
|
||||
// else's token is a 404 rather than a permission check that
|
||||
// could be forgotten.
|
||||
const revoked = await AccessToken.revoke({
|
||||
id: c.req.param('id'),
|
||||
ownerUserId: Actor.userID
|
||||
});
|
||||
if (!revoked) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such token, or it is not yours'
|
||||
);
|
||||
}
|
||||
return c.json({ data: { id: revoked.id } });
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user