/ ucanto / src / delegation.ts
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  }