delegation.ts
1 import * as Client from '@ucanto/client'; 2 import * as Core from '@ucanto/core'; 3 import * as Principal from '@ucanto/principal/ed25519'; 4 import { keypairToPrincipal } from './principal.js'; 5 import { 6 capabilitySchema, 7 delegationSchema, 8 verificationResultSchema, 9 type Capability, 10 type Delegation, 11 type VerificationResult 12 } from './schemas.js'; 13 import { type Keypair, type DID, publicKeyToDID } from '@protocol/identity'; 14 15 /** 16 * Issue a UCAN delegation using ucanto 17 */ 18 export async function delegate(params: { 19 issuer: Keypair; 20 audience: DID; 21 capabilities: Capability[]; 22 expiration?: number; // Unix timestamp in milliseconds 23 notBefore?: number; 24 facts?: Record<string, unknown>; 25 proofs?: Delegation[]; // Parent delegations 26 }): Promise<Delegation> { 27 const { 28 issuer, 29 audience, 30 capabilities, 31 expiration, 32 notBefore, 33 facts, 34 proofs = [] 35 } = params; 36 37 // Validate capabilities 38 capabilities.forEach(cap => capabilitySchema.parse(cap)); 39 40 // Convert issuer keypair to ucanto Principal 41 const issuerPrincipal = await keypairToPrincipal(issuer); 42 43 // Parse audience DID using Verifier 44 const audiencePrincipal = Principal.Verifier.parse(audience); 45 46 // Create delegation using ucanto 47 const delegation = await Client.delegate({ 48 issuer: issuerPrincipal, 49 audience: audiencePrincipal, 50 capabilities: capabilities.map(cap => ({ 51 can: cap.can, 52 with: cap.with, 53 ...(cap.nb && { nb: cap.nb }) 54 })), 55 ...(expiration && { expiration: Math.floor(expiration / 1000) }), 56 ...(notBefore && { notBefore: Math.floor(notBefore / 1000) }), 57 ...(facts && { facts }), 58 ...(proofs.length > 0 && { 59 proofs: await Promise.all( 60 proofs.map(async p => { 61 const archive = Buffer.from(p.token, 'base64'); 62 const result = await Core.Delegation.extract(archive); 63 if (!result.ok) { 64 throw new Error('Failed to extract proof delegation'); 65 } 66 return result.ok; 67 }) 68 ) 69 }) 70 }); 71 72 // Archive the delegation to CAR format 73 const archive = await delegation.archive(); 74 if (!archive.ok) { 75 throw new Error('Failed to archive delegation'); 76 } 77 78 // Encode as base64 token 79 const token = Buffer.from(archive.ok).toString('base64'); 80 81 // Build payload 82 const payload = { 83 iss: issuerPrincipal.did(), 84 aud: audiencePrincipal.did(), 85 att: capabilities, 86 exp: delegation.expiration || Math.floor((Date.now() + 3600000) / 1000), 87 nbf: delegation.notBefore, 88 prf: proofs.map(p => p.token), 89 fct: facts 90 }; 91 92 return delegationSchema.parse({ token, payload }); 93 } 94 95 /** 96 * Verify a UCAN delegation 97 */ 98 export async function verify(token: string): Promise<VerificationResult> { 99 try { 100 // Decode from base64 101 const archive = Buffer.from(token, 'base64'); 102 103 // Extract delegation 104 const result = await Core.Delegation.extract(archive); 105 106 if (!result.ok) { 107 return verificationResultSchema.parse({ 108 valid: false, 109 error: 'Failed to parse delegation' 110 }); 111 } 112 113 const delegation = result.ok; 114 115 // Extract capabilities 116 const capabilities: Capability[] = delegation.capabilities.map((cap: any) => ({ 117 with: cap.with, 118 can: cap.can, 119 ...(cap.nb && { nb: cap.nb }) 120 })); 121 122 // Build payload 123 const payload = { 124 iss: delegation.issuer.did(), 125 aud: delegation.audience.did(), 126 att: capabilities, 127 exp: delegation.expiration || 0, 128 nbf: delegation.notBefore, 129 prf: delegation.proofs.map((p: any) => { 130 // Convert proof CID to string 131 return p.cid ? p.cid.toString() : ''; 132 }).filter(Boolean), 133 // Handle facts - ucanto may return array, but we expect object or undefined 134 ...(delegation.facts && !Array.isArray(delegation.facts) && { fct: delegation.facts }) 135 }; 136 137 // Check expiration 138 const now = Math.floor(Date.now() / 1000); 139 if (delegation.expiration && delegation.expiration < now) { 140 return verificationResultSchema.parse({ 141 valid: false, 142 error: 'UCAN has expired', 143 payload 144 }); 145 } 146 147 // Check not before 148 if (delegation.notBefore && delegation.notBefore > now) { 149 return verificationResultSchema.parse({ 150 valid: false, 151 error: 'UCAN not yet valid', 152 payload 153 }); 154 } 155 156 return verificationResultSchema.parse({ 157 valid: true, 158 payload, 159 capabilities 160 }); 161 } catch (error) { 162 return verificationResultSchema.parse({ 163 valid: false, 164 error: error instanceof Error ? error.message : 'Unknown error' 165 }); 166 } 167 } 168 169 /** 170 * Create an invocation (for use with ucanto server) 171 */ 172 export async function invoke(params: { 173 issuer: Keypair; 174 audience: DID; 175 capability: Capability; 176 proofs?: Delegation[]; 177 }) { 178 const { issuer, audience, capability, proofs = [] } = params; 179 180 // Validate capability 181 capabilitySchema.parse(capability); 182 183 // Convert issuer to principal 184 const issuerPrincipal = await keypairToPrincipal(issuer); 185 const audiencePrincipal = Principal.Verifier.parse(audience); 186 187 // Create invocation 188 return await Client.invoke({ 189 issuer: issuerPrincipal, 190 audience: audiencePrincipal, 191 capability: { 192 can: capability.can, 193 with: capability.with, 194 ...(capability.nb && { nb: capability.nb }) 195 }, 196 ...(proofs.length > 0 && { 197 proofs: await Promise.all( 198 proofs.map(async p => { 199 const archive = Buffer.from(p.token, 'base64'); 200 const result = await Core.Delegation.extract(archive); 201 if (!result.ok) { 202 throw new Error('Failed to extract proof'); 203 } 204 return result.ok; 205 }) 206 ) 207 }) 208 }); 209 } 210 211 /** 212 * Extract capabilities from a UCAN token 213 */ 214 export async function extractCapabilities(token: string): Promise<Capability[]> { 215 const result = await verify(token); 216 217 if (!result.valid || !result.capabilities) { 218 return []; 219 } 220 221 return result.capabilities; 222 } 223 224 /** 225 * Check if a UCAN is expired 226 */ 227 export async function isExpired(token: string): Promise<boolean> { 228 const result = await verify(token); 229 return !result.valid && (result.error?.includes('expired') ?? false); 230 }