diff --git a/README.md b/README.md
index cf67b5509..78e055207 100644
--- a/README.md
+++ b/README.md
@@ -249,6 +249,15 @@ Controls whether to prompt the user to start a new connection if a connection ha
Defaults to `false`.
+
+##### **`permissionRequests`**
+
+Allows for restricting the scope of access to the underlying DWeb Node.
+
+If provided, `permissionRequests` must be an array of [`scope`](https://identity.foundation/decentralized-web-node/spec/#permissionsrequest) objects.
+
+Defaults to `undefined`.
+
#### **Events**
After calling `connect`, at least one of the following events will be dispatched on the `Web5` instance:
@@ -301,7 +310,99 @@ document.querySelector('#connect_button').addEventListener('click', async event
console.error(event);
});
- web5.connect();
+ web5.connect({
+ permissionRequests: [
+ {
+ interface: 'Records',
+ method: 'Write',
+ protocol: 'https://decentralized-music.org/protocol'
+ }
+ ]
+ });
+});
+```
+
+### **`web5.changePermissions(permissionRequests)`**
+
+Enables an app to change the permissions of their connection to a user's local identity app, or an in-app DID to represent the user (e.g. if the user does not have an identity app).
+
+> NOTE: This method **_MUST_** be called _after_ `web5.connect()`.
+
+#### **`permissionRequests`**
+
+Allows for restricting the scope of access to the underlying DWeb Node.
+
+`permissionRequests` must be an array of [`scope`](https://identity.foundation/decentralized-web-node/spec/#permissionsrequest) objects.
+
+> NOTE: `permissionRequests` are not additive, so all desired permissions must be provided together.
+
+#### **Events**
+
+After calling `changePermissions`, at least one of the following events will be dispatched on the `Web5` instance:
+
+##### **`'open'`**
+
+A `'open'` event is dispatched if a local identity app is found, containing a verification pin and port number used for the connection.
+
+- **`event.detail.pin`** - *`number`*: 1-4 digit numerical code that must be displayed to the user to ensure they are connecting to the desired local identity app.
+
+##### **`'change'`**
+
+A `'change'` event is dispatched if the user accepts the change.
+
+##### **`'close'`**
+
+A `'close'` event is dispatched if the user refuses to accept or respond to the change attempt for any reason.
+
+##### **`'error'`**
+
+An `'error'` event is dispached if anything goes wrong (e.g. `Web5` was unable to find any local identity apps).
+
+#### **Example**
+
+```javascript
+document.querySelector('#connect_button').addEventListener('click', async event => {
+
+ event.preventDefault();
+
+ const web5 = new Web5();
+
+ web5.addEventListener('open', (event) => {
+ const { pin } = event.detail
+ document.querySelector('#pin_code_text').textContent = pin;
+ });
+
+ web5.addEventListener('change', (event) => {
+ alert('Change succeeded!');
+ });
+
+ web5.addEventListener('close', (event) => {
+ alert('Change was denied');
+ });
+
+ web5.addEventListener('error', (event) => {
+ console.error(event);
+ });
+
+ web5.changePermissions([
+ {
+ interface: 'Records',
+ method: 'Write',
+ protocol: 'https://decentralized-music.org/protocol'
+ },
+ {
+ interface: 'Records',
+ method: 'Write',
+ protocol: 'https://decentralized-music.org/protocol',
+ schema: 'https://decentralized-music.org/protocol/playlist'
+ },
+ {
+ interface: 'Records',
+ method: 'Write',
+ protocol: 'https://decentralized-music.org/protocol',
+ schema: 'https://decentralized-music.org/protocol/track'
+ }
+ ]);
});
```
diff --git a/examples/test-dashboard/desktop-agent.html b/examples/test-dashboard/desktop-agent.html
index 09e21d596..0cf02c480 100644
--- a/examples/test-dashboard/desktop-agent.html
+++ b/examples/test-dashboard/desktop-agent.html
@@ -39,6 +39,7 @@
DID Connect
+ DID Update
Security Code:
@@ -246,6 +247,9 @@
myDid = event.detail.did;
alert('Connection succeeded!');
});
+ web5.addEventListener('change', (event) => {
+ alert('Update succeeded!');
+ });
web5.addEventListener('close', (event) => {
alert('Connection was denied');
});
@@ -256,7 +260,35 @@
connect_button.addEventListener('click', async event => {
event.preventDefault();
- web5.connect();
+ web5.connect({
+ permissionRequests: [
+ { interface: 'Records', method: 'Write', schema: 'foo/text' },
+ { interface: 'Records', method: 'Query', schema: 'foo/text' },
+ { interface: 'Records', method: 'Write', protocol: 'test', schema: 'test/post' },
+ { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' }
+ ]
+ });
+ });
+
+ update_button.addEventListener('click', async event => {
+ event.preventDefault();
+
+ web5.changePermissions([
+ { interface: 'Records', method: 'Write', schema: 'foo/text' },
+ { interface: 'Records', method: 'Query', schema: 'foo/text' },
+ { interface: 'Records', method: 'Write', schema: 'foo/json' },
+ { interface: 'Records', method: 'Query', schema: 'foo/json' },
+ { interface: 'Records', method: 'Write', schema: 'foo/avatar' },
+ { interface: 'Records', method: 'Query', schema: 'foo/avatar' },
+ { interface: 'Records', method: 'Write', protocol: 'test', schema: 'test/post' },
+ { interface: 'Records', method: 'Query', protocol: 'test', schema: 'test/post' },
+
+ // tests for permissions UI
+ { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/address.schema.json' },
+ { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/calendar.schema.json' },
+ { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/card.schema.json' },
+ { interface: 'Records', method: 'Write', schema: 'https://json-schema.org/learn/examples/geographical-location.schema.json' }
+ ]);
});
async function logConsole(obj, message = '') {
diff --git a/src/Web5.js b/src/Web5.js
index 064bcda24..53739d6a9 100644
--- a/src/Web5.js
+++ b/src/Web5.js
@@ -2,13 +2,13 @@ import nacl from 'tweetnacl';
import { Web5DID } from './did/Web5DID.js';
import { Web5DWN } from './dwn/Web5DWN.js';
+import { JSONRPCSocket } from './json-rpc/JSONRPCSocket.js';
import { LocalStorage } from './storage/LocalStorage.js';
import { AppTransport } from './transport/AppTransport.js';
import { HTTPTransport } from './transport/HTTPTransport.js';
import {
decodePin,
isUnsignedMessage,
- parseJSON,
parseURL,
triggerProtocolHandler,
} from './utils.js';
@@ -18,8 +18,11 @@ class Web5 extends EventTarget {
#did;
#transports;
+ #storage = null;
#keys = null;
+ #keysLocation = null;
#connection = null;
+ #connectionLocation = null;
constructor(options = { }) {
super();
@@ -90,24 +93,14 @@ class Web5 extends EventTarget {
}
async connect(options = { }) {
- const storage = options?.storage ?? new LocalStorage();
- const connectionLocation = options?.connectionLocation ?? 'web5-connection';
- const keysLocation = options?.keysLocation ?? 'web5-keys';
+ this.#storage = options?.storage ?? new LocalStorage();
+ this.#connectionLocation = options?.connectionLocation ?? 'web5-connection';
+ this.#keysLocation = options?.keysLocation ?? 'web5-keys';
- if (this.#connection) {
- return;
- }
+ const permissionRequests = structuredClone(options?.permissionRequests);
- this.#connection = await storage.get(connectionLocation);
- if (this.#connection) {
- // Register DID on reconnection
- await this.#did.register({
- connected: true,
- did: this.#connection.did,
- endpoint: `http://localhost:${this.#connection.port}/dwn`,
- });
-
- this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection }));
+ const connectionAlreadyExists = await this.#loadConnection(options);
+ if (connectionAlreadyExists) {
return;
}
@@ -115,37 +108,11 @@ class Web5 extends EventTarget {
return;
}
- if (!this.#keys) {
- const keys = await storage.get(keysLocation);
- if (keys) {
- this.#keys = {
- encoded: keys,
- decoded: {
- publicKey: this.#dwn.SDK.Encoder.base64UrlToBytes(keys.publicKey),
- secretKey: this.#dwn.SDK.Encoder.base64UrlToBytes(keys.secretKey),
- },
- };
- } else {
- const keys = nacl.box.keyPair();
- this.#keys = {
- encoded: {
- publicKey: this.#dwn.SDK.Encoder.bytesToBase64Url(keys.publicKey),
- secretKey: this.#dwn.SDK.Encoder.bytesToBase64Url(keys.secretKey),
- },
- decoded: keys,
- };
- await storage.set(keysLocation, this.#keys.encoded);
- }
- }
-
- const encodedOrigin = this.#dwn.SDK.Encoder.stringToBase64Url(location.origin);
- triggerProtocolHandler(`web5://connect/${this.#keys.encoded.publicKey}/${encodedOrigin}`);
-
function destroySocket(socket) {
socket.close();
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('error', handleError);
- socket.removeEventListener('message', handleMessage);
+ socket.removeEventListener('notification', handleNotification);
}
const removeSocket = (socket) => {
@@ -160,7 +127,7 @@ class Web5 extends EventTarget {
function handleOpen(event) {
const socket = event.target;
- socket.addEventListener('message', handleMessage);
+ socket.addEventListener('notification', handleNotification);
}
function handleError(event) {
@@ -169,73 +136,128 @@ class Web5 extends EventTarget {
removeSocket(socket);
}
- const handleMessage = async (event) => {
+ const handleNotification = async (event) => {
const socket = event.target;
+ const { method, params } = event.detail;
+
+ switch (method) {
+ case 'requested-web5':
+ try {
+ const connection = await socket.sendRequest('connect', { permissionRequests });
+ if (!connection) {
+ // stop listening to this socket as it is missing required data
+ removeSocket(socket);
+ return;
+ }
+
+ this.#connection = connection;
+ await this.#storage.set(this.#connectionLocation, this.#connection);
+
+ // Register DID on initial connection
+ await this.#did.register({
+ connected: true,
+ did: this.#connection.did,
+ endpoint: `http://localhost:${this.#connection.port}/dwn/${this.#keys.encoded.publicKey}`,
+ });
+
+ this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection }));
+ } catch {
+ this.dispatchEvent(new CustomEvent('close'));
+ }
- const json = parseJSON(event.data);
+ sockets.forEach(destroySocket);
+ sockets.clear();
+ return;
- switch (json?.type) {
- case 'connected':
- if (!json.data) {
+ case 'show-pin':
+ if (!this.#decodePinAndDispatchOpenEvent(params)) {
+ // stop listening to this socket as it is missing required data
removeSocket(socket);
- return;
}
+ return;
+ }
+
+ // stop listening to this socket as it has sent us unexpected data
+ removeSocket(socket);
+ };
+
+ const sockets = new Set();
+ for (let port = 55_500; port <= 55_600; ++port) {
+ const socket = new JSONRPCSocket();
+ sockets.add(socket);
- this.#connection = json.data;
- await storage.set(connectionLocation, this.#connection);
+ socket.addEventListener('open', handleOpen);
+ socket.addEventListener('error', handleError);
- // Register DID on initial connection
- await this.#did.register({
- connected: true,
- did: this.#connection.did,
- endpoint: `http://localhost:${this.#connection.port}/dwn`,
- });
+ socket.open(`ws://localhost:${port}/connections/${this.#keys.encoded.publicKey}`);
+ }
- this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection }));
- break;
+ const encodedOrigin = this.#dwn.SDK.Encoder.stringToBase64Url(location.origin);
+ triggerProtocolHandler(`web5://connect/${this.#keys.encoded.publicKey}/${encodedOrigin}`);
+ }
- case 'requested':
- if (!json.data) {
- removeSocket(socket);
- return;
- }
+ async changePermissions(permissionRequests) {
+ if (!this.#connection || !this.#keys) {
+ throw 'must call connect() before calling changePermissions()';
+ }
- try {
- await decodePin(json.data, this.#keys.decoded.secretKey);
- } catch {
- removeSocket(socket);
+ permissionRequests = structuredClone(permissionRequests);
+
+ function destroySocket() {
+ socket.close();
+ socket.removeEventListener('open', handleOpen);
+ socket.removeEventListener('error', removeSocket);
+ socket.removeEventListener('notification', handleNotification);
+ }
+
+ const removeSocket = () => {
+ destroySocket(socket);
+
+ this.dispatchEvent(new CustomEvent('error'));
+ };
+
+ const handleOpen = async () => {
+ socket.addEventListener('notification', handleNotification);
+
+ try {
+ const connection = await socket.sendRequest('update', { permissionRequests });
+ if (!connection) {
+ // stop listening to this socket as it is missing required data
+ removeSocket();
return;
}
- this.dispatchEvent(new CustomEvent('open', { detail: json.data }));
- return;
+ this.#connection = { ...this.#connection, connection };
+ await this.#storage.set(this.#connectionLocation, this.#connection);
- case 'blocked':
- case 'denied':
- case 'closed':
+ this.dispatchEvent(new CustomEvent('change', { detail: this.#connection }));
+ } catch {
this.dispatchEvent(new CustomEvent('close'));
- break;
+ }
- case 'unknown':
- return;
+ destroySocket();
+ };
- default:
- removeSocket(socket);
+ const handleNotification = async (event) => {
+ const { method, params } = event.detail;
+
+ switch (method) {
+ case 'show-pin':
+ if (!this.#decodePinAndDispatchOpenEvent(params)) {
+ // stop listening to this socket as it is missing required data
+ removeSocket();
+ }
return;
}
- sockets.forEach(destroySocket);
- sockets.clear();
+ // stop listening to this socket as it has sent us unexpected data
+ removeSocket();
};
- const sockets = new Set();
- for (let port = 55_500; port <= 55_600; ++port) {
- const socket = new WebSocket(`ws://localhost:${port}/connections/${this.#keys.encoded.publicKey}`);
- sockets.add(socket);
-
- socket.addEventListener('open', handleOpen);
- socket.addEventListener('error', handleError);
- }
+ const socket = new JSONRPCSocket();
+ socket.addEventListener('open', handleOpen);
+ socket.addEventListener('error', removeSocket);
+ socket.open(`ws://localhost:${this.#connection.port}/connections/${this.#keys.encoded.publicKey}`);
}
async #createSignedMessage(resolvedAuthor, message, data) {
@@ -286,6 +308,79 @@ class Web5 extends EventTarget {
return response ?? { status: { code: 503, detail: 'Service Unavailable' } };
}
+
+ async #decodePinAndDispatchOpenEvent(data) {
+ if (!data) {
+ return false;
+ }
+
+ try {
+ await decodePin(data, this.#keys.decoded.secretKey);
+ } catch {
+ return false;
+ }
+
+ this.dispatchEvent(new CustomEvent('open', { detail: data }));
+ return true;
+ }
+
+ async #loadKeys(options = { }) {
+ if (this.#keys) {
+ return true;
+ }
+
+ const encoded = await this.#storage.get(this.#keysLocation);
+ if (encoded) {
+ this.#keys = {
+ encoded,
+ decoded: {
+ publicKey: this.#dwn.SDK.Encoder.base64UrlToBytes(encoded.publicKey),
+ secretKey: this.#dwn.SDK.Encoder.base64UrlToBytes(encoded.secretKey),
+ },
+ };
+ return true;
+ }
+
+ if (!options.silent) {
+ const decoded = nacl.box.keyPair();
+ this.#keys = {
+ encoded: {
+ publicKey: this.#dwn.SDK.Encoder.bytesToBase64Url(decoded.publicKey),
+ secretKey: this.#dwn.SDK.Encoder.bytesToBase64Url(decoded.secretKey),
+ },
+ decoded,
+ };
+ await this.#storage.set(this.#keysLocation, this.#keys.encoded);
+ }
+
+ return false;
+ }
+
+ async #loadConnection(options = { }) {
+ if (this.#connection) {
+ return true;
+ }
+
+ const keysAlreadyExist = await this.#loadKeys(options);
+ if (!keysAlreadyExist) {
+ return false;
+ }
+
+ this.#connection = await this.#storage.get(this.#connectionLocation);
+ if (!this.#connection) {
+ return false;
+ }
+
+ // Register DID on reconnection
+ await this.#did.register({
+ connected: true,
+ did: this.#connection.did,
+ endpoint: `http://localhost:${this.#connection.port}/dwn/${this.#keys.encoded.publicKey}`,
+ });
+
+ this.dispatchEvent(new CustomEvent('connection', { detail: this.#connection }));
+ return true;
+ }
}
export {
diff --git a/src/json-rpc/JSONRPCError.js b/src/json-rpc/JSONRPCError.js
new file mode 100644
index 000000000..c0d85e831
--- /dev/null
+++ b/src/json-rpc/JSONRPCError.js
@@ -0,0 +1,25 @@
+class JSONRPCError extends Error {
+ #code;
+ #data;
+
+ constructor(code, message, data = { }) {
+ super(message);
+
+ this.name = 'JSONRPCError';
+
+ this.#code = code;
+ this.#data = data;
+ }
+
+ get code() {
+ return this.#code;
+ }
+
+ get data() {
+ return this.#data;
+ }
+}
+
+export {
+ JSONRPCError,
+};
diff --git a/src/json-rpc/JSONRPCSocket.js b/src/json-rpc/JSONRPCSocket.js
new file mode 100644
index 000000000..6b14519e2
--- /dev/null
+++ b/src/json-rpc/JSONRPCSocket.js
@@ -0,0 +1,165 @@
+import { parseJSON } from '../utils.js';
+import { JSONRPCError } from './JSONRPCError.js';
+
+class JSONRPCSocket extends EventTarget {
+ #socket = null;
+ #ready = false;
+
+ #nextRequestID = 0;
+ #requestHandlersForID = new Map();
+
+ #bound = { };
+
+ constructor() {
+ super();
+
+ this.#bound['open'] = this.#handleOpen.bind(this);
+ this.#bound['message'] = this.#handleMessage.bind(this);
+ this.#bound['close'] = this.#handleClose.bind(this);
+ this.#bound['error'] = this.#handleError.bind(this);
+ }
+
+ open(url) {
+ if (this.#ready) {
+ return;
+ }
+
+ this.#socket = new WebSocket(url);
+ this.#socket.addEventListener('open', this.#bound['open'], { capture: true, passive: true, once: true });
+ this.#socket.addEventListener('message', this.#bound['message'], { capture: true, passive: true });
+ this.#socket.addEventListener('close', this.#bound['close'], { capture: true, passive: true, once: true });
+ this.#socket.addEventListener('error', this.#bound['error'], { capture: true, passive: true, once: true });
+ }
+
+ sendRequest(method, params = undefined, callback = undefined) {
+ if (!this.#ready) {
+ throw 'not ready';
+ }
+
+ const id = ++this.#nextRequestID;
+
+ this.#socket.send(JSON.stringify({ id, method, params }));
+
+ if (!callback) {
+ return new Promise((resolve, reject) => {
+ this.#requestHandlersForID.set(id, { resolve, reject });
+ });
+ }
+
+ this.#requestHandlersForID.set(id, {
+ resolve: callback,
+ reject: callback,
+ });
+ }
+
+ sendNotification(method, params = undefined) {
+ if (!this.#ready) {
+ throw 'not ready';
+ }
+
+ this.#socket.send(JSON.stringify({ method, params }));
+ }
+
+ close() {
+ const closed = this.#close();
+ if (!closed) {
+ return;
+ }
+
+ this.dispatchEvent(new CustomEvent('close'));
+ }
+
+ #close() {
+ if (!this.#ready) {
+ return false;
+ }
+
+ this.#ready = false;
+
+ this.#socket.removeEventListener('open', this.#bound['open'], { capture: true });
+ this.#socket.removeEventListener('message', this.#bound['message'], { capture: true });
+ this.#socket.removeEventListener('close', this.#bound['close'], { capture: true });
+ this.#socket.removeEventListener('error', this.#bound['error'], { capture: true });
+ this.#socket.close();
+ this.#socket = null;
+
+ this.#nextRequestID = 0;
+
+ for (const { reject } of Array.from(this.#requestHandlersForID.values())) {
+ reject(new Error('closed'));
+ }
+ this.#requestHandlersForID.clear();
+
+ return true;
+ }
+
+ #error(error) {
+ const closed = this.#close();
+ if (!closed) {
+ return;
+ }
+
+ this.dispatchEvent(new CustomEvent('error'), { detail: error });
+ }
+
+ #handleOpen() {
+ this.#ready = true;
+
+ this.dispatchEvent(new CustomEvent('open'));
+ }
+
+ #handleMessage(event) {
+ const json = parseJSON(event.data);
+ if (!json) {
+ // stop listening to this socket as it is missing required data
+ this.#error('invalid message');
+ return;
+ }
+
+ const id = json.id;
+ if (!id) {
+ if (!json.method) {
+ // stop listening to this socket as it is missing required data
+ this.#error('invalid notification');
+ return;
+ }
+
+ this.dispatchEvent(new CustomEvent('notification', { detail: {
+ method: json.method,
+ params: json.params ?? { },
+ } }));
+ return;
+ }
+
+ const { resolve, reject } = this.#requestHandlersForID.get(id) ?? { };
+ if (!resolve || !reject) {
+ // stop listening to this socket as it is in an unexpected state
+ this.#error('invalid id');
+ return;
+ }
+
+ if (json.error) {
+ if (!json.error.code || !json.error.message) {
+ // stop listening to this socket as it is missing required data
+ this.#error('invalid error');
+ return;
+ }
+
+ reject(new JSONRPCError(json.error.code, json.error.message, json.error.data));
+ } else {
+ resolve(json.result);
+ }
+ }
+
+ #handleClose() {
+ this.close();
+ }
+
+ #handleError(event) {
+ this.#error(event.error);
+ }
+}
+
+export {
+ JSONRPCSocket,
+};