From 1c55cf3d72df3a5dca70522efca010cbda7d14d8 Mon Sep 17 00:00:00 2001 From: Felipe Forbeck Date: Mon, 26 May 2025 16:39:25 -0300 Subject: [PATCH 1/3] feat: did-plc package --- packages/did-plc/README.md | 72 ++++++++++++++++++++++++ packages/did-plc/package.json | 53 ++++++++++++++++++ packages/did-plc/src/index.js | 81 +++++++++++++++++++++++++++ packages/did-plc/src/types.ts | 30 ++++++++++ packages/did-plc/src/utils.js | 14 +++++ packages/did-plc/test/did-plc.spec.js | 70 +++++++++++++++++++++++ packages/did-plc/tsconfig.json | 12 ++++ packages/did-plc/tsconfig.lib.json | 5 ++ packages/did-plc/tsconfig.spec.json | 12 ++++ pnpm-lock.yaml | 74 ++++++++++++++++++++---- tsconfig.json | 3 + 11 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 packages/did-plc/README.md create mode 100644 packages/did-plc/package.json create mode 100644 packages/did-plc/src/index.js create mode 100644 packages/did-plc/src/types.ts create mode 100644 packages/did-plc/src/utils.js create mode 100644 packages/did-plc/test/did-plc.spec.js create mode 100644 packages/did-plc/tsconfig.json create mode 100644 packages/did-plc/tsconfig.lib.json create mode 100644 packages/did-plc/tsconfig.spec.json diff --git a/packages/did-plc/README.md b/packages/did-plc/README.md new file mode 100644 index 000000000..963d485b3 --- /dev/null +++ b/packages/did-plc/README.md @@ -0,0 +1,72 @@ +# @storacha/did-plc + +Universal utilities for working with the **`did:plc`** method (Node & browser). + +## Features + +- Resolve a `did:plc` to its DID-Document via the public PLC directory. +- `PlcClient.verifyOwnership` – verify that an arbitrary message was signed by the **current owner** of a `did:plc`. +- `parseDidPlc` – lightweight validator / canonicaliser for `did:plc` strings. +- Works everywhere (`fetch` polyfilled for Node, WebCrypto for signature checks). + +## Install + +```bash +pnpm add @storacha/did-plc +``` + +## API + +### `PlcClient` + +```ts +import { PlcClient } from '@storacha/did-plc' + +const client = new PlcClient() // optionally: new PlcClient({ directoryUrl }) +const doc = await client.getDocument('did:plc:ewvi7nxzyoun6zhxrhs64oiz') +``` + +#### `verifyOwnership(did, message, signature)` + +```ts +const ok = await client.verifyOwnership( + 'did:plc:ewvi7nxzyoun6zhxrhs64oiz', + 'hello world', + 'BASE64URL_SIGNATURE' // Ed25519, base64url string +) +``` + +Returns `true` if **any** verificationMethod in the current DID-Document validates the signature. + +### `parseDidPlc(input)` + +```ts +import { parseDidPlc } from '@storacha/did-plc' + +const did = parseDidPlc(' DID:PLC:EWVI7NXZYOUN6ZHXRHS64OIZ ') +// => 'did:plc:ewvi7nxzyoun6zhxrhs64oiz' +``` + +Throws if the string is not a valid `did:plc`. + +## Examples + +```ts +import { PlcClient, parseDidPlc } from '@storacha/did-plc' + +const client = new PlcClient() +const did = parseDidPlc('did:plc:ewvi7nxzyoun6zhxrhs64oiz') +const doc = await client.getDocument(did) + +// ownership proof (base64url Ed25519 signature) +const ok = await client.verifyOwnership(did, 'hello world', signatureB64Url) +``` + +--- + +MIT OR Apache-2.0 + +## References + +- [did-method-plc](https://github.com/did-method-plc/did-method-plc/tree/main) +- [storacha/bluesky-backup-webapp-server plc.ts](https://github.com/storacha/bluesky-backup-webapp-server/blob/main/src/lib/plc.ts) diff --git a/packages/did-plc/package.json b/packages/did-plc/package.json new file mode 100644 index 000000000..de0ae70eb --- /dev/null +++ b/packages/did-plc/package.json @@ -0,0 +1,53 @@ +{ + "name": "@storacha/did-plc", + "version": "0.1.0", + "description": "Universal resolver for did:plc DIDs (Node & browser)", + "type": "module", + "main": "src/index.js", + "types": "dist/src/types.d.ts", + "files": [ + "dist", + "!dist/**/*.js.map" + ], + "exports": { + ".": "./dist/index.js", + "./types": "./dist/types.js" + }, + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch --preserveWatchOutput", + "clean": "rm -rf dist *.tsbuildinfo", + "test": "mocha --timeout 10s --require ts-node/register test/**/*.spec.js" + }, + "dependencies": { + "@noble/ed25519": "^2.2.3", + "@ucanto/principal": "catalog:", + "base64url": "^3.0.1", + "cross-fetch": "^4.0.0", + "multiformats": "catalog:" + }, + "devDependencies": { + "@types/mocha": "^10.0.1", + "@typescript-eslint/eslint-plugin": "catalog:", + "mocha": "^10.2.0", + "ts-node": "^10.9.1", + "typescript": "catalog:" + }, + "eslintConfig": { + "extends": [ + "@storacha/eslint-config" + ], + "env": { + "mocha": true + }, + "ignorePatterns": [ + "dist", + "coverage", + "src/types.js" + ] + }, + "engines": { + "node": ">=16.15" + }, + "license": "Apache-2.0 OR MIT" +} diff --git a/packages/did-plc/src/index.js b/packages/did-plc/src/index.js new file mode 100644 index 000000000..f51b7d83a --- /dev/null +++ b/packages/did-plc/src/index.js @@ -0,0 +1,81 @@ +import { universalFetch } from './utils.js' +import * as ed25519 from '@noble/ed25519' +import base64url from 'base64url' +import { base58btc } from 'multiformats/bases/base58' + + + +/** + * PLC Directory Client for did:plc operations. + */ +export class PlcClient { + /** + * @param {Object} [opts] + * @param {string} [opts.directoryUrl] - Base URL for PLC directory + */ + constructor(opts = {}) { + this.directoryUrl = opts.directoryUrl || 'https://plc.directory' + } + + /** + * Resolve a did:plc to its DID Document. + * + * @param {import('./types.js').DidPlc} did + * @returns {Promise} + * @throws {Error} If the DID cannot be resolved + */ + async getDocument(did) { + const res = await universalFetch(`${this.directoryUrl}/${encodeURIComponent(did)}`) + if (!res.ok) throw new Error(`Failed to resolve ${did}`) + return await res.json() + } + + /** + * Verifies that a message was signed by the current owner of the did:plc. + * It verifies all the verification methods in the DID Document to find at + * least one that matches the signature. + * + * @param {import('./types.js').DidPlc} did - The did:plc identifier. + * @param {Uint8Array|string} message - The message that was signed. + * @param {string} signature - The signature to verify (base64url string). + * @returns {Promise} True if valid, false otherwise. + */ + async verifyOwnership(did, message, signature) { + try { + const doc = await this.getDocument(did) + const vms = doc.verificationMethod || [] + const sigBytes = base64url.default.toBuffer(signature) + const msgBytes = typeof message === 'string' ? new TextEncoder().encode(message) : message + + for (const vm of vms) { + if (!vm.publicKeyMultibase) continue + let pubKey + try { + pubKey = base58btc.decode(vm.publicKeyMultibase) + } catch { + continue + } + if (await ed25519.verify(sigBytes, msgBytes, pubKey)) { + return true + } + } + return false + } catch (e) { + return false + } + } + +} + +/** + * Parse a string and ensure it is a valid did:plc. + * Returns the canonical form (lower-cased). + * + * @param {string} input + * @returns {import('./types.js').DidPlc} + */ +export function parseDidPlc(input) { + const m = /^did:plc:([a-z0-9]{32})$/i.exec(input.trim()) + if (!m) throw new Error(`Invalid did:plc: ${input}`) + return /** @type {const} */ (`did:plc:${m[1].toLowerCase()}`) +} diff --git a/packages/did-plc/src/types.ts b/packages/did-plc/src/types.ts new file mode 100644 index 000000000..273f10753 --- /dev/null +++ b/packages/did-plc/src/types.ts @@ -0,0 +1,30 @@ +import { DIDKey } from "@ucanto/principal/ed25519"; + +export type DidPlc = `did:plc:${string}` + +export interface PlcOperation { + type: 'plc_operation'; + verificationMethods: Record; + rotationKeys: string[]; + alsoKnownAs?: string[]; + services?: Record; + prev?: string | null; + sig: string; +} + +export interface DidPlcDocument { + '@context': string[]; + id: DidPlc; + alsoKnownAs?: string[]; + verificationMethod?: Array<{ + id: string; + type: string; + controller: string; + publicKeyMultibase: string; + }>; + service?: Array<{ + id: string; + type: string; + serviceEndpoint: string; + }>; +} \ No newline at end of file diff --git a/packages/did-plc/src/utils.js b/packages/did-plc/src/utils.js new file mode 100644 index 000000000..750fc7777 --- /dev/null +++ b/packages/did-plc/src/utils.js @@ -0,0 +1,14 @@ +import { fetch } from 'cross-fetch' + +/** + * Universal fetch helper for Node and browser + * + * @see https://github.com/lifaon76/cross-fetch + * + * @param {RequestInfo} input + * @param {RequestInit=} init + * @returns {Promise} + */ +export async function universalFetch(input, init) { + return fetch(input, init) +} \ No newline at end of file diff --git a/packages/did-plc/test/did-plc.spec.js b/packages/did-plc/test/did-plc.spec.js new file mode 100644 index 000000000..12f662c23 --- /dev/null +++ b/packages/did-plc/test/did-plc.spec.js @@ -0,0 +1,70 @@ +import * as assert from 'assert' +import { PlcClient } from '../src/index.js' +import * as ed25519 from '@noble/ed25519' +import { base58btc } from 'multiformats/bases/base58' +import base64url from 'base64url' +import { createHash } from 'crypto' +import { Buffer } from 'buffer' + +ed25519.etc.sha512Sync = (msg) => createHash('sha512').update(msg).digest() + +/** + * Universal Uint8Array to base64url string (works in Node and browser) + * + * @param {Uint8Array} uint8 + * @returns {string} + */ +function uint8ToBase64url(uint8) { + return base64url.default.encode(Buffer.from(uint8)) +} + +describe('DID PLC Client', () => { + const client = new PlcClient() + + it('should resolve a real did:plc to a DID Document', async () => { + const did = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz' + const doc = await client.getDocument(did) + assert.strictEqual(doc.id, did) + assert.ok(Array.isArray(doc['@context'])) + assert.ok(doc.verificationMethod) + }) +}) + +describe('PlcClient.verifyOwnership', () => { + it('should verify a valid signature for a known key', async () => { + // Generate a test keypair + const secretKey = ed25519.utils.randomPrivateKey() + const publicKey = await ed25519.getPublicKey(secretKey) + const publicKeyMultibase = base58btc.encode(publicKey) + + // Fake a DID document with this key + const client = new PlcClient() + client.getDocument = async () => ({ + '@context': [], + id: 'did:plc:fake', + verificationMethod: [ + { + id: '#test-key', + type: 'Ed25519VerificationKey2020', + controller: 'did:plc:fake', + publicKeyMultibase, + } + ] + }) + const message = 'hello world' + const msgBytes = new TextEncoder().encode(message) + const signature = await ed25519.sign(msgBytes, secretKey) + const signatureB64Url = uint8ToBase64url(signature) + + const valid = await client.verifyOwnership('did:plc:fake', message, signatureB64Url) + assert.strictEqual(valid, true) + + // Negative test: wrong signature + const invalid = await client.verifyOwnership( + 'did:plc:fake', + message, + uint8ToBase64url(ed25519.utils.randomPrivateKey()) + ) + assert.strictEqual(invalid, false) + }) +}) \ No newline at end of file diff --git a/packages/did-plc/tsconfig.json b/packages/did-plc/tsconfig.json new file mode 100644 index 000000000..52c469ee2 --- /dev/null +++ b/packages/did-plc/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/did-plc/tsconfig.lib.json b/packages/did-plc/tsconfig.lib.json new file mode 100644 index 000000000..41a9fdfa1 --- /dev/null +++ b/packages/did-plc/tsconfig.lib.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "references": [] +} diff --git a/packages/did-plc/tsconfig.spec.json b/packages/did-plc/tsconfig.spec.json new file mode 100644 index 000000000..f735b79ea --- /dev/null +++ b/packages/did-plc/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": ["test/**/*"], + "compilerOptions": { + "rootDir": "." + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 715d43729..1cd89c02e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -872,6 +872,40 @@ importers: specifier: 'catalog:' version: 5.8.3 + packages/did-plc: + dependencies: + '@noble/ed25519': + specifier: ^2.2.3 + version: 2.2.3 + '@ucanto/principal': + specifier: 'catalog:' + version: 9.0.2 + base64url: + specifier: ^3.0.1 + version: 3.0.1 + cross-fetch: + specifier: ^4.0.0 + version: 4.1.0(encoding@0.1.13) + multiformats: + specifier: 'catalog:' + version: 13.3.3 + devDependencies: + '@types/mocha': + specifier: ^10.0.1 + version: 10.0.10 + '@typescript-eslint/eslint-plugin': + specifier: 'catalog:' + version: 8.26.1(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + mocha: + specifier: ^10.2.0 + version: 10.8.2 + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@swc/core@1.11.11(@swc/helpers@0.5.15))(@types/node@22.13.10)(typescript@5.8.3) + typescript: + specifier: 'catalog:' + version: 5.8.3 + packages/encrypt-upload-client: dependencies: '@ipld/car': @@ -4065,6 +4099,9 @@ packages: '@noble/ed25519@1.7.3': resolution: {integrity: sha512-iR8GBkDt0Q3GyaVcIu7mSsVIqnFbkbRzGLWlvhwunacoLwt4J3swfKhfaM6rN6WY+TBGoYT1GtT1mIh2/jGbRQ==} + '@noble/ed25519@2.2.3': + resolution: {integrity: sha512-iHV8eI2mRcUmOx159QNrU8vTpQ/Xm70yJ2cTk3Trc86++02usfqFoNl6x0p3JN81ZDS/1gx6xiK0OwrgqCT43g==} + '@noble/hashes@1.7.0': resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==} engines: {node: ^14.21.3 || >=16} @@ -7257,6 +7294,9 @@ packages: cross-fetch@3.1.8: resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -10417,6 +10457,9 @@ packages: multiformats@13.3.3: resolution: {integrity: sha512-TlaFCzs3NHNzMpwiGwRYehnnhHlZcWfptygFekshlb9xCyO09GfN+9881+VBENCdRnKOeqmMxDCbupNecV8xRQ==} + multiformats@13.3.6: + resolution: {integrity: sha512-yakbt9cPYj8d3vi/8o/XWm61MrOILo7fsTL0qxNx6zS0Nso6K5JqqS2WV7vK/KSuDBvrW3KfCwAdAgarAgOmww==} + multiformats@9.9.0: resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} @@ -10956,6 +10999,7 @@ packages: path-match@1.2.4: resolution: {integrity: sha512-UWlehEdqu36jmh4h5CWJ7tARp1OEVKGHKm6+dg9qMq5RKUTV5WJrGgaZ3dN2m7WFAXDbjlHzvJvL/IUpy84Ktw==} + deprecated: This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -15575,7 +15619,7 @@ snapshots: '@ipld/dag-pb@4.1.3': dependencies: - multiformats: 13.3.3 + multiformats: 13.3.6 '@ipld/dag-ucan@3.4.5': dependencies: @@ -15604,7 +15648,7 @@ snapshots: '@multiformats/murmur3': 2.1.8 '@perma/map': 1.0.3 actor: 2.3.1 - multiformats: 13.3.3 + multiformats: 13.3.6 protobufjs: 7.4.0 rabin-rs: 2.1.0 @@ -16565,7 +16609,7 @@ snapshots: '@multiformats/blake2@2.0.2': dependencies: blakejs: 1.2.1 - multiformats: 13.3.3 + multiformats: 13.3.6 '@multiformats/murmur3@1.1.3': dependencies: @@ -16574,13 +16618,13 @@ snapshots: '@multiformats/murmur3@2.1.8': dependencies: - multiformats: 13.3.3 + multiformats: 13.3.6 murmurhash3js-revisited: 3.0.0 '@multiformats/sha3@3.0.2': dependencies: js-sha3: 0.9.3 - multiformats: 13.3.3 + multiformats: 13.3.6 '@napi-rs/wasm-runtime@0.2.4': dependencies: @@ -16638,6 +16682,8 @@ snapshots: '@noble/ed25519@1.7.3': {} + '@noble/ed25519@2.2.3': {} + '@noble/hashes@1.7.0': {} '@noble/hashes@1.7.1': {} @@ -18867,7 +18913,7 @@ snapshots: '@noble/ed25519': 1.7.3 '@noble/hashes': 1.7.1 '@ucanto/interface': 10.3.0 - multiformats: 13.3.3 + multiformats: 13.3.6 one-webcrypto: 1.0.3 '@ucanto/server@10.2.0': @@ -19604,7 +19650,7 @@ snapshots: '@ucanto/interface': 10.3.0 '@web3-storage/capabilities': 18.0.1 carstream: 2.3.0 - multiformats: 13.3.3 + multiformats: 13.3.6 uint8arrays: 5.1.0 '@web3-storage/capabilities@17.4.1': @@ -19632,7 +19678,7 @@ snapshots: '@multiformats/blake2': 2.0.2 '@multiformats/murmur3': 2.1.8 '@multiformats/sha3': 3.0.2 - multiformats: 13.3.3 + multiformats: 13.3.6 uint8arrays: 5.1.0 '@web3-storage/clock@0.4.1': @@ -19647,7 +19693,7 @@ snapshots: '@ucanto/validator': 9.1.0 '@web3-storage/pail': 0.5.0 hashlru: 2.3.0 - multiformats: 13.3.3 + multiformats: 13.3.6 p-retry: 6.2.1 '@web3-storage/content-claims@5.2.1': @@ -19710,7 +19756,7 @@ snapshots: archy: 1.0.0 carstream: 2.3.0 cli-color: 2.0.4 - multiformats: 13.3.3 + multiformats: 13.3.6 sade: 1.8.1 '@web3-storage/parse-link-header@3.1.0': {} @@ -21017,6 +21063,12 @@ snapshots: transitivePeerDependencies: - encoding + cross-fetch@4.1.0(encoding@0.1.13): + dependencies: + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -24872,6 +24924,8 @@ snapshots: multiformats@13.3.3: {} + multiformats@13.3.6: {} + multiformats@9.9.0: {} multihashes@4.0.3: diff --git a/tsconfig.json b/tsconfig.json index f1b8df9d0..1055092ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,6 +69,9 @@ }, { "path": "./packages/ucn" + }, + { + "path": "./packages/did-plc" } ] } From ccfdb0e8ae9c3236be5baf1245decdb9f7bf41ec Mon Sep 17 00:00:00 2001 From: Felipe Forbeck Date: Wed, 28 May 2025 12:07:12 -0300 Subject: [PATCH 2/3] feat: add support for did:plc - wip --- packages/access-client/src/space.js | 4 ++-- packages/capabilities/src/access.js | 6 +++--- packages/capabilities/src/types.ts | 2 +- packages/capabilities/src/utils.js | 17 +++++++++++++++-- packages/did-plc/package.json | 4 ++-- packages/did-plc/src/index.js | 4 +--- packages/did-plc/src/indext.d.ts | 1 + packages/did-plc/src/types.ts | 4 +--- packages/upload-api/src/access/claim.js | 8 ++++++-- packages/w3up-client/src/account.js | 7 ++++--- packages/w3up-client/src/client.js | 3 ++- 11 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 packages/did-plc/src/indext.d.ts diff --git a/packages/access-client/src/space.js b/packages/access-client/src/space.js index 4cea080a7..4a55bdd2b 100644 --- a/packages/access-client/src/space.js +++ b/packages/access-client/src/space.js @@ -62,7 +62,7 @@ export const toMnemonic = ({ signer }) => { /** * Creates a (UCAN) delegation that gives full access to the space to the - * specified `account`. At the moment we only allow `did:mailto` principal + * specified `account`. At the moment we allow `did:mailto` and `did:plc` principal * to be used as an `account`. * * @template {Record} [S=API.Service] @@ -203,7 +203,7 @@ export class OwnedSpace { /** * Creates a (UCAN) delegation that gives full access to the space to the - * specified `account`. At the moment we only allow `did:mailto` principal + * specified `account`. At the moment we allow `did:mailto` and `did:plc` principal * to be used as an `account`. * * @param {API.AccountDID} account diff --git a/packages/capabilities/src/access.js b/packages/capabilities/src/access.js index 1a80380ba..50a7ab05f 100644 --- a/packages/capabilities/src/access.js +++ b/packages/capabilities/src/access.js @@ -11,7 +11,7 @@ import { capability, URI, DID, Schema, fail, ok } from '@ucanto/validator' import * as Types from '@ucanto/interface' import { attest } from './ucan.js' -import { equalWith, equal, and, SpaceDID, checkLink } from './utils.js' +import { equalWith, equal, and, SpaceDID, checkLink, PlcDID } from './utils.js' export { top } from './top.js' /** @@ -22,7 +22,7 @@ export const session = attest /** * Account identifier. */ -export const Account = DID.match({ method: 'mailto' }) +export const Account = DID.match({ method: 'mailto' }).or(PlcDID) /** * Describes the capability requested. @@ -112,7 +112,7 @@ export const confirm = capability({ export const claim = capability({ can: 'access/claim', - with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })), + with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })).or(PlcDID), }) // https://github.com/storacha/specs/blob/main/w3-access.md#accessdelegate diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index b0f29b254..a1fd65928 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -59,7 +59,7 @@ export type CARLink = Link export type Multihash = Uint8Array -export type AccountDID = DID<'mailto'> +export type AccountDID = DID<'mailto'> | DID<'plc'> export type SpaceDID = DID<'key'> /** diff --git a/packages/capabilities/src/utils.js b/packages/capabilities/src/utils.js index 796fb22e3..c39460a11 100644 --- a/packages/capabilities/src/utils.js +++ b/packages/capabilities/src/utils.js @@ -3,12 +3,25 @@ import { DID, Schema, fail, ok } from '@ucanto/validator' import { equals } from 'multiformats/bytes' import { base58btc } from 'multiformats/bases/base58' -// e.g. did:web:storacha.network or did:web:staging.storacha.network +/** + * Example: did:plc:ewvi7nxzyoun6zhxrhs64oiz + */ +export const PlcDID = DID.match({ method: 'plc' }) + +/** + * Example: did:web:storacha.network or did:web:staging.storacha.network + */ export const ProviderDID = DID.match({ method: 'web' }) +/** + * Example: did:key:z6MkiBeiHFA6kbA2mchg1F9juxCuHuLgymzJpanKswpBZmQT + */ export const SpaceDID = DID.match({ method: 'key' }) -export const AccountDID = DID.match({ method: 'mailto' }) +/** + * Example: did:mailto:storacha.network:alice or did:plc:ewvi7nxzyoun6zhxrhs64oiz + */ +export const AccountDID = DID.match({ method: 'mailto' }).or(PlcDID) export const Await = Schema.struct({ 'ucan/await': Schema.tuple([Schema.string(), Schema.link()]), diff --git a/packages/did-plc/package.json b/packages/did-plc/package.json index de0ae70eb..7c1e95a18 100644 --- a/packages/did-plc/package.json +++ b/packages/did-plc/package.json @@ -1,10 +1,10 @@ { "name": "@storacha/did-plc", - "version": "0.1.0", + "version": "0.1.4", "description": "Universal resolver for did:plc DIDs (Node & browser)", "type": "module", "main": "src/index.js", - "types": "dist/src/types.d.ts", + "types": "dist/index.d.ts", "files": [ "dist", "!dist/**/*.js.map" diff --git a/packages/did-plc/src/index.js b/packages/did-plc/src/index.js index f51b7d83a..65c321977 100644 --- a/packages/did-plc/src/index.js +++ b/packages/did-plc/src/index.js @@ -3,8 +3,6 @@ import * as ed25519 from '@noble/ed25519' import base64url from 'base64url' import { base58btc } from 'multiformats/bases/base58' - - /** * PLC Directory Client for did:plc operations. */ @@ -21,7 +19,7 @@ export class PlcClient { * Resolve a did:plc to its DID Document. * * @param {import('./types.js').DidPlc} did - * @returns {Promise} + * @returns {Promise} * @throws {Error} If the DID cannot be resolved */ async getDocument(did) { diff --git a/packages/did-plc/src/indext.d.ts b/packages/did-plc/src/indext.d.ts new file mode 100644 index 000000000..e8ace5106 --- /dev/null +++ b/packages/did-plc/src/indext.d.ts @@ -0,0 +1 @@ + export * from './types' \ No newline at end of file diff --git a/packages/did-plc/src/types.ts b/packages/did-plc/src/types.ts index 273f10753..f8854a261 100644 --- a/packages/did-plc/src/types.ts +++ b/packages/did-plc/src/types.ts @@ -1,5 +1,3 @@ -import { DIDKey } from "@ucanto/principal/ed25519"; - export type DidPlc = `did:plc:${string}` export interface PlcOperation { @@ -12,7 +10,7 @@ export interface PlcOperation { sig: string; } -export interface DidPlcDocument { +export interface PlcDocument { '@context': string[]; id: DidPlc; alsoKnownAs?: string[]; diff --git a/packages/upload-api/src/access/claim.js b/packages/upload-api/src/access/claim.js index 8acd0c619..13a21e361 100644 --- a/packages/upload-api/src/access/claim.js +++ b/packages/upload-api/src/access/claim.js @@ -13,11 +13,14 @@ export const provide = (ctx) => /** * Checks if the given Principal is an Account. + * At the moment we allow `did:mailto` and `did:plc` as account principals. * * @param {API.Principal} principal * @returns {principal is API.Principal>} */ -const isAccount = (principal) => principal.did().startsWith('did:mailto:') +const isAccount = (principal) => + principal.did().startsWith('did:mailto:') || + principal.did().startsWith('did:plc:') /** * Returns true when the delegation has a `ucan:*` capability. @@ -130,7 +133,8 @@ async function createSessionProofsForLogin( delegationsStorage, signer ) { - // These should always be Accounts (did:mailto:), but if one's not, skip it. + // TODO(fforbeck): we probably wont need this if we use just did:plc: + // These should always be Accounts (did:mailto: or did:plc:), but if one's not, skip it. if (!isAccount(loginDelegation.issuer)) return { ok: [] } const accountDelegationsResult = await delegationsStorage.find({ diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index 8ab7aae35..138de42e2 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -15,15 +15,15 @@ export { fromEmail } /** * List all accounts that agent has stored access to. Returns a dictionary - * of accounts keyed by their `did:mailto` identifier. + * of accounts keyed by their `did:mailto` or `did:plc` identifier. * * @param {{agent: API.Agent}} client * @param {object} query - * @param {API.DID<'mailto'>} [query.account] + * @param {API.DID<'mailto'> | API.DID<'plc'>} [query.account] */ export const list = ({ agent }, { account } = {}) => { const query = /** @type {API.CapabilityQuery} */ ({ - with: account ?? /did:mailto:.*/, + with: account ?? /did:mailto:|did:plc:.*$/, can: '*', }) @@ -84,6 +84,7 @@ export const list = ({ agent }, { account } = {}) => { * @returns {Promise>} */ export const login = async ({ agent }, email, options = {}) => { + // TODO(fforbeck): we should use the PlcClient to resolve the did:plc: account const account = fromEmail(email) // If we already have a session for this account we diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index 4eef166a1..38872d786 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -13,6 +13,7 @@ import { Space as SpaceCapabilities, } from '@storacha/capabilities' import * as DIDMailto from '@storacha/did-mailto' +import * as DIDPlc from '@storacha/did-plc' import { Base } from './base.js' import * as Account from './account.js' import { Space } from './space.js' @@ -101,7 +102,7 @@ export class Client extends Base { /** * List all accounts that agent has stored access to. * - * @returns {Record} A dictionary with `did:mailto` as keys and `Account` instances as values. + * @returns {Record} A dictionary with `did:mailto` or `did:plc` as keys and `Account` instances as values. */ accounts() { return Account.list(this) From 27bd1baf922ad495e8ddfddb2ce8e5a10ccc3d7b Mon Sep 17 00:00:00 2001 From: Felipe Forbeck Date: Wed, 4 Jun 2025 17:13:45 -0300 Subject: [PATCH 3/3] feat: support did:plc types for auth, billing, etc --- packages/access-client/src/agent-use-cases.js | 7 ++- packages/capabilities/src/access.js | 13 +++- packages/capabilities/src/types.ts | 9 ++- packages/cli/test/helpers/context.js | 2 +- packages/upload-api/src/access.js | 2 + packages/upload-api/src/access/fetch.js | 49 +++++++++++++++ packages/upload-api/src/provider-add.js | 55 +++++++++++------ .../src/test/storage/provisions-storage.js | 2 +- packages/upload-api/src/types.ts | 8 +++ packages/upload-api/src/types/delegations.ts | 2 +- packages/upload-api/src/types/provisions.ts | 4 +- packages/w3up-client/src/account.js | 61 +++++++++++++++++-- packages/w3up-client/src/capability/access.js | 33 +++++++++- packages/w3up-client/src/client.js | 25 ++++++-- packages/w3up-client/test/client.test.js | 14 ++--- 15 files changed, 242 insertions(+), 44 deletions(-) create mode 100644 packages/upload-api/src/access/fetch.js diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index dcb5d8d74..10046faeb 100644 --- a/packages/access-client/src/agent-use-cases.js +++ b/packages/access-client/src/agent-use-cases.js @@ -223,7 +223,7 @@ export async function authorizeWaitAndClaim(accessAgent, email, opts) { * * @param {AccessAgent} access * @param {AgentData} agentData - * @param {string} email + * @param {API.AccountDID} accountId - Account DID (did:mailto or did:plc) * @param {object} [opts] * @param {AbortSignal} [opts.signal] * @param {API.DID<'key'>} [opts.space] @@ -232,7 +232,7 @@ export async function authorizeWaitAndClaim(accessAgent, email, opts) { export async function addProviderAndDelegateToAccount( access, agentData, - email, + accountId, opts ) { const space = opts?.space || access.currentSpace() @@ -257,7 +257,8 @@ export async function addProviderAndDelegateToAccount( if (spaceMeta) { throw new Error('Space already registered with storacha.network.') } - const account = { did: () => DidMailto.fromEmail(DidMailto.email(email)) } + + const account = { did: () => accountId } await addProvider({ access, space, account, provider }) const delegateSpaceAccessResult = await delegateSpaceAccessToAccount( access, diff --git a/packages/capabilities/src/access.js b/packages/capabilities/src/access.js index 50a7ab05f..04cbacff8 100644 --- a/packages/capabilities/src/access.js +++ b/packages/capabilities/src/access.js @@ -112,7 +112,18 @@ export const confirm = capability({ export const claim = capability({ can: 'access/claim', - with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })).or(PlcDID), + with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })), +}) + +/** + * Capability can be invoked to fetch delegations for a did:plc account + * via public retrieval. This is a public operation that doesn't require + * authentication since delegations are not secrets and only the account owner + * can use them. + */ +export const fetch = capability({ + can: 'access/fetch', + with: DID.match({ method: 'plc' }), }) // https://github.com/storacha/specs/blob/main/w3-access.md#accessdelegate diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index a1fd65928..0e1b95f41 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -109,7 +109,14 @@ export interface AccessClaimSuccess { } export interface AccessClaimFailure extends Ucanto.Failure { name: 'AccessClaimFailure' - message: string +} + +export type AccessFetch = InferInvokedCapability +export interface AccessFetchSuccess { + delegations: Record> +} +export interface AccessFetchFailure extends Ucanto.Failure { + name: 'AccessFetchFailure' | 'InvalidDID' } export interface AccessConfirmSuccess { diff --git a/packages/cli/test/helpers/context.js b/packages/cli/test/helpers/context.js index 6ace0795d..dc079d655 100644 --- a/packages/cli/test/helpers/context.js +++ b/packages/cli/test/helpers/context.js @@ -33,7 +33,7 @@ export { createContext, cleanupContext } * @param {UcantoServerTestContext} context * @param {object} input * @param {API.DIDKey} input.space - * @param {API.DID<'mailto'>} input.account + * @param {API.DID<'mailto'> | API.DID<'plc'>} input.account * @param {API.DID<'web'>} input.provider */ export const provisionSpace = async (context, { space, account, provider }) => { diff --git a/packages/upload-api/src/access.js b/packages/upload-api/src/access.js index 3957d9633..14f56beaf 100644 --- a/packages/upload-api/src/access.js +++ b/packages/upload-api/src/access.js @@ -2,6 +2,7 @@ import * as API from './types.js' import * as Authorize from './access/authorize.js' import * as Delegate from './access/delegate.js' import * as Claim from './access/claim.js' +import * as Fetch from './access/fetch.js' import * as Confirm from './access/confirm.js' /** @@ -11,5 +12,6 @@ export const createService = (context) => ({ authorize: Authorize.provide(context), delegate: Delegate.provide(context), claim: Claim.provide(context), + fetch: Fetch.provide(context), confirm: Confirm.provide(context), }) diff --git a/packages/upload-api/src/access/fetch.js b/packages/upload-api/src/access/fetch.js new file mode 100644 index 000000000..e92dbc62a --- /dev/null +++ b/packages/upload-api/src/access/fetch.js @@ -0,0 +1,49 @@ +import * as Server from '@ucanto/server' +import * as validator from '@ucanto/validator' +import * as Access from '@storacha/capabilities/access' +import * as API from '../types.js' +import * as delegationsResponse from '../utils/delegations-response.js' + +/** + * @param {API.AccessFetchContext} ctx + */ +export const provide = (ctx) => + Server.provide(Access.fetch, (input) => fetch(input, ctx)) + +/** + * @param {API.Input} input + * @param {API.AccessFetchContext} ctx + * @returns {Promise>} + */ +export const fetch = async ({ capability }, { delegationsStorage }) => { + const did = capability.with + + if (!validator.DID.match({ method: 'plc' }).is(did)) { + return { + error: { + name: 'InvalidDID', + message: 'access/fetch only supports did:plc identifiers', + }, + } + } + + // Public operation - no authentication required + // TODO(fforbeck): add rate limiting + const result = await delegationsStorage.find({ audience: did }) + + if (result.error) { + return { + error: { + name: 'AccessFetchFailure', + message: 'error finding delegations', + cause: result.error, + }, + } + } + + return { + ok: { + delegations: delegationsResponse.encode(result.ok), + }, + } +} \ No newline at end of file diff --git a/packages/upload-api/src/provider-add.js b/packages/upload-api/src/provider-add.js index 693f31a14..e962e9098 100644 --- a/packages/upload-api/src/provider-add.js +++ b/packages/upload-api/src/provider-add.js @@ -28,37 +28,58 @@ export const add = async ( nb: { consumer, provider }, with: accountDID, } = capability - if (!validator.DID.match({ method: 'mailto' }).is(accountDID)) { + + // Validate that the account DID is either did:mailto or did:plc + if (!validator.DID.match({ method: 'mailto' }).is(accountDID) && !validator.DID.match({ method: 'plc' }).is(accountDID)) { return { error: { name: 'Unauthorized', - message: 'Resource must be a mailto DID', + message: 'Resource must be a valid account DID (did:mailto or did:plc)', }, } } - const accountMailtoDID = - /** @type {import('@storacha/did-mailto/types').DidMailto} */ (accountDID) - const rateLimitResult = await ensureRateLimitAbove( - rateLimits, - [mailtoDidToDomain(accountMailtoDID), mailtoDidToEmail(accountMailtoDID)], - 0 - ) - if (rateLimitResult.error) { - return { - error: { - name: 'AccountBlocked', - message: `Account identified by ${accountMailtoDID} is blocked`, - }, + + // Handle rate limiting based on account type + if (accountDID.startsWith('did:mailto:')) { + const accountMailtoDID = + /** @type {import('@storacha/did-mailto/types').DidMailto} */ (accountDID) + const rateLimitResult = await ensureRateLimitAbove( + rateLimits, + [mailtoDidToDomain(accountMailtoDID), mailtoDidToEmail(accountMailtoDID)], + 0 + ) + if (rateLimitResult.error) { + return { + error: { + name: 'AccountBlocked', + message: `Account identified by ${accountDID} is blocked`, + }, + } + } + } else if (accountDID.startsWith('did:plc:')) { + // For did:plc accounts, use the full DID for rate limiting + const rateLimitResult = await ensureRateLimitAbove( + rateLimits, + [accountDID], + 0 + ) + if (rateLimitResult.error) { + return { + error: { + name: 'AccountBlocked', + message: `Account identified by ${accountDID} is blocked`, + }, + } } } if (requirePaymentPlan) { - const planGetResult = await plansStorage.get(accountMailtoDID) + const planGetResult = await plansStorage.get(accountDID) if (!planGetResult.ok?.product) { return { error: { name: 'AccountPlanMissing', - message: `Account identified by ${accountMailtoDID} has not selected a payment plan`, + message: `Account identified by ${accountDID} has not selected a payment plan`, }, } } diff --git a/packages/upload-api/src/test/storage/provisions-storage.js b/packages/upload-api/src/test/storage/provisions-storage.js index 690c81bdb..c50c596eb 100644 --- a/packages/upload-api/src/test/storage/provisions-storage.js +++ b/packages/upload-api/src/test/storage/provisions-storage.js @@ -88,7 +88,7 @@ export class ProvisionsStorage { /** * * @param {Types.ProviderDID} provider - * @param {Types.DID<'mailto'>} customer + * @param {Types.AccountDID} customer * @returns */ async getCustomer(provider, customer) { diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 2c3c1ca8e..48ebf7961 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -97,6 +97,9 @@ import { AccessClaim, AccessClaimSuccess, AccessClaimFailure, + AccessFetch, + AccessFetchSuccess, + AccessFetchFailure, AccessConfirm, AccessConfirmSuccess, AccessConfirmFailure, @@ -252,6 +255,7 @@ export interface Service extends StorefrontService { AccessAuthorizeFailure > claim: ServiceMethod + fetch: ServiceMethod confirm: ServiceMethod< AccessConfirm, AccessConfirmSuccess, @@ -436,6 +440,10 @@ export interface AccessClaimContext { delegationsStorage: Delegations } +export interface AccessFetchContext { + delegationsStorage: Delegations +} + export interface AccessServiceContext extends AccessClaimContext, AgentContext { email: Email url: URL diff --git a/packages/upload-api/src/types/delegations.ts b/packages/upload-api/src/types/delegations.ts index 508a31784..57da6cefc 100644 --- a/packages/upload-api/src/types/delegations.ts +++ b/packages/upload-api/src/types/delegations.ts @@ -1,7 +1,7 @@ import * as Ucanto from '@ucanto/interface' interface ByAudience { - audience: Ucanto.DID<'key' | 'mailto'> + audience: Ucanto.DID<'key' | 'mailto' | 'plc'> } export type Query = ByAudience diff --git a/packages/upload-api/src/types/provisions.ts b/packages/upload-api/src/types/provisions.ts index 1ebff1697..0f9471cc2 100644 --- a/packages/upload-api/src/types/provisions.ts +++ b/packages/upload-api/src/types/provisions.ts @@ -13,12 +13,12 @@ import { SpaceDID } from '../types.js' export interface Provision { cause: Ucanto.Invocation consumer: Ucanto.DID<'key'> - customer: Ucanto.DID<'mailto'> + customer: AccountDID provider: ProviderDID } export interface Customer { - did: Ucanto.DID<'mailto'> + did: AccountDID subscriptions: string[] } diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index 138de42e2..a8aa4ae10 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -28,7 +28,7 @@ export const list = ({ agent }, { account } = {}) => { }) const proofs = agent.proofs([query]) - /** @type {Record} */ + /** @type {Record, Account>} */ const accounts = {} /** @type {Record} */ const attestations = {} @@ -84,7 +84,6 @@ export const list = ({ agent }, { account } = {}) => { * @returns {Promise>} */ export const login = async ({ agent }, email, options = {}) => { - // TODO(fforbeck): we should use the PlcClient to resolve the did:plc: account const account = fromEmail(email) // If we already have a session for this account we @@ -192,6 +191,60 @@ export const externalLogin = async ( } /* c8 ignore end */ +/** + * Attempts to obtain account access using a did:plc identifier through + * public delegation retrieval. Uses the new access/fetch capability that + * allows public, unauthenticated retrieval of delegations for did:plc identifiers. + * + * @param {{agent: API.Agent}} client + * @param {API.DID<'plc'>} didPlc - The did:plc identifier to authenticate + * @param {API.Delegation} selfDelegation - Self-signed delegation from did:plc to agent + * @param {object} [options] + * @param {AbortSignal} [options.signal] + * @returns {Promise>} + */ +export const plcLogin = async ({ agent }, didPlc, selfDelegation, options = {}) => { + try { + // Validate inputs + if (!didPlc.startsWith('did:plc:')) { + return { error: new Error('Invalid DID: must be a did:plc identifier') } + } + + if (!selfDelegation) { + return { error: new Error('Self-delegation required: must provide delegation from did:plc to agent') } + } + + // TODO: Replace with actual access/fetch capability when implemented + // For now, this is a placeholder that shows the intended design: + // + // const fetchResult = await agent.invokeAndExecute(Access.fetch, { + // with: didPlc, + // }) + // + // if (fetchResult.error) { + // return { error: new Error(`Failed to fetch delegations: ${fetchResult.error.message}`) } + // } + // + // // Combine self-delegation with fetched delegations + // const allProofs = [selfDelegation, ...fetchResult.ok.delegations] + + // Placeholder implementation until access/fetch is ready + // This simulates the successful retrieval of public delegations + const allProofs = [selfDelegation] // In real implementation, this would include fetched delegations + + // Create account with the did:plc identifier and combined proofs + const account = new Account({ + id: /** @type {API.AccountDID} */ (didPlc), + proofs: allProofs, + agent + }) + + return { ok: account } + } catch (/** @type {any} */ error) { + return { error: new Error(`PLC login failed: ${error?.message}`) } + } +} + /** * @param {API.Delegation} d * @returns {d is API.Delegation<[API.UCANAttest]>} @@ -200,7 +253,7 @@ const isUCANAttest = (d) => d.capabilities[0].can === UCAN.attest.can /** * @typedef {object} Model - * @property {API.DidMailto} id + * @property {API.AccountDID} id * @property {API.Agent} agent * @property {API.Delegation[]} proofs */ @@ -225,7 +278,7 @@ export class Account { } toEmail() { - return toEmail(this.did()) + return this.did().startsWith('did:mailto:') ? toEmail(/** @type {API.DidMailto} */ (this.did())) : null } /** diff --git a/packages/w3up-client/src/capability/access.js b/packages/w3up-client/src/capability/access.js index 0505d03af..bf998ac24 100644 --- a/packages/w3up-client/src/capability/access.js +++ b/packages/w3up-client/src/capability/access.js @@ -2,6 +2,7 @@ import { Base } from '../base.js' import * as Agent from '@storacha/access/agent' import * as DIDMailto from '@storacha/did-mailto' import * as Result from '../result.js' +import * as Access from '@storacha/capabilities/access' import * as API from '../types.js' @@ -45,6 +46,16 @@ export class AccessClient extends Base { return access.proofs } + /** + * Fetch delegations for a did:plc account via public retrieval. + * This is a public operation that doesn't require authentication. + * + * @param {API.DID<'plc'>} didPlc - The did:plc identifier to fetch delegations for + */ + async fetch(didPlc) { + return await fetch(this, { didPlc }) + } + /** * Requests specified `access` level from the account from the given account. * @@ -78,6 +89,26 @@ export class AccessClient extends Base { export const claim = async ({ agent }, input) => Agent.Access.claim(agent, input) +/** + * Fetch delegations for a did:plc account via public access/fetch capability. + * This is a public operation that doesn't require authentication. + * + * @param {{agent: API.Agent}} client + * @param {object} input + * @param {API.DID<'plc'>} input.didPlc + */ +export const fetch = async ({ agent }, { didPlc }) => { + const result = await agent.invokeAndExecute(Access.fetch, { + with: didPlc, + }) + + if (result.out.error) { + return { error: new Error(`Failed to fetch delegations: ${result.out.error.message}`) } + } + + return { ok: result.out.ok } +} + /** * Requests specified `access` level from specified `account`. It will invoke * `access/authorize` capability and keep polling `access/claim` capability @@ -119,4 +150,4 @@ export const createPendingAccessRequest = ({ agent }, input) => export const delegate = async ({ agent }, input) => Agent.Access.delegate(agent, input) -export const { spaceAccess, accountAccess } = Agent.Access +export const { spaceAccess, accountAccess } = Agent.Access \ No newline at end of file diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index 38872d786..84e5e1040 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -88,6 +88,8 @@ export class Client extends Base { /* c8 ignore stop */ /** + * Login with a did:mailto account. + * * @param {Account.EmailAddress} email * @param {object} [options] * @param {AbortSignal} [options.signal] @@ -99,6 +101,19 @@ export class Client extends Base { return account } + /** + * Login with a did:plc account. + * + * @param {DIDPlc.DidPlc} didPlc + * @param {object} [options] + * @param {AbortSignal} [options.signal] + */ + async plcLogin(didPlc, options = {}) { + const account = Result.unwrap(await Account.plcLogin(this, didPlc, options)) + Result.unwrap(await account.save()) + return account + } + /** * List all accounts that agent has stored access to. * @@ -338,7 +353,7 @@ export class Client extends Base { * @property {import('./types.js').ServiceAbility[]} abilities - Abilities to delegate to the delegate account. * @property {number} expiration - Expiration time in seconds. - * @param {import("./types.js").EmailAddress} delegateEmail - Email of the account to share the space with. + * @param {import("./types.js").AccountDID} accountId - The did:mailto or did:plc of the account to share the space with. * @param {import('./types.js').SpaceDID} spaceDID - The DID of the space to share. * @param {ShareOptions} [options] - Options for the delegation. * @@ -346,7 +361,7 @@ export class Client extends Base { * @throws {Error} - Throws an error if there is an issue delegating access to the space. */ async shareSpace( - delegateEmail, + accountId, spaceDID, options = { abilities: [ @@ -372,14 +387,14 @@ export class Client extends Base { ...restOptions, abilities, audience: { - did: () => DIDMailto.fromEmail(DIDMailto.email(delegateEmail)), + did: () => accountId, }, // @ts-expect-error audienceMeta is not defined in ShareOptions audienceMeta: options.audienceMeta ?? {}, }) const delegation = new AgentDelegation(root, blocks, { - audience: delegateEmail, + audience: accountId, }) const sharingResult = await this.capability.access.delegate({ @@ -389,7 +404,7 @@ export class Client extends Base { if (sharingResult.error) { throw new Error( - `failed to share space with ${delegateEmail}: ${sharingResult.error.message}`, + `failed to share space with ${accountId}: ${sharingResult.error.message}`, { cause: sharingResult.error, } diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.test.js index 10ad99cbc..0b0c3a32b 100644 --- a/packages/w3up-client/test/client.test.js +++ b/packages/w3up-client/test/client.test.js @@ -435,11 +435,11 @@ export const testClient = { assert.ok(space) // Step 3: Alice shares the space with Bob - const bobEmail = 'bob@web.mail' - await aliceClient.shareSpace(bobEmail, space.did()) + const bobDid = DIDMailto.fromEmail('bob@web.mail') + await aliceClient.shareSpace(bobDid, space.did()) // Step 4: Bob access his device and his device gets authorized - const bobAccount = Absentee.from({ id: DIDMailto.fromEmail(bobEmail) }) + const bobAccount = Absentee.from({ id: bobDid }) const bobAgentData = await AgentData.create() const bobClient = await Agent.create(bobAgentData, { connection, @@ -496,9 +496,9 @@ export const testClient = { } // Step 4: Attempt to share the space with Bob and expect failure - const bobEmail = 'bob@web.mail' - await assert.rejects(client.shareSpace(bobEmail, space.did()), { - message: `failed to share space with ${bobEmail}: Delegate failed`, + const bobDid = DIDMailto.fromEmail('bob@web.mail') + await assert.rejects(client.shareSpace(bobDid, space.did()), { + message: `failed to share space with ${bobDid}: Delegate failed`, }) // Restore the original delegate method @@ -533,7 +533,7 @@ export const testClient = { // Step 4: Alice set the current space to space A and shares the space B with Bob await client.setCurrentSpace(spaceA.did()) - await client.shareSpace('bob@web.mail', spaceB.did()) + await client.shareSpace(DIDMailto.fromEmail('bob@web.mail'), spaceB.did()) // Step 5: Check that current space from Alice is still space A const currentSpace = client.currentSpace()