refactor(tokens): MCP and API tokens through McpTokensRepository

This commit is contained in:
jubnl
2026-09-22 20:23:11 +02:00
parent 57f4e29b9a
commit daef15be70
11 changed files with 945 additions and 85 deletions
@@ -1,4 +1,374 @@
import type { McpTokens } from '../entities/McpTokens.entity';
import { McpTokens } from '../entities/McpTokens.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { columnRef, currentTimestamp } from '../dialect/sql-functions';
import { EntityRepository } from '@mikro-orm/sql';
export class McpTokensRepository extends EntityRepository<McpTokens> {}
/**
* A full `mcp_tokens` row (`SELECT * FROM mcp_tokens WHERE ...`), as the API
* emits it. `user_id` is the `persist(false)` mirror of the `user` relation
* (same pattern as `InviteTokens.created_by`/`createdByRef` and
* `Categories.user`/`user_id`) — a scalar column for `AssertRowKeys`
* purposes even though it is never written directly.
*/
export interface McpTokenRow {
id: number;
user_id: number;
name: string;
token_hash: string;
token_prefix: string;
created_at: string | null;
last_used_at: string | null;
kind: string;
scope_mode: string;
api_scopes: string | null;
}
const _mcpTokenRowKeys: AssertRowKeys<McpTokenRow, McpTokens> = true;
/** `TokenService.listTokens` (TK1) — the column set both the MCP and API panels start from. */
export interface McpTokenListRow {
id: number;
name: string;
token_prefix: string;
created_at: string | null;
last_used_at: string | null;
scope_mode: string;
api_scopes: string | null;
}
/** `TokenService.createToken`'s write (TK3) — the hash is computed by the service, never here. */
export interface NewMcpTokenRow {
user_id: number;
name: string;
token_hash: string;
token_prefix: string;
kind: string;
scope_mode: string;
api_scopes: string | null;
}
/** `TokenService.createToken`'s re-select (TK4) and `adminDeleteMcpToken`'s 404 check (TK9) — see `findBasic`. */
export interface McpTokenBasicRow {
id: number;
user_id: number;
name: string;
token_prefix: string;
created_at: string | null;
last_used_at: string | null;
}
/** `TokenService.deleteToken`'s ownership + kind check (TK5). */
export interface McpTokenIdRow {
id: number;
}
/** `TokenService.listAllMcpTokens` (TK8) — the admin panel's cross-user join. */
export interface McpTokenWithUsernameRow {
id: number;
name: string;
token_prefix: string;
created_at: string | null;
last_used_at: string | null;
user_id: number;
username: string;
}
/** `TokenService.verifyApiTokenWithGrant` (TK11) — bearer auth for the public API. */
export interface McpTokenGrantRow {
id: number;
username: string;
email: string;
role: string;
scope_mode: string;
api_scopes: string | null;
}
/** `TokenService.verifyToken` (TK13) — bearer auth shared by the MCP/legacy-static-token surfaces. */
export interface McpTokenUserRow {
id: number;
username: string;
email: string;
role: string;
}
/**
* MCP tokens (assistant-tool bearer credentials) and API tokens (public REST
* integration keys) — one table (`mcp_tokens`), discriminated by `kind`, one
* repository (Plan 3b Task 2, inventory §2). The raw token is never stored,
* only its SHA-256 hash — this repository never computes or compares a
* hash, `TokenService` does, and it is handed the hash as an opaque string
* on every read here. `deleteAllForUser` exists for Task 5's `AuthService`
* (AU17/AU45 — session revocation on password change), not consumed here.
*/
export class McpTokensRepository extends EntityRepository<McpTokens> {
/**
* `SELECT id, name, token_prefix, created_at, last_used_at, scope_mode,
* api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ?
* ORDER BY created_at DESC` (TK1, `listTokens` — the private method
* `listMcpTokens`/`listApiTokens` both call). `kind` discriminates the
* shape the caller derives from the row: an MCP token drops
* `scope_mode`/`api_scopes` from what it shows, an API key resolves them
* into a grant — both stay in the service, unchanged.
*
* `disableIdentityMap: true` (controller ruling on Task 1's review,
* applied to every entity-hydrating read in this repository): a `find`/
* `findOne` with a `fields` projection merges a PARTIAL snapshot of this
* row into the request's shared identity map. If the same row is later
* loaded elsewhere in the request with a *different* projection, MikroORM
* merges both into one managed entity; a `nativeUpdate` elsewhere in the
* same transaction does not touch that managed entity (native writes
* bypass the identity map by design), so the merged-but-stale entity can
* still be flushed — by the closing `flush()` of a surrounding
* `uow.transactional` block — writing its stale unselected columns back
* over the `nativeUpdate`'s change. `disableIdentityMap: true` returns an
* isolated entity that is never merged into the shared context, so it can
* never be flushed at all: nothing here can go stale-write-back a later
* write. See `MCPTOKREPO-021` for the regression proof.
*/
async listByUserAndKind(userId: number, kind: string): Promise<McpTokenListRow[]> {
const rows = await this.find(
{ user: userId, kind },
{
fields: ['id', 'name', 'token_prefix', 'created_at', 'last_used_at', 'scope_mode', 'api_scopes'],
orderBy: { created_at: 'desc' },
disableIdentityMap: true,
},
);
return rows.map((row) => ({
id: row.id,
name: row.name,
token_prefix: row.token_prefix,
created_at: row.created_at ?? null,
last_used_at: row.last_used_at ?? null,
scope_mode: row.scope_mode,
api_scopes: row.api_scopes ?? null,
}));
}
/** `SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ? AND kind = ?` (TK2 — the 10-per-kind cap). */
async countByUserAndKind(userId: number, kind: string): Promise<number> {
return this.count({ user: userId, kind });
}
/**
* `INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind,
* scope_mode, api_scopes) VALUES (...)` (TK3 — token mint,
* **security-sensitive**). Named `insertToken`, not `create`:
* `EntityRepository#create` is a synchronous, non-persisting factory with
* an incompatible signature — shadowing it would fail to compile (the
* same collision Task 1's `insertUser` avoided). The raw token is never
* given to or returned by this method — `token_hash` arrives already
* hashed, and the caller appends the raw value to its own response
* afterward, never storing or re-reading it.
*
* A native `em.insert()`, not `create()` + `persist()` + `flush()`
* (controller ruling on Task 1's review — every repository insert, not
* only the best-effort ones Plan 3a scoped this to originally):
* `flush()` commits the *whole* unit of work of the request's
* `EntityManager`, not just this row — inside `AuthService.registerUser`'s
* transaction (a later task's caller), a `flush()` here would commit
* whatever else that transaction was still assembling. `em.insert()` fires
* one native INSERT with no side effects on the context/identity map,
* matching `AuditLogRepository.insertEntry`'s precedent. Followed by a
* `disableIdentityMap: true` re-select (D4's create rule, this
* repository's isolated-read shape) rather than `em.refresh`, which needs
* a managed entity `em.insert` never produces.
*/
async insertToken(row: NewMcpTokenRow): Promise<McpTokenRow> {
const id = await this.getEntityManager().insert(McpTokens, {
user: row.user_id,
name: row.name,
token_hash: row.token_hash,
token_prefix: row.token_prefix,
kind: row.kind,
scope_mode: row.scope_mode,
api_scopes: row.api_scopes,
});
const inserted = await this.findOne({ id }, { disableIdentityMap: true });
if (!inserted) {
throw new Error(`McpTokensRepository.insertToken: row ${String(id)} not found immediately after insert`);
}
return toRow(inserted) as McpTokenRow;
}
/**
* `SELECT id, name, token_prefix, created_at, last_used_at FROM
* mcp_tokens WHERE id = ?` (TK4, `createToken`'s re-select) and
* `SELECT id, user_id FROM mcp_tokens WHERE id = ?` (TK9,
* `adminDeleteMcpToken`'s 404 check) — ONE method: TK9's two columns
* (`id`, `user_id`) are a subset of TK4's five, and both statements are a
* plain by-id lookup with no other filter, so a single narrower-than-`*`
* projection covering the union of both column sets serves both call
* sites without a second near-identical statement (same economy as
* `deleteById` below covering TK6/TK10). PK-only `findOne` ->
* `disableIdentityMap: true`, NOT `refresh: true` (controller ruling on
* Task 1's review, superseding Plan 3a's "PK-only `findOne` -> `refresh:
* true`" ruling everywhere in this repository): `refresh` still re-queries
* on every call, but it also MERGES the fresh row into the shared identity
* map — exactly the mechanism `listByUserAndKind`'s docstring above
* describes going stale. `disableIdentityMap: true` re-queries every call
* (same "always fresh" property `refresh` gave) without ever registering
* the result anywhere a later `flush()` could find it.
*
* `fields` asks for the RELATION property `'user'`, not the `persist(false)`
* mirror `'user_id'` — verified empirically (not from the `.d.ts`): a
* `fields` list naming the mirror directly leaves `row.user_id`
* `undefined` (it has no column of its own to select), while naming the
* relation loads its (unpopulated) `Ref`, whose `.id` is readable
* synchronously without a second query (`EntityRef`'s whole point, per
* `@mikro-orm/core`'s own typings comment: `ref.id` instead of
* `ref.unwrap().id`) — read here as `row.user.id`, not the entity's own
* `.user_id` field, which the `fields`-narrowed `Loaded<...>` type does
* not expose (MikroORM does derive it at runtime, same mechanism `toRow`'s
* full-row methods rely on, but `fields` narrows the TYPE to only the
* names actually requested).
*/
async findBasic(id: number): Promise<McpTokenBasicRow | null> {
const row = await this.findOne(
{ id },
{ fields: ['id', 'user', 'name', 'token_prefix', 'created_at', 'last_used_at'], disableIdentityMap: true },
);
return row
? {
id: row.id,
user_id: row.user.id,
name: row.name,
token_prefix: row.token_prefix,
created_at: row.created_at ?? null,
last_used_at: row.last_used_at ?? null,
}
: null;
}
/**
* `SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ? AND kind = ?`
* (TK5, `deleteToken`'s 404 check — ownership AND kind scoping, so an
* integrations-panel delete can never remove a token the MCP panel
* manages, and vice versa). Not a PK-only filter (`id` + `user` + `kind`),
* so this always re-queries regardless; `disableIdentityMap: true` all the
* same (controller ruling on Task 1's review — every entity-hydrating read
* in this repository, PK-only or not, per `listByUserAndKind`'s docstring
* above: a narrow `fields` projection merged into the shared identity map
* is the hazard, not specifically a PK-only filter).
*/
async findOwnedByKind(id: number, userId: number, kind: string): Promise<McpTokenIdRow | null> {
const row = await this.findOne({ id, user: userId, kind }, { fields: ['id'], disableIdentityMap: true });
return row ? { id: row.id } : null;
}
/**
* `DELETE FROM mcp_tokens WHERE id = ?` — TK6 (`deleteToken`, after the
* owner+kind check above; the caller treats the follow-up session revoke
* as best-effort) and TK10 (`adminDeleteMcpToken`, after `findBasic`'s
* 404 check; the caller revokes sessions unconditionally). Identical
* statement, two call sites with different revoke-session semantics —
* that difference stays in `TokenService`, this repository only deletes
* the row.
*/
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/**
* `SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at,
* t.user_id, u.username FROM mcp_tokens t JOIN users u ON u.id =
* t.user_id ORDER BY t.created_at DESC` (TK8, `listAllMcpTokens` — the
* admin panel, deliberately unscoped by user). An INNER JOIN, matching
* the legacy `JOIN` (every `mcp_tokens` row has a `user`, FK
* `deleteRule('cascade')`, so no row is ever excluded by this choice).
* The projection root is `McpTokens` (`this.qb('t')`), not `Users` — D4/
* Task 1's ruling that a join projection through this table stays owned
* by `McpTokensRepository` rather than adding a users-side method.
* `mapResults: false` (`execute(..., false)`) leaves the driver's raw row
* alone. Verified empirically (not from the `.d.ts`, and NOT the same
* shape as `TripsRepository.findAccessible`'s `t.user` -> `user_id`
* precedent): selecting the relation property `t.user` while ALSO joining
* that same relation (`t.user` -> alias `u`) is absorbed into the join
* rather than emitting a `user_id` column at all — MikroORM treats the
* select as redundant with the join, not as "select the FK scalar." A
* bare `columnRef(platform, 't.user_id')` (the dialect helper `no
* -restricted-syntax` steers every repository through instead of a
* hand-spelled `raw()`) selects the literal column and comes back keyed
* `user_id`, confirmed against the formatted SQL and the raw row.
*/
async listAllWithUsername(): Promise<McpTokenWithUsernameRow[]> {
const platform = this.getEntityManager().getPlatform();
const rows = await this.qb('t')
.innerJoin('t.user', 'u')
.select(['t.id', 't.name', 't.token_prefix', 't.created_at', 't.last_used_at', columnRef(platform, 't.user_id'), 'u.username'])
.orderBy({ 't.created_at': 'desc' })
.execute<McpTokenWithUsernameRow[]>('all', false);
return rows.map((row) => ({
id: row.id,
name: row.name,
token_prefix: row.token_prefix,
created_at: row.created_at ?? null,
last_used_at: row.last_used_at ?? null,
user_id: row.user_id,
username: row.username,
}));
}
/**
* `SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes
* FROM mcp_tokens mt JOIN users u ON mt.user_id = u.id WHERE
* mt.token_hash = ? AND mt.kind = 'api'` (TK11,
* `verifyApiTokenWithGrant` — **security-sensitive**, bearer auth for the
* public API). `kind = 'api'` is a literal in the WHERE, same as the
* legacy statement — this method only ever verifies an API key, never an
* MCP token, so the filter is not a parameter. Projection root
* `McpTokens` (`this.qb('mt')`), matching the legacy alias.
*/
async findGrantByHash(hash: string): Promise<McpTokenGrantRow | null> {
const row = await this.qb('mt')
.innerJoin('mt.user', 'u')
.select(['u.id', 'u.username', 'u.email', 'u.role', 'mt.scope_mode', 'mt.api_scopes'])
.where({ 'mt.token_hash': hash, 'mt.kind': 'api' })
.execute<McpTokenGrantRow | undefined>('get', false);
return row ?? null;
}
/**
* `SELECT u.id, u.username, u.email, u.role FROM mcp_tokens mt JOIN users
* u ON mt.user_id = u.id WHERE mt.token_hash = ? AND mt.kind = ?` (TK13,
* `verifyToken` — **security-sensitive**, every MCP/legacy static-token
* request). `kind` is part of the WHERE, never a post-filter applied to
* the result: a token of the wrong kind produces the exact same `null` a
* nonexistent token would, at the SQL level, not after a JS check — the
* caller (and a timing measurement) cannot distinguish "no such token"
* from "real token, wrong door."
*/
async findUserByHashAndKind(hash: string, kind: string): Promise<McpTokenUserRow | null> {
const row = await this.qb('mt')
.innerJoin('mt.user', 'u')
.select(['u.id', 'u.username', 'u.email', 'u.role'])
.where({ 'mt.token_hash': hash, 'mt.kind': kind })
.execute<McpTokenUserRow | undefined>('get', false);
return row ?? null;
}
/**
* `UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE
* token_hash = ?` — TK12 (`verifyApiTokenWithGrant`'s fire-and-forget
* bookkeeping) and TK14 (`verifyToken`'s, same statement text, a separate
* call site) — ONE method, two callers, same as `deleteById` above.
* `currentTimestamp(platform)` rather than a hand-spelled `CURRENT_TIMESTAMP`
* string (byte-identical per `sql-functions.test.ts`'s `SQLF-008`).
*/
async touchLastUsedByHash(hash: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ token_hash: hash }, { last_used_at: currentTimestamp(platform) });
}
/**
* `DELETE FROM mcp_tokens WHERE user_id = ?` — `AuthService.changePassword`
* (AU17) and `resetPassword` (AU45), both inside the same transaction as
* the password write itself (**security-sensitive**: every MCP/API token
* the user held stops working the moment their password changes). Built
* here because this table belongs to `nest/tokens`, per the inventory's
* cross-domain ruling (§6) — Task 5 is the only caller.
*/
async deleteAllForUser(userId: number): Promise<void> {
await this.nativeDelete({ user: userId });
}
}
+55 -57
View File
@@ -1,11 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { randomBytes, createHash } from 'crypto';
import {
PUBLIC_API_SCOPES,
type PublicApiGrant,
type PublicApiScope,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import type { McpTokensRepository, McpTokenBasicRow } from '../../db/repositories/McpTokens.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
// the whole tools fan-out (and via the domain bridges, the Nest services) into
@@ -29,8 +33,11 @@ type TokenKind = 'mcp' | 'api';
* touch one table (mcp_tokens) plus the ephemeral-token store, and nothing in
* here needs to know how a password is hashed or how a session is established.
*
* The methods moved verbatim — same SQL, same validation order, same error
* strings and status codes, same best-effort session revoke on delete.
* The methods moved verbatim — same SQL (now through `McpTokensRepository`),
* same validation order, same error strings and status codes, same
* best-effort session revoke on delete. The raw token is still never stored —
* only its SHA-256 hash reaches the repository, computed here exactly as
* before.
*
* Deliberately NOT here: verifyJwtToken (that is login identity, and it stays
* next to the cookie/JWT logic on AuthService) and isDemoUser (a demo gate that
@@ -39,7 +46,8 @@ type TokenKind = 'mcp' | 'api';
@Injectable()
export class TokenService {
constructor(
private readonly db: DatabaseService,
@InjectRepository(McpTokens) private readonly tokens: McpTokensRepository,
@InjectRepository(Users) private readonly users: UsersRepository,
private readonly ephemeral: EphemeralTokenService,
) {}
@@ -62,10 +70,7 @@ export class TokenService {
}
private async listTokens(userId: number, kind: TokenKind) {
const rows = this.db.all<TokenRow>(
'SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
userId, kind
);
const rows = await this.tokens.listByUserAndKind(userId, kind);
// The MCP list keeps the exact shape it has always had: those tokens carry
// no read scopes, and two columns that are always "everything" would be
// noise in a panel that cannot act on them.
@@ -106,7 +111,7 @@ export class TokenService {
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ? AND kind = ?', userId, kind)!.count;
const tokenCount = await this.tokens.countByUserAndKind(userId, kind);
if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 };
const rawToken = 'trek_' + randomBytes(24).toString('hex');
@@ -117,22 +122,28 @@ export class TokenService {
// the column default — rather than being handed a list it would ignore.
const narrowed = kind === 'api' ? sanitizeScopes(scopes) : null;
const result = this.db.run(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)',
userId, name.trim(), tokenHash, tokenPrefix, kind,
narrowed ? 'limited' : 'all',
narrowed ? JSON.stringify(narrowed) : null,
);
const inserted = await this.tokens.insertToken({
user_id: userId,
name: name.trim(),
token_hash: tokenHash,
token_prefix: tokenPrefix,
kind,
scope_mode: narrowed ? 'limited' : 'all',
api_scopes: narrowed ? JSON.stringify(narrowed) : null,
});
const token = this.db.get(
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?',
result.lastInsertRowid
);
// A separate re-select, matching the legacy INSERT-then-SELECT shape
// exactly (TK3 then TK4) rather than reusing `inserted`'s full row —
// `findBasic` also serves TK9's admin lookup, so its `user_id` field is
// dropped here: TK4's response never carried it, and leaking it would be
// a new field on the client-facing token payload, not a refactor.
const basic = (await this.tokens.findBasic(inserted.id)) as McpTokenBasicRow;
const { user_id: _userId, ...token } = basic;
const grant = kind === 'api'
? { scope_mode: narrowed ? 'limited' : 'all', scopes: narrowed ?? [...PUBLIC_API_SCOPES] }
: {};
return { token: { ...(token as object), ...grant, raw_token: rawToken } };
return { token: { ...token, ...grant, raw_token: rawToken } };
}
async deleteMcpToken(userId: number, tokenId: string) {
@@ -149,9 +160,10 @@ export class TokenService {
* missing from a screen they never opened.
*/
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ error?: string; status?: number; success?: boolean }> {
const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ? AND kind = ?', tokenId, userId, kind);
const id = Number(tokenId);
const token = await this.tokens.findOwnedByKind(id, userId, kind);
if (!token) return { error: 'Token not found', status: 404 };
this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId);
await this.tokens.deleteById(id);
// Best-effort, like the changePassword/resetPassword revocations: a session
// sweep failure must not turn a successful token delete into a 500.
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
@@ -165,7 +177,7 @@ export class TokenService {
async createWsToken(userId: number): Promise<{ error?: string; status?: number; token?: string }> {
// Bind the ws-token to the user's current password_version so a token minted
// before a password reset is rejected on connect (defence-in-depth session gate).
const pv = this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', userId)?.password_version ?? 0;
const pv = (await this.users.getPasswordVersion(userId)) ?? 0;
const token = this.ephemeral.create(userId, 'ws', { pv });
if (!token) return { error: 'Service unavailable', status: 503 };
return { token };
@@ -185,7 +197,7 @@ export class TokenService {
// Admin view
//
// The same table, seen across all users. Moved here from AdminService, which
// owned a second copy of the delete purely because the admin route lived
// only owned a second copy of the delete purely because the admin route lived
// there. Note the two are genuinely different queries, not duplicates: the
// user-facing ones scope every statement by user_id, these deliberately do
// not, and the admin delete revokes sessions unconditionally where the
@@ -193,18 +205,14 @@ export class TokenService {
// -------------------------------------------------------------------------
async listAllMcpTokens() {
return this.db.all(`
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
FROM mcp_tokens t
JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
`);
return await this.tokens.listAllWithUsername();
}
async adminDeleteMcpToken(id: string) {
const token = this.db.get<{ id: number; user_id: number }>('SELECT id, user_id FROM mcp_tokens WHERE id = ?', id);
const numericId = Number(id);
const token = await this.tokens.findBasic(numericId);
if (!token) return { error: 'Token not found', status: 404 };
this.db.run('DELETE FROM mcp_tokens WHERE id = ?', id);
await this.tokens.deleteById(numericId);
revokeUserSessions(token.user_id);
return {};
}
@@ -231,14 +239,9 @@ export class TokenService {
*/
async verifyApiTokenWithGrant(rawToken: string): Promise<{ user: User; grant: PublicApiGrant } | null> {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = this.db.get<User & { scope_mode: string | null; api_scopes: string | null }>(`
SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ? AND mt.kind = 'api'
`, hash);
const row = await this.tokens.findGrantByHash(hash);
if (!row) return null;
this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash);
await this.tokens.touchLastUsedByHash(hash);
const { scope_mode, api_scopes, ...user } = row;
return { user: user as User, grant: resolveGrant(scope_mode, api_scopes) };
}
@@ -253,30 +256,18 @@ export class TokenService {
*/
private async verifyToken(rawToken: string, kind: TokenKind): Promise<User | null> {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = this.db.get<User>(`
SELECT u.id, u.username, u.email, u.role
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ? AND mt.kind = ?
`, hash, kind);
const row = await this.tokens.findUserByHashAndKind(hash, kind);
if (row) {
this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash);
return row;
await this.tokens.touchLastUsedByHash(hash);
// `role` is `users.role TEXT`, narrower at runtime than the repository's
// row type states — same trust boundary the legacy `this.db.get<User>()`
// generic asserted without a runtime check.
return row as User;
}
return null;
}
}
interface TokenRow {
id: number;
name: string;
token_prefix: string;
created_at: string;
last_used_at: string | null;
scope_mode: string | null;
api_scopes: string | null;
}
/**
* The requested sections, cleaned up — unknown values dropped, duplicates
* collapsed, order pinned to the canonical list so two keys with the same
@@ -308,6 +299,13 @@ function sanitizeScopes(scopes: readonly string[] | undefined): PublicApiScope[]
* flag instead of "NULL means everything" is that a key minted as restricted
* must never widen on its own. A key that stops working is a support ticket; a
* key that quietly reads every trip is the bug this feature exists to prevent.
*
* Still defends against `mode`/`raw` arriving `null`, even though
* `McpTokensRepository`'s rows type them as non-nullable strings (the
* `mcp_tokens.scope_mode` column is `NOT NULL DEFAULT 'all'`): this function
* is shared, pure, and untouched by the repository conversion — the
* defensiveness costs nothing and keeps `PUBAPI-SCOPE-U042` (a hand-built row
* simulating a pre-migration NULL) meaningful.
*/
function resolveGrant(mode: string | null, raw: string | null): PublicApiGrant {
if (mode !== 'limited') return { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
+15 -4
View File
@@ -1,4 +1,7 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import { Users } from '../../db/entities/Users.entity';
import { TokenService } from './token.service';
import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
@@ -6,12 +9,20 @@ import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
* Tokens that are not the login JWT: the long-lived MCP tokens a user manages
* in settings, plus the short-lived ws and download tokens.
*
* A leaf module on purpose. It needs nothing but DatabaseService, which keeps
* it cheap to import — AuthModule, AdminModule and the MCP transport all reach
* for it, and any dependency added here lands in all three graphs.
* A leaf module on purpose. It needs nothing but its own two repositories,
* which keeps it cheap to import — AuthModule, AdminModule and the MCP
* transport all reach for it, and any dependency added here lands in all
* three graphs. `forFeature([McpTokens, Users])` resolves `TokenService`'s
* `@InjectRepository`s from THIS module's own graph regardless of who
* imports `TokensModule` (Plan 3b Task 1's guard-injection blast-radius
* reasoning does not apply here: that was about a class Nest instantiates by
* reference from `@UseGuards()`, resolved from the HOST CONTROLLER's module;
* `TokenService` is an ordinary provider declared in this module's own
* `providers`, so its constructor dependencies resolve from this module's
* own `imports`, exactly like any other Nest provider).
*/
@Module({
imports: [EphemeralTokenModule],
imports: [EphemeralTokenModule, MikroOrmModule.forFeature([McpTokens, Users])],
providers: [TokenService],
exports: [TokenService],
})