/**
* Personal access token RPC handlers (classic-git-auth Phase 1).
*
* create-access-token { name?, scope?, ttlDays?, userId? }
* Mints a PAT for the CALLER's own user (HTTP: resolved from session;
* UDS: must pass userId). Returns the raw secret ONCE — it is never
* stored or recoverable. Any authenticated user may mint a token for
* themselves; the token can never exceed the user's own perms, and a
* read-scoped token can't push at all (enforced in canPushGit).
*
* list-access-tokens { userId? }
* Metadata only (never the secret). Caller's own tokens by default.
*
* revoke-access-token { identityId }
* Soft revoke (revokeIdentity). Caller may revoke only their OWN tokens;
* UDS callers (local operator) may revoke any.
*
* Tokens are `access-token` identities in the IdentityRegistry — see
* lib/auth/access-token.js and spec/flows/classic-git-auth.md.
*/
import { AuthError } from '../auth/check.js'
import { mintToken, listTokens, revokeToken } from '../auth/access-token.js'
import { findOwnerUserId } from '../auth/ssh-identity.js'
const DAY_MS = 24 * 60 * 60 * 1000
const DEFAULT_TTL_DAYS = 90
export function accessTokenHandlers (svc) {
const registry = () => svc.identityRegistry
async function resolveCallerUserId (ctx) {
if (!ctx || !ctx.identity || !ctx.identity.publicKey) return null
const user = await registry().getUserByIdentity(ctx.identity.publicKey).catch(() => null)
return user ? user.userId : null
}
// Resolve a UDS caller to the OWNER user. The local operator on the UDS
// socket IS the daemon owner — transport:'uds' is already fully trusted for
// authz; this just attributes the operation to a concrete User. The
// hyperident root is the intrinsic owner (see auth/envelope-ctx.js); fall
// back to the registry's admin user. Explicit { userId } wins (operator
// acting on behalf of another user).
async function udsOwnerUserId (params) {
if (params && params.userId) return params.userId
const reg = registry()
const rootHex = svc.identity && svc.identity.rootPubkeyHex
if (rootHex) {
const u = await reg.getUserByIdentity(rootHex).catch(() => null)
if (u) return u.userId
}
const owner = await findOwnerUserId(reg)
if (!owner) {
throw new Error('no owner user yet — sign in via the web once to bootstrap the owner identity')
}
return owner
}
// HTTP → caller's own user (from session); UDS → the owner (local operator).
async function ownUserId (ctx, params) {
if (ctx && ctx.transport === 'uds') return udsOwnerUserId(params)
const userId = await resolveCallerUserId(ctx)
if (!userId) throw new AuthError('not authenticated')
return userId
}
// Resolve a requested TTL to an absolute expiry (epoch ms) or null.
// expiresAt (number) → used verbatim
// ttlDays (number > 0) → now + ttlDays
// ttlDays === 0 → no expiry (explicit opt-in)
// (neither) → DEFAULT_TTL_DAYS
function resolveExpiry (params, now) {
if (params && typeof params.expiresAt === 'number') return params.expiresAt
if (params && typeof params.ttlDays === 'number') {
return params.ttlDays > 0 ? now + params.ttlDays * DAY_MS : null
}
return now + DEFAULT_TTL_DAYS * DAY_MS
}
return [
{
name: 'create-access-token',
fn: async (ctx, params = {}) => {
const userId = await ownUserId(ctx, params)
const now = Date.now()
const minted = await mintToken(registry(), userId, {
name: params.name,
scope: params.scope || 'write',
expiresAt: resolveExpiry(params, now),
now
})
// Return the secret ONCE. Caller (UI/CLI) must surface it immediately.
return {
secret: minted.secret,
identityId: minted.identityId,
name: minted.name,
scope: minted.scope,
createdAt: minted.createdAt,
expiresAt: minted.expiresAt
}
}
},
{
name: 'list-access-tokens',
fn: async (ctx, params = {}) => {
return { tokens: await listTokens(registry(), await ownUserId(ctx, params)) }
}
},
{
name: 'revoke-access-token',
fn: async (ctx, params = {}) => {
const identityId = params && params.identityId
if (!identityId || identityId.indexOf('tok:') !== 0) {
throw new Error('revoke-access-token requires { identityId: "tok:…" }')
}
// Ownership: a token may be revoked by its owner, or by any UDS caller
// (local operator). Never let one HTTP user revoke another's token.
if (!(ctx && ctx.transport === 'uds')) {
const callerId = await ownUserId(ctx, params)
const owner = await registry().getUserByIdentity(identityId).catch(() => null)
if (!owner || owner.userId !== callerId) {
throw new AuthError('cannot revoke a token you do not own', 403)
}
}
await revokeToken(registry(), identityId)
return { revoked: true, identityId }
}
}
]
}