mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
refactor(tokens): MCP and API tokens through McpTokensRepository
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user