// Unit: access-token RPC handlers (create / list / revoke) including the
// self-service auth model and the revoke ownership guard.
import test from 'brittle'
import { accessTokenHandlers } from '../lib/handlers/access-tokens.js'
import { canPushGit } from '../lib/auth/git-acl.js'
// In-memory IdentityRegistry stand-in (same observable contract the handlers
// and access-token.js rely on).
function memRegistry () {
const identities = new Map()
const link = new Map()
const users = new Map()
const revoked = new Set()
return {
_addUser (userId, user) { users.set(userId, { userId, identities: [], ...user }) },
// link a web-identity pubkey → user so ctx.identity.publicKey resolves
_linkWeb (pubkey, userId) {
identities.set(pubkey, { identityId: pubkey, method: 'web-identity' })
link.set(pubkey, userId)
const u = users.get(userId); if (u) u.identities.push(pubkey)
},
async registerIdentity (identity) { identities.set(identity.identityId, { ...identity }) },
async linkIdentityToUser (identityId, userId, opts = {}) {
if (!opts.verified) throw new Error('requires verified')
link.set(identityId, userId)
const u = users.get(userId)
if (u) { u.identities = u.identities || []; if (!u.identities.includes(identityId)) u.identities.push(identityId) }
},
async getIdentity (id) { return identities.get(id) || null },
async getUserByIdentity (id) { const uid = link.get(id); return uid ? (users.get(uid) || null) : null },
async getUser (uid) { return users.get(uid) || null },
async listUsers () { return Array.from(users.values()) },
async getUserWithIdentities (uid) {
const user = users.get(uid); if (!user) return null
const ids = (user.identities || []).map((i) => identities.get(i)).filter(Boolean)
return { user, identities: ids }
},
async isRevoked (id) { return revoked.has(id) },
async revokeIdentity (id) { revoked.add(id) } // separate index, record untouched (matches real registry)
}
}
function handlers (reg, identity) {
const svc = { identityRegistry: reg, identity: identity || null }
const map = {}
for (const { name, fn } of accessTokenHandlers(svc)) map[name] = fn
return map
}
const httpCtx = (pubkey) => ({ transport: 'http', identity: { publicKey: pubkey } })
const udsCtx = () => ({ transport: 'uds' })
test('create-access-token: HTTP caller mints for self, secret returned once', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice')
const h = handlers(reg)
const out = await h['create-access-token'](httpCtx('alice-pub'), { name: 'laptop' })
t.ok(out.secret.startsWith('pgv3_'), 'returns a pgv3_ secret')
t.is(out.scope, 'write', 'default write scope')
t.ok(out.expiresAt > Date.now(), 'default expiry is in the future (90d)')
// and it actually authorizes a push
t.ok(await canPushGit(reg, { transport: 'http', token: out.secret }), 'minted token can push')
})
test('create-access-token: ttlDays:0 → no expiry', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice')
const h = handlers(reg)
const out = await h['create-access-token'](httpCtx('alice-pub'), { ttlDays: 0 })
t.is(out.expiresAt, null, 'explicit no-expiry')
})
test('create-access-token: UDS mints for explicit userId, else the owner', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._addUser('owner', { globalPermissions: ['admin'] })
const h = handlers(reg, null)
const explicit = await h['create-access-token'](udsCtx(), { userId: 'alice' })
t.ok(explicit.secret.startsWith('pgv3_'), 'explicit userId honoured')
const def = await h['create-access-token'](udsCtx(), {})
t.ok(def.secret.startsWith('pgv3_'), 'no userId → defaults to owner')
})
test('create-access-token: UDS resolves owner via hyperident root identity', async (t) => {
const reg = memRegistry()
reg._addUser('owner', { globalPermissions: ['admin'] })
reg._linkWeb('root-pub', 'owner') // hyperident root linked to the owner user
const h = handlers(reg, { rootPubkeyHex: 'root-pub' })
const out = await h['create-access-token'](udsCtx(), {})
t.ok(out.secret.startsWith('pgv3_'), 'minted for the hyperident owner')
const { tokens } = await h['list-access-tokens'](udsCtx(), {})
t.is(tokens.length, 1, 'token belongs to the owner')
})
test('create-access-token: UDS with no owner yet → clear error', async (t) => {
const reg = memRegistry()
reg._addUser('reader', { globalPermissions: ['read'] }) // no admin exists
const h = handlers(reg, null)
await t.exception(() => h['create-access-token'](udsCtx(), {}), /no owner user yet/)
})
test('list-access-tokens: metadata only, no secret', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice')
const h = handlers(reg)
await h['create-access-token'](httpCtx('alice-pub'), { name: 'laptop' })
const { tokens } = await h['list-access-tokens'](httpCtx('alice-pub'), {})
t.is(tokens.length, 1, 'one token listed')
t.is(tokens[0].name, 'laptop', 'name surfaced')
t.absent('secret' in tokens[0], 'no secret in listing')
})
test('revoke-access-token: owner can revoke; push then denied', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice')
const h = handlers(reg)
const minted = await h['create-access-token'](httpCtx('alice-pub'), {})
const res = await h['revoke-access-token'](httpCtx('alice-pub'), { identityId: minted.identityId })
t.ok(res.revoked, 'revoked')
t.absent(await canPushGit(reg, { transport: 'http', token: minted.secret }), 'revoked token cannot push')
const { tokens } = await h['list-access-tokens'](httpCtx('alice-pub'), {})
t.is(tokens.length, 0, 'gone from listing')
})
test('revoke-access-token: cannot revoke another user\'s token', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._addUser('bob', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice')
reg._linkWeb('bob-pub', 'bob')
const h = handlers(reg)
const aliceTok = await h['create-access-token'](httpCtx('alice-pub'), {})
await t.exception(
() => h['revoke-access-token'](httpCtx('bob-pub'), { identityId: aliceTok.identityId }),
/do not own/
)
// alice's token still works
t.ok(await canPushGit(reg, { transport: 'http', token: aliceTok.secret }), 'untouched by bob')
})
test('revoke-access-token: rejects non-token identityId', async (t) => {
const reg = memRegistry()
reg._addUser('alice', { globalPermissions: ['write'] })
reg._linkWeb('alice-pub', 'alice')
const h = handlers(reg)
await t.exception(
() => h['revoke-access-token'](httpCtx('alice-pub'), { identityId: 'ssh:SHA256:x' }),
/requires \{ identityId/
)
})