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
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
151 lines
5.2 KiB
TypeScript
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');
|
|
}
|