diff --git a/package.json b/package.json index e24ab1e..df8cf41 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,10 @@ "./vcdm": { "types": "./dist/VCDM.d.ts", "default": "./dist/VCDM.js" + }, + "./zcap": { + "types": "./dist/ZCap.d.ts", + "default": "./dist/ZCap.js" } }, "devDependencies": { diff --git a/src/ZCap.ts b/src/ZCap.ts new file mode 100644 index 0000000..5669d86 --- /dev/null +++ b/src/ZCap.ts @@ -0,0 +1,98 @@ +/*! + * Authorization Capabilities (zCap) interfaces. + * @see https://w3c-ccg.github.io/zcap-spec/ + */ + +/** + * A proof attached to a delegated zCap. + */ +export interface ICapabilityDelegationProof { + /** The cryptographic suite type (e.g. `'Ed25519Signature2020'`). */ + type: string + /** ISO 8601 date-time the proof was created. */ + created: string + /** Verification method URI used to sign. */ + verificationMethod: string + /** Always `'capabilityDelegation'`. */ + proofPurpose: 'capabilityDelegation' + /** + * Ordered capability chain (root → parent). All entries are string IDs + * except the last delegated zCap, which is embedded as an object. + */ + capabilityChain: Array + /** The encoded proof value. */ + proofValue: string +} + +/** + * A root authorization capability (zCap). Root zCaps are unsigned, have no + * `expires` field and no delegation proof. Their `id` follows the convention + * `urn:zcap:root:${encodeURIComponent(invocationTarget)}`. + */ +export interface IRootZcap { + /** The zCap JSON-LD context URL. */ + '@context': string + /** Capability ID (`urn:zcap:root:`). */ + id: string + /** The DID(s) authorized to invoke. */ + controller: string | string[] + /** Resource URI this capability grants access to (absolute URI). */ + invocationTarget: string +} + +/** + * A delegated authorization capability (zCap). Delegated capabilities narrow + * a parent capability and must carry exactly one `capabilityDelegation` proof. + */ +export interface IDelegatedZcap { + /** JSON-LD context array; first entry MUST be the zCap context URL. */ + '@context': string[] + /** Capability ID (absolute URI). */ + id: string + /** Parent capability ID (absolute URI). */ + parentCapability: string + /** The DID(s) authorized to invoke. */ + controller: string | string[] + /** Resource URI this capability grants access to (absolute URI). */ + invocationTarget: string + /** + * The action(s) the controller may perform; if absent, no actions are + * allowed (except for the root zCap). + */ + allowedAction?: string | string[] + /** ISO 8601 date-time when this capability expires. */ + expires: string + /** The capability delegation proof(s). */ + proof: ICapabilityDelegationProof | ICapabilityDelegationProof[] +} + +/** + * A zCap is either a root or a delegated authorization capability. + * + * Use this discriminated union when the call site knows it has a real, + * well-formed zCap and can narrow via `'parentCapability' in cap`. + */ +export type IZcap = IRootZcap | IDelegatedZcap + +/** + * A permissive zCap shape that conflates root and delegated kinds and admits + * arbitrary additional properties (e.g. the opaque object returned from + * `jsigs.sign()`). + * + * Prefer the strict `IZcap` union when possible. Use `IZcapLike` only when: + * - the call site receives a capability whose kind is not yet known and + * cannot reasonably discriminate, or + * - the call site needs to accept the open-ended shape produced by + * linked-data signature libraries that may attach extra properties. + */ +export interface IZcapLike { + '@context': string | string[] + id: string + invocationTarget: string + controller?: string | string[] + parentCapability?: string + allowedAction?: string | string[] + expires?: string + proof?: ICapabilityDelegationProof | ICapabilityDelegationProof[] + [key: string]: unknown +} diff --git a/src/index.ts b/src/index.ts index e0d77f6..5e9a955 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export * from './DID.js' export * from './KeyPair.js' export * from './VCDM.js' export * from './OBv3.js' +export * from './ZCap.js' diff --git a/test/ZCap.spec.ts b/test/ZCap.spec.ts new file mode 100644 index 0000000..55c8747 --- /dev/null +++ b/test/ZCap.spec.ts @@ -0,0 +1,74 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert' +import { + ICapabilityDelegationProof, + IDelegatedZcap, + IRootZcap, + IZcap, + IZcapLike +} from '../src' + +await describe('RootZcap', async () => { + await test('exists', async () => { + const root: IRootZcap = { + '@context': 'https://w3id.org/zcap/v1', + id: 'urn:zcap:root:https%3A%2F%2Fexample.com%2Fresource', + controller: 'did:example:alice', + invocationTarget: 'https://example.com/resource' + } + + assert.ok(root) + }) +}) + +await describe('DelegatedZcap', async () => { + await test('exists', async () => { + const proof: ICapabilityDelegationProof = { + type: 'Ed25519Signature2020', + created: '2026-01-01T00:00:00Z', + verificationMethod: 'did:example:alice#key-1', + proofPurpose: 'capabilityDelegation', + capabilityChain: [ + 'urn:zcap:root:https%3A%2F%2Fexample.com%2Fresource' + ], + proofValue: 'z3...' + } + + const delegated: IDelegatedZcap = { + '@context': ['https://w3id.org/zcap/v1'], + id: 'urn:uuid:00000000-0000-0000-0000-000000000001', + parentCapability: 'urn:zcap:root:https%3A%2F%2Fexample.com%2Fresource', + controller: 'did:example:bob', + invocationTarget: 'https://example.com/resource', + allowedAction: ['GET'], + expires: '2026-12-31T23:59:59Z', + proof + } + + assert.ok(delegated) + }) +}) + +await describe('IZcap union', async () => { + await test('accepts root and delegated', async () => { + const root: IZcap = { + '@context': 'https://w3id.org/zcap/v1', + id: 'urn:zcap:root:https%3A%2F%2Fexample.com%2Fr', + controller: 'did:example:alice', + invocationTarget: 'https://example.com/r' + } + assert.ok(!('parentCapability' in root)) + }) +}) + +await describe('IZcapLike', async () => { + await test('admits extra properties', async () => { + const loose: IZcapLike = { + '@context': 'https://w3id.org/zcap/v1', + id: 'urn:zcap:root:https%3A%2F%2Fexample.com%2Fr', + invocationTarget: 'https://example.com/r', + arbitraryExtra: 'allowed' + } + assert.ok(loose) + }) +})