Release 0.7.0
This commit is contained in:
1 parent
d586fbd8ce
commit
6d96900d81
27 files changed
+2060
-25
No files matched your search
@@ -0,0 +1,151 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { AuthUser } from './auth.js';
|
||||
import { getDatabase } from './database.js';
|
||||
|
||||
export type TechnicalPermission = 'read' | 'read_write';
|
||||
export type TechnicalActor = { id: string; name: string; permissions: TechnicalPermission };
|
||||
|
||||
function tokenHash(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function listTechnicalAccesses() {
|
||||
return getDatabase()
|
||||
.prepare(
|
||||
`SELECT id, name, permissions, created_at_utc AS createdAtUtc,
|
||||
last_used_at_utc AS lastUsedAtUtc, revoked_at_utc AS revokedAtUtc FROM technical_accesses ORDER BY name`
|
||||
)
|
||||
.all() as {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: TechnicalPermission;
|
||||
createdAtUtc: string;
|
||||
lastUsedAtUtc: string | null;
|
||||
revokedAtUtc: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function createTechnicalAccess(actor: AuthUser, nameValue: string, permissions: string) {
|
||||
const name = nameValue.trim();
|
||||
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
|
||||
throw new Error('INVALID_ACCESS');
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`INSERT INTO technical_accesses
|
||||
(id, name, token_hash, permissions, created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, name, tokenHash(token), permissions, actor.id, now);
|
||||
auditTechnical(
|
||||
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
|
||||
'technical_access.create',
|
||||
'technical_access',
|
||||
id,
|
||||
'user'
|
||||
);
|
||||
return { id, token };
|
||||
}
|
||||
|
||||
export function revokeTechnicalAccess(actor: AuthUser, id: string) {
|
||||
const result = getDatabase()
|
||||
.prepare(
|
||||
'UPDATE technical_accesses SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL'
|
||||
)
|
||||
.run(new Date().toISOString(), id);
|
||||
if (!result.changes) throw new Error('NOT_FOUND');
|
||||
auditTechnical(
|
||||
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
|
||||
'technical_access.revoke',
|
||||
'technical_access',
|
||||
id,
|
||||
'user'
|
||||
);
|
||||
}
|
||||
|
||||
export function authenticateTechnical(request: Request, write = false): TechnicalActor {
|
||||
const authorization = request.headers.get('authorization') ?? '';
|
||||
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
|
||||
if (!token) throw new Error('API_UNAUTHORIZED');
|
||||
const row = getDatabase()
|
||||
.prepare(
|
||||
`SELECT id, name, permissions FROM technical_accesses
|
||||
WHERE token_hash = ? AND revoked_at_utc IS NULL`
|
||||
)
|
||||
.get(tokenHash(token)) as TechnicalActor | undefined;
|
||||
if (!row) throw new Error('API_UNAUTHORIZED');
|
||||
if (write && row.permissions !== 'read_write') throw new Error('API_FORBIDDEN');
|
||||
getDatabase()
|
||||
.prepare('UPDATE technical_accesses SET last_used_at_utc = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), row.id);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function auditTechnical(
|
||||
actor: TechnicalActor,
|
||||
action: string,
|
||||
targetType: string,
|
||||
targetId: string,
|
||||
actorType: 'technical' | 'user' = 'technical'
|
||||
) {
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`INSERT INTO audit_events (id, actor_type, actor_id, action, target_type, target_id, created_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
randomUUID(),
|
||||
actorType,
|
||||
actor.id,
|
||||
action.slice(0, 120),
|
||||
targetType.slice(0, 80),
|
||||
targetId.slice(0, 200),
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
export function idempotentResponse(
|
||||
actor: TechnicalActor,
|
||||
request: Request,
|
||||
body: unknown,
|
||||
operation: () => { status: number; body: unknown }
|
||||
) {
|
||||
const key = request.headers.get('idempotency-key')?.trim() ?? '';
|
||||
if (!key || key.length > 200) throw new Error('API_IDEMPOTENCY_REQUIRED');
|
||||
const hash = createHash('sha256')
|
||||
.update(`${request.method} ${new URL(request.url).pathname}\n${JSON.stringify(body)}`)
|
||||
.digest('hex');
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const existing = database
|
||||
.prepare(
|
||||
'SELECT request_hash, status_code, response_json FROM api_idempotency WHERE access_id = ? AND request_key = ?'
|
||||
)
|
||||
.get(actor.id, key) as
|
||||
{ request_hash: string; status_code: number; response_json: string } | undefined;
|
||||
if (existing) {
|
||||
if (existing.request_hash !== hash) throw new Error('API_IDEMPOTENCY_CONFLICT');
|
||||
return {
|
||||
status: existing.status_code,
|
||||
body: JSON.parse(existing.response_json),
|
||||
duplicate: true
|
||||
};
|
||||
}
|
||||
const result = operation();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO api_idempotency
|
||||
(access_id, request_key, request_hash, status_code, response_json, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
actor.id,
|
||||
key,
|
||||
hash,
|
||||
result.status,
|
||||
JSON.stringify(result.body),
|
||||
new Date().toISOString()
|
||||
);
|
||||
return { ...result, duplicate: false };
|
||||
})();
|
||||
}
|
||||
Reference in new issue
Block a user