mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 11:42:40 -06:00
- routes/tokens.js: create (returns the full secret once), list (never the secret), revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or revoke tokens - no self-escalation. - Settings "API Tokens" section: create form (name + read/write/full scope), one-time secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
58 lines
3 KiB
JavaScript
58 lines
3 KiB
JavaScript
// Public API token management (Phase 1). DASHBOARD-ONLY: this router is mounted
|
|
// JWT-only in server.js, so an API token can never manage tokens (no privilege
|
|
// self-escalation). A user manages their own tokens, bound to their active workspace.
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const crypto = require('crypto');
|
|
const { db } = require('../db/database');
|
|
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
|
|
const { accessContext } = require('../lib/tenancy');
|
|
|
|
const SCOPES = ['read', 'write', 'full'];
|
|
|
|
// List the caller's tokens in the active workspace. Never returns the secret/hash.
|
|
router.get('/', (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' });
|
|
const rows = db.prepare(`
|
|
SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at
|
|
FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC
|
|
`).all(req.user.id, req.workspaceId);
|
|
res.json(rows);
|
|
});
|
|
|
|
// Create a token bound to the active workspace. The full secret is returned ONCE.
|
|
router.post('/', (req, res) => {
|
|
if (!req.workspaceId || !req.workspace) return res.status(403).json({ error: 'No active workspace' });
|
|
const name = (req.body.name || '').trim();
|
|
const scope = req.body.scope || 'read';
|
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
|
|
if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write' or 'full'" });
|
|
// The token runs with platform powers stripped (role forced to 'user'), so it must
|
|
// bind to a workspace the owner reaches via membership/org - not platform act-as -
|
|
// else apiTokenAuth+resolveTenancy would land it in no workspace at use time.
|
|
if (!accessContext(req.user.id, 'user', req.workspace)) {
|
|
return res.status(400).json({ error: 'You must be a member of this workspace to create a token here' });
|
|
}
|
|
const secret = generateToken();
|
|
const id = crypto.randomUUID();
|
|
db.prepare(`
|
|
INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
|
|
`).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope);
|
|
// `token` is returned only here, never again.
|
|
res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId });
|
|
});
|
|
|
|
// Revoke one of the caller's own tokens (soft delete - takes effect on the next request).
|
|
router.delete('/:id', (req, res) => {
|
|
const row = db.prepare('SELECT id, revoked_at FROM api_tokens WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
|
if (!row) return res.status(404).json({ error: 'Token not found' });
|
|
if (!row.revoked_at) {
|
|
db.prepare("UPDATE api_tokens SET revoked_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
|
}
|
|
res.json({ success: true });
|
|
});
|
|
|
|
module.exports = router;
|