mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat: personal access tokens with scoped bearer auth
Add cnp_ personal access tokens so the CLI, MCP server, and external agents authenticate without a browser session — the agent-first spine from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown once, only SHA-256 hash stored. - user_tokens table (goose migration + sqlc), name/prefix/scope/expiry, owner-scoped revoke, last_used_at stamping - middleware: cnp_ bearer resolution, read scope gates mutations with 403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin owners — a token never outranks its scope or its owner - GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types - Settings UI: create with scope/expiry pickers, one-time token display with copy, list with last-used/expiry, confirm-revoke - docs: OPERATIONS.md section, README feature line Verified end-to-end against live Postgres: create/list/revoke, read scope blocks PUT (403), write PAT on admin owner blocked from admin routes (403), admin scope works, revoked token returns 401. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
f9ae5b962c
commit
e881747388
17 files changed
+1141
-30
No files matched your search
@@ -17,6 +17,9 @@ import {
|
||||
createAgentToken,
|
||||
listAgentTokens,
|
||||
revokeAgentToken,
|
||||
createUserToken,
|
||||
listUserTokens,
|
||||
revokeUserToken,
|
||||
listDatabases,
|
||||
databaseAction,
|
||||
updateDatabaseBackupSchedule,
|
||||
@@ -46,6 +49,8 @@ import {
|
||||
updateVulnerability,
|
||||
getSecurityMetrics,
|
||||
type AgentAuthTokenCreated,
|
||||
type UserTokenCreated,
|
||||
type UserTokenScope,
|
||||
type CreateDatabaseInput,
|
||||
type DatabaseEntity,
|
||||
type FailoverPolicy,
|
||||
@@ -1267,6 +1272,164 @@ function PlatformSettingsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [
|
||||
{ label: 'Never', days: 0 },
|
||||
{ label: '30 days', days: 30 },
|
||||
{ label: '90 days', days: 90 },
|
||||
{ label: '1 year', days: 365 },
|
||||
];
|
||||
|
||||
function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: ['user-tokens'],
|
||||
queryFn: listUserTokens,
|
||||
});
|
||||
const [tokenName, setTokenName] = useState('');
|
||||
const [tokenScope, setTokenScope] = useState<UserTokenScope>('write');
|
||||
const [expiryDays, setExpiryDays] = useState(0);
|
||||
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createUserToken({
|
||||
name: tokenName.trim(),
|
||||
scope: tokenScope,
|
||||
expiresInDays: expiryDays,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setIssuedToken(data);
|
||||
setTokenName('');
|
||||
queryClient.invalidateQueries({ queryKey: ['user-tokens'] });
|
||||
},
|
||||
});
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (id: string) => revokeUserToken(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }),
|
||||
});
|
||||
|
||||
const tokens = tokensQuery.data ?? [];
|
||||
const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin
|
||||
? [
|
||||
{ value: 'read', label: 'Read - GET requests only' },
|
||||
{ value: 'write', label: 'Write - read and manage resources' },
|
||||
{ value: 'admin', label: 'Admin - everything, incl. platform settings' },
|
||||
]
|
||||
: [
|
||||
{ value: 'read', label: 'Read - GET requests only' },
|
||||
{ value: 'write', label: 'Write - read and manage resources' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Key size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
placeholder="Token name (e.g. laptop cli)"
|
||||
className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<select
|
||||
value={tokenScope}
|
||||
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
|
||||
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||
>
|
||||
{scopeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={expiryDays}
|
||||
onChange={(e) => setExpiryDays(Number(e.target.value))}
|
||||
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||
>
|
||||
{PAT_EXPIRY_OPTIONS.map((option) => (
|
||||
<option key={option.days} value={option.days}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !tokenName.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{issuedToken ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||
<button
|
||||
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
|
||||
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
|
||||
>
|
||||
<Copy size={11} />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{createMutation.isError ? (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tokensQuery.isLoading ? (
|
||||
<div className="py-4 text-center">
|
||||
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{token.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
|
||||
<span className="ml-2 text-[var(--text-muted)]">
|
||||
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
|
||||
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
|
||||
revokeMutation.mutate(token.id ?? '');
|
||||
}
|
||||
}}
|
||||
disabled={revokeMutation.isPending}
|
||||
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1438,6 +1601,9 @@ export function SettingsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Personal Access Tokens */}
|
||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||
|
||||
{/* Platform Settings - owner only */}
|
||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||
|
||||
|
||||
@@ -231,6 +231,153 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/user/tokens": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List personal access tokens
|
||||
* @description Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Token list */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
tokens?: components["schemas"]["UserToken"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Create personal access token
|
||||
* @description Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateUserTokenRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Token created; raw value returned once */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserTokenCreated"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid input */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Admin scope requires platform admin */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/user/tokens/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Revoke personal access token
|
||||
* @description Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on.
|
||||
*/
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Token revoked */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Token not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8476,6 +8623,37 @@ export interface components {
|
||||
/** @description Human-readable label for the token (e.g. node name) */
|
||||
label?: string;
|
||||
};
|
||||
UserToken: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
/** @description First 12 chars of the token, for identification in lists */
|
||||
key_prefix: string;
|
||||
/** @enum {string} */
|
||||
scope: "read" | "write" | "admin";
|
||||
/** Format: date-time */
|
||||
expires_at?: string;
|
||||
/** Format: date-time */
|
||||
last_used_at?: string;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
UserTokenCreated: components["schemas"]["UserToken"] & {
|
||||
/** @description Raw cnp_ token value, returned exactly once at creation */
|
||||
token: string;
|
||||
};
|
||||
CreateUserTokenRequest: {
|
||||
/** @description Human-readable label (e.g. "laptop cli", "ci deploy") */
|
||||
name: string;
|
||||
/**
|
||||
* @description read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account)
|
||||
* @default write
|
||||
* @enum {string}
|
||||
*/
|
||||
scope: "read" | "write" | "admin";
|
||||
/** @description 0 or omitted = never expires */
|
||||
expires_in_days?: number;
|
||||
};
|
||||
AgentCapabilities: {
|
||||
container_runtimes?: string[];
|
||||
supported_architectures?: string[];
|
||||
|
||||
@@ -52,7 +52,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/OPERATIONS.md",
|
||||
"section": "guides",
|
||||
"title": "Operations",
|
||||
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n"
|
||||
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n"
|
||||
},
|
||||
{
|
||||
"path": "guides/README_BACKEND.md",
|
||||
|
||||
@@ -993,6 +993,34 @@ export async function revokeAgentToken(id: string): Promise<void> {
|
||||
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export type UserToken = components['schemas']['UserToken'];
|
||||
export type UserTokenCreated = components['schemas']['UserTokenCreated'];
|
||||
export type UserTokenScope = 'read' | 'write' | 'admin';
|
||||
|
||||
export async function createUserToken(input: {
|
||||
name: string;
|
||||
scope?: UserTokenScope;
|
||||
expiresInDays?: number;
|
||||
}): Promise<UserTokenCreated> {
|
||||
return requestJson<UserTokenCreated>(`/user/tokens`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
scope: input.scope ?? 'write',
|
||||
expires_in_days: input.expiresInDays ?? 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUserTokens(): Promise<UserToken[]> {
|
||||
const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`);
|
||||
return payload.tokens ?? [];
|
||||
}
|
||||
|
||||
export async function revokeUserToken(id: string): Promise<void> {
|
||||
await requestJson(`/user/tokens/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
||||
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in new issue
Block a user