dorfteich/apps/api/src/public-api/api-tokens.service.ts
Claude Fable 5 0c85293830
Some checks failed
CI / Lint, typecheck, test (push) Failing after 1m39s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m51s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 11s
Public REST API v1: personal access tokens, instance switch + per-pond opt-in (#104)
Token-authenticated machine access at /api/public/v1 — the foundation for
the built-in MCP endpoint (#105).

Personal access tokens:
- api_tokens table (SHA-256 hash, scope read|write, optional pond
  restriction, expiry, revocation, throttled last-used) + migration;
  secrets are dt_pat_<random>, shown exactly once
- lifecycle endpoints under /users/me/api-tokens (session-only — a leaked
  token can never mint more tokens) with audit entries
  api.token_created/api.token_revoked
- settings UI section (create with scope/expiry/pond restriction,
  one-time reveal with copy, list with status + revoke), de+en

Activation (404 semantics per #60 on both levels):
- instance setting api.enabled (default off, admin settings switch)
- pond setting apiEnabled (default off, pond settings toggle; the
  PondsService settings-merge learned the key — the #92 lesson)

Surface (/api/public/v1, excluded from the SPA's global prefix):
- me, ponds, pages (list/read as Markdown+HTML, create from Markdown via
  the shared pipeline, PATCH title/content, DELETE to trash), search
  (permission-filtered + narrowed to exposed ponds, highlights as **…**),
  markdown ZIP export, labels (tree, create/rename/recolour/move/delete,
  assign/unassign), comments (threads, create, resolve/reopen)
- content replacement travels the collab-owned document path: the new
  state lands as a MANUAL version "API update", then the established
  restore NOTIFY applies it — open editors converge, history stays
  append-only, no second lineage (VersionsService.replaceContent)
- hand-maintained OpenAPI 3.1 document at /openapi.json, pinned to the
  controller by a route-coverage test in both directions

Enforcement:
- PublicApiGuard: instance switch → bearer PAT auth (request.user is the
  token's user) → per-token rate limit (429 + Retry-After) → scope
  (403 scope_required) → pond opt-in + token restriction
- the shared PermissionGuard then applies the unchanged permission model;
  PageParamSource gained pondSlugParam for the slug+slug routes
- no cookies anywhere → no CSRF surface (pinned by a hostile-Origin test)
- every write audit-logged as api.write with the token attributed

Tests/verification:
- 12-test e2e pack: lifecycle, switches, permission matrix
  (reader/editor/outsider × scopes), restriction, page roundtrip incl.
  restore-NOTIFY assertion, labels, comments incl. policy, search
  narrowing, ZIP export, rate limit; full api suite 60/60 green
  (quota fixture via per-user override — never the instance default)
- new collab-pack test proves an open editor converges onto an API
  content replacement (green against a local seeded stack)
- UI smoke against the built SPA: token create/reveal/revoke, pond
  opt-in persists, admin switch persists (10/10)
- docs/self-hosting/public-api.md + README link

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 11:17:03 +02:00

151 lines
5.2 KiB
TypeScript

import { createHash, randomBytes } from 'node:crypto';
import { Injectable, NotFoundException } from '@nestjs/common';
import {
API_TOKEN_PREFIX,
type ApiTokenCreatedView,
type ApiTokenScope,
type ApiTokenView,
type CreateApiTokenInput,
} from '@dorfteich/shared';
import { ApiToken, User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
/** Throttle for the last-used timestamp — one write per token per minute
* keeps busy clients from turning every request into an UPDATE. */
const LAST_USED_WRITE_INTERVAL_MS = 60_000;
/**
* Personal access tokens (issue #104). The secret (`dt_pat_<random>`) is
* returned exactly once at creation and stored as its SHA-256 hash
* (auth-tokens pattern). A token authenticates AS its user; scope and the
* optional pond restriction only narrow what the public API lets it do —
* they never widen permissions.
*/
@Injectable()
export class ApiTokensService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly audit: AuditService,
) {}
async create(user: User, input: CreateApiTokenInput): Promise<ApiTokenCreatedView> {
// The restriction may only name ponds the user can currently see —
// anything else would leak pond ids into stored rows and confuse the
// settings list. Unknown ids are rejected, not silently dropped.
const pondIds = [...new Set(input.pondIds)];
for (const pondId of pondIds) {
const pond = await this.prisma.pond.findFirst({
where: { id: pondId, deletedAt: null },
select: { id: true },
});
if (!pond || !(await this.permissions.canSeePond(user, pondId))) {
throw new NotFoundException({ code: 'pond_not_found' });
}
}
const raw = `${API_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
const row = await this.prisma.apiToken.create({
data: {
tokenHash: hashApiToken(raw),
userId: user.id,
name: input.name,
scope: input.scope === 'write' ? 'WRITE' : 'READ',
pondIds,
expiresAt: input.expiresAt ?? null,
},
});
await this.audit.record({
action: 'api.token_created',
actorId: user.id,
targetType: 'api_token',
targetId: row.id,
details: { name: row.name, scope: input.scope, ponds: pondIds.length },
});
return { ...(await this.viewOf(row)), token: raw };
}
async list(user: User): Promise<ApiTokenView[]> {
const rows = await this.prisma.apiToken.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
});
return Promise.all(rows.map((row) => this.viewOf(row)));
}
async revoke(user: User, tokenId: string): Promise<void> {
// Owner-scoped update: someone else's token id reads as "not found".
const result = await this.prisma.apiToken.updateMany({
where: { id: tokenId, userId: user.id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (result.count === 0) throw new NotFoundException();
await this.audit.record({
action: 'api.token_revoked',
actorId: user.id,
targetType: 'api_token',
targetId: tokenId,
});
}
/**
* Resolves a bearer secret to its live token + user, or null for unknown,
* revoked, expired tokens or disabled accounts. Updates the last-used
* timestamp (throttled, fire-and-forget).
*/
async validate(raw: string): Promise<{ user: User; token: ApiToken } | null> {
if (!raw.startsWith(API_TOKEN_PREFIX)) return null;
const token = await this.prisma.apiToken.findUnique({
where: { tokenHash: hashApiToken(raw) },
include: { user: true },
});
if (!token || token.revokedAt) return null;
if (token.expiresAt && token.expiresAt.getTime() <= Date.now()) return null;
if (token.user.status !== 'ACTIVE') return null;
const lastUsed = token.lastUsedAt?.getTime() ?? 0;
if (Date.now() - lastUsed > LAST_USED_WRITE_INTERVAL_MS) {
this.prisma.apiToken
.update({ where: { id: token.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
}
const { user, ...rest } = token;
return { user, token: rest as ApiToken };
}
scopeOf(token: ApiToken): ApiTokenScope {
return token.scope === 'WRITE' ? 'write' : 'read';
}
private async viewOf(row: ApiToken): Promise<ApiTokenView> {
const ponds =
row.pondIds.length === 0
? []
: await this.prisma.pond.findMany({
where: { id: { in: row.pondIds } },
select: { id: true, name: true },
});
return {
id: row.id,
name: row.name,
scope: this.scopeOf(row),
ponds: row.pondIds.map((id) => ({
id,
name: ponds.find((pond) => pond.id === id)?.name ?? id,
})),
expiresAt: row.expiresAt?.toISOString() ?? null,
revokedAt: row.revokedAt?.toISOString() ?? null,
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
}
export function hashApiToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}