├── .github ├── actions │ └── test │ │ └── action.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── package-lock.json ├── package.json ├── src ├── api.js ├── api.ts ├── capabilities.js ├── client │ ├── api.js │ ├── api.ts │ └── index.js ├── index.js ├── server │ └── index.js └── worker │ ├── block.js │ ├── durable-clock.js │ ├── index.js │ ├── middleware.js │ ├── service.js │ └── types.d.ts ├── test ├── helpers │ ├── durable-objects.js │ └── ucanto.js └── worker │ ├── durable-clock.test.js │ └── service.test.js ├── tsconfig.json └── wrangler.toml /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | description: 'Setup and test' 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - uses: actions/setup-node@v3 8 | with: 9 | registry-url: 'https://registry.npmjs.org' 10 | node-version: 18 11 | cache: 'npm' 12 | - run: npm ci 13 | shell: bash 14 | - run: npm run lint 15 | shell: bash 16 | - run: npm test 17 | shell: bash 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: ./.github/actions/test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .env 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The contents of this repository are Copyright (c) corresponding authors and 2 | contributors, licensed under the `Permissive License Stack` meaning either of: 3 | 4 | - Apache-2.0 Software License: https://www.apache.org/licenses/LICENSE-2.0 5 | ([...4tr2kfsq](https://dweb.link/ipfs/bafkreiankqxazcae4onkp436wag2lj3ccso4nawxqkkfckd6cg4tr2kfsq)) 6 | 7 | - MIT Software License: https://opensource.org/licenses/MIT 8 | ([...vljevcba](https://dweb.link/ipfs/bafkreiepofszg4gfe2gzuhojmksgemsub2h4uy2gewdnr35kswvljevcba)) 9 | 10 | You may not use the contents of this repository except in compliance 11 | with one of the listed Licenses. For an extended clarification of the 12 | intent behind the choice of Licensing please refer to 13 | https://protocol.ai/blog/announcing-the-permissive-license-stack/ 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the terms listed in this notice is distributed on 17 | an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 18 | either express or implied. See each License for the specific language 19 | governing permissions and limitations under that License. 20 | 21 | 22 | 23 | `SPDX-License-Identifier: Apache-2.0 OR MIT` 24 | 25 | Verbatim copies of both licenses are included below: 26 | 27 |
Apache-2.0 Software License 28 | 29 | ``` 30 | Apache License 31 | Version 2.0, January 2004 32 | http://www.apache.org/licenses/ 33 | 34 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 35 | 36 | 1. Definitions. 37 | 38 | "License" shall mean the terms and conditions for use, reproduction, 39 | and distribution as defined by Sections 1 through 9 of this document. 40 | 41 | "Licensor" shall mean the copyright owner or entity authorized by 42 | the copyright owner that is granting the License. 43 | 44 | "Legal Entity" shall mean the union of the acting entity and all 45 | other entities that control, are controlled by, or are under common 46 | control with that entity. For the purposes of this definition, 47 | "control" means (i) the power, direct or indirect, to cause the 48 | direction or management of such entity, whether by contract or 49 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 50 | outstanding shares, or (iii) beneficial ownership of such entity. 51 | 52 | "You" (or "Your") shall mean an individual or Legal Entity 53 | exercising permissions granted by this License. 54 | 55 | "Source" form shall mean the preferred form for making modifications, 56 | including but not limited to software source code, documentation 57 | source, and configuration files. 58 | 59 | "Object" form shall mean any form resulting from mechanical 60 | transformation or translation of a Source form, including but 61 | not limited to compiled object code, generated documentation, 62 | and conversions to other media types. 63 | 64 | "Work" shall mean the work of authorship, whether in Source or 65 | Object form, made available under the License, as indicated by a 66 | copyright notice that is included in or attached to the work 67 | (an example is provided in the Appendix below). 68 | 69 | "Derivative Works" shall mean any work, whether in Source or Object 70 | form, that is based on (or derived from) the Work and for which the 71 | editorial revisions, annotations, elaborations, or other modifications 72 | represent, as a whole, an original work of authorship. For the purposes 73 | of this License, Derivative Works shall not include works that remain 74 | separable from, or merely link (or bind by name) to the interfaces of, 75 | the Work and Derivative Works thereof. 76 | 77 | "Contribution" shall mean any work of authorship, including 78 | the original version of the Work and any modifications or additions 79 | to that Work or Derivative Works thereof, that is intentionally 80 | submitted to Licensor for inclusion in the Work by the copyright owner 81 | or by an individual or Legal Entity authorized to submit on behalf of 82 | the copyright owner. For the purposes of this definition, "submitted" 83 | means any form of electronic, verbal, or written communication sent 84 | to the Licensor or its representatives, including but not limited to 85 | communication on electronic mailing lists, source code control systems, 86 | and issue tracking systems that are managed by, or on behalf of, the 87 | Licensor for the purpose of discussing and improving the Work, but 88 | excluding communication that is conspicuously marked or otherwise 89 | designated in writing by the copyright owner as "Not a Contribution." 90 | 91 | "Contributor" shall mean Licensor and any individual or Legal Entity 92 | on behalf of whom a Contribution has been received by Licensor and 93 | subsequently incorporated within the Work. 94 | 95 | 2. Grant of Copyright License. Subject to the terms and conditions of 96 | this License, each Contributor hereby grants to You a perpetual, 97 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 98 | copyright license to reproduce, prepare Derivative Works of, 99 | publicly display, publicly perform, sublicense, and distribute the 100 | Work and such Derivative Works in Source or Object form. 101 | 102 | 3. Grant of Patent License. Subject to the terms and conditions of 103 | this License, each Contributor hereby grants to You a perpetual, 104 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 105 | (except as stated in this section) patent license to make, have made, 106 | use, offer to sell, sell, import, and otherwise transfer the Work, 107 | where such license applies only to those patent claims licensable 108 | by such Contributor that are necessarily infringed by their 109 | Contribution(s) alone or by combination of their Contribution(s) 110 | with the Work to which such Contribution(s) was submitted. If You 111 | institute patent litigation against any entity (including a 112 | cross-claim or counterclaim in a lawsuit) alleging that the Work 113 | or a Contribution incorporated within the Work constitutes direct 114 | or contributory patent infringement, then any patent licenses 115 | granted to You under this License for that Work shall terminate 116 | as of the date such litigation is filed. 117 | 118 | 4. Redistribution. You may reproduce and distribute copies of the 119 | Work or Derivative Works thereof in any medium, with or without 120 | modifications, and in Source or Object form, provided that You 121 | meet the following conditions: 122 | 123 | (a) You must give any other recipients of the Work or 124 | Derivative Works a copy of this License; and 125 | 126 | (b) You must cause any modified files to carry prominent notices 127 | stating that You changed the files; and 128 | 129 | (c) You must retain, in the Source form of any Derivative Works 130 | that You distribute, all copyright, patent, trademark, and 131 | attribution notices from the Source form of the Work, 132 | excluding those notices that do not pertain to any part of 133 | the Derivative Works; and 134 | 135 | (d) If the Work includes a "NOTICE" text file as part of its 136 | distribution, then any Derivative Works that You distribute must 137 | include a readable copy of the attribution notices contained 138 | within such NOTICE file, excluding those notices that do not 139 | pertain to any part of the Derivative Works, in at least one 140 | of the following places: within a NOTICE text file distributed 141 | as part of the Derivative Works; within the Source form or 142 | documentation, if provided along with the Derivative Works; or, 143 | within a display generated by the Derivative Works, if and 144 | wherever such third-party notices normally appear. The contents 145 | of the NOTICE file are for informational purposes only and 146 | do not modify the License. You may add Your own attribution 147 | notices within Derivative Works that You distribute, alongside 148 | or as an addendum to the NOTICE text from the Work, provided 149 | that such additional attribution notices cannot be construed 150 | as modifying the License. 151 | 152 | You may add Your own copyright statement to Your modifications and 153 | may provide additional or different license terms and conditions 154 | for use, reproduction, or distribution of Your modifications, or 155 | for any such Derivative Works as a whole, provided Your use, 156 | reproduction, and distribution of the Work otherwise complies with 157 | the conditions stated in this License. 158 | 159 | 5. Submission of Contributions. Unless You explicitly state otherwise, 160 | any Contribution intentionally submitted for inclusion in the Work 161 | by You to the Licensor shall be under the terms and conditions of 162 | this License, without any additional terms or conditions. 163 | Notwithstanding the above, nothing herein shall supersede or modify 164 | the terms of any separate license agreement you may have executed 165 | with Licensor regarding such Contributions. 166 | 167 | 6. Trademarks. This License does not grant permission to use the trade 168 | names, trademarks, service marks, or product names of the Licensor, 169 | except as required for reasonable and customary use in describing the 170 | origin of the Work and reproducing the content of the NOTICE file. 171 | 172 | 7. Disclaimer of Warranty. Unless required by applicable law or 173 | agreed to in writing, Licensor provides the Work (and each 174 | Contributor provides its Contributions) on an "AS IS" BASIS, 175 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 176 | implied, including, without limitation, any warranties or conditions 177 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 178 | PARTICULAR PURPOSE. You are solely responsible for determining the 179 | appropriateness of using or redistributing the Work and assume any 180 | risks associated with Your exercise of permissions under this License. 181 | 182 | 8. Limitation of Liability. In no event and under no legal theory, 183 | whether in tort (including negligence), contract, or otherwise, 184 | unless required by applicable law (such as deliberate and grossly 185 | negligent acts) or agreed to in writing, shall any Contributor be 186 | liable to You for damages, including any direct, indirect, special, 187 | incidental, or consequential damages of any character arising as a 188 | result of this License or out of the use or inability to use the 189 | Work (including but not limited to damages for loss of goodwill, 190 | work stoppage, computer failure or malfunction, or any and all 191 | other commercial damages or losses), even if such Contributor 192 | has been advised of the possibility of such damages. 193 | 194 | 9. Accepting Warranty or Additional Liability. While redistributing 195 | the Work or Derivative Works thereof, You may choose to offer, 196 | and charge a fee for, acceptance of support, warranty, indemnity, 197 | or other liability obligations and/or rights consistent with this 198 | License. However, in accepting such obligations, You may act only 199 | on Your own behalf and on Your sole responsibility, not on behalf 200 | of any other Contributor, and only if You agree to indemnify, 201 | defend, and hold each Contributor harmless for any liability 202 | incurred by, or claims asserted against, such Contributor by reason 203 | of your accepting any such warranty or additional liability. 204 | 205 | END OF TERMS AND CONDITIONS 206 | ``` 207 | 208 |
209 | 210 |
MIT Software License 211 | 212 | ``` 213 | Permission is hereby granted, free of charge, to any person obtaining a copy 214 | of this software and associated documentation files (the "Software"), to deal 215 | in the Software without restriction, including without limitation the rights 216 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 217 | copies of the Software, and to permit persons to whom the Software is 218 | furnished to do so, subject to the following conditions: 219 | 220 | The above copyright notice and this permission notice shall be included in 221 | all copies or substantial portions of the Software. 222 | 223 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 224 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 225 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 226 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 227 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 228 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 229 | THE SOFTWARE. 230 | ``` 231 | 232 |
233 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 |
4 | w3clock 5 |

6 |

UCAN based merkle clock implementation.

7 |
8 |

9 | GitHub Workflow Status 10 | StandardJS Code Style 11 | 12 | License: Apache-2.0 OR MIT 13 |

14 | 15 | ## Background 16 | 17 | Merkle clocks are a method of recording events with partial order. This repo implements a method of managing merkle clocks with UCANs. 18 | 19 | Read the [SPEC](https://github.com/web3-storage/specs/blob/main/w3-clock.md). 20 | 21 | ### Capabilities 22 | 23 | #### `clock/advance` 24 | 25 | Advance the clock by adding a new event. 26 | 27 | #### `clock/head` 28 | 29 | Get the current clock head. 30 | 31 | 42 | 43 | ## License 44 | 45 | Dual-licensed under [MIT + Apache 2.0](LICENSE.md) 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web3-storage/clock", 3 | "version": "0.4.1", 4 | "description": "UCAN based Merkle Clock as a service.", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm run build:worker:debug && miniflare dist/worker.mjs --watch --debug -m", 8 | "build": "npm run build:worker && npm run build:module", 9 | "build:worker": "esbuild --bundle src/worker/index.js --format=esm --sourcemap --minify --outfile=dist/worker.mjs", 10 | "build:worker:debug": "esbuild --bundle src/worker/index.js --format=esm --sourcemap --outfile=dist/worker.mjs", 11 | "build:module": "tsc --build", 12 | "test": "mocha --experimental-vm-modules 'test/**/*.test.js'", 13 | "coverage": "c8 --reporter=text --reporter=html --exclude=node_modules --exclude=test --exclude-after-remap npm run test", 14 | "lint": "standard" 15 | }, 16 | "keywords": [ 17 | "merkle", 18 | "clock", 19 | "CRDT", 20 | "IPFS", 21 | "UCAN" 22 | ], 23 | "author": "Alan Shaw", 24 | "license": "Apache-2.0 OR MIT", 25 | "exports": { 26 | ".": { 27 | "types": "./dist/src/index.d.ts", 28 | "import": "./src/index.js" 29 | }, 30 | "./api": { 31 | "types": "./dist/src/api.d.ts", 32 | "import": "./src/api.js" 33 | }, 34 | "./client": { 35 | "types": "./dist/src/client/index.d.ts", 36 | "import": "./src/client/index.js" 37 | }, 38 | "./client/api": { 39 | "types": "./dist/src/client/api.d.ts", 40 | "import": "./src/client/api.js" 41 | }, 42 | "./capabilities": { 43 | "types": "./dist/src/capabilities.d.ts", 44 | "import": "./src/capabilities.js" 45 | }, 46 | "./server": { 47 | "types": "./dist/src/server/index.d.ts", 48 | "import": "./src/server/index.js" 49 | } 50 | }, 51 | "typesVersions": { 52 | "*": { 53 | "*": [ 54 | "dist/*" 55 | ], 56 | "api": [ 57 | "dist/src/api.d.ts" 58 | ], 59 | "client": [ 60 | "dist/src/client/index.d.ts" 61 | ], 62 | "client/api": [ 63 | "dist/src/client/api.d.ts" 64 | ], 65 | "capabilities": [ 66 | "dist/src/capabilities.d.ts" 67 | ], 68 | "server": [ 69 | "dist/src/server/index.d.ts" 70 | ] 71 | } 72 | }, 73 | "files": [ 74 | "src/*.js", 75 | "src/client", 76 | "src/server", 77 | "dist/src/*.d.ts", 78 | "dist/src/*.d.ts.map", 79 | "dist/src/client", 80 | "dist/src/server" 81 | ], 82 | "dependencies": { 83 | "@ipld/dag-cbor": "^9.0.0", 84 | "@ipld/dag-ucan": "^3.3.2", 85 | "@ucanto/client": "^9.0.1", 86 | "@ucanto/interface": "^10.0.1", 87 | "@ucanto/principal": "^9.0.1", 88 | "@ucanto/server": "^10.0.0", 89 | "@ucanto/transport": "^9.1.1", 90 | "@ucanto/validator": "^9.0.2", 91 | "@web3-storage/pail": "^0.5.0", 92 | "hashlru": "^2.3.0", 93 | "multiformats": "^13.1.0", 94 | "p-retry": "^6.2.0" 95 | }, 96 | "devDependencies": { 97 | "@cloudflare/workers-types": "^4.20230115.0", 98 | "c8": "^7.13.0", 99 | "esbuild": "^0.17.10", 100 | "miniflare": "^2.12.1", 101 | "mocha": "^10.2.0", 102 | "standard": "^17.0.0", 103 | "typescript": "^5.0.2", 104 | "wrangler": "^2.12.0" 105 | }, 106 | "standard": { 107 | "ignore": [ 108 | "*.ts" 109 | ] 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | export {} // eslint-disable-line 2 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { Failure, ServiceMethod, DID } from '@ucanto/interface' 2 | import { ClockAdvance, ClockHead } from './capabilities.js' 3 | // import { ClockFollow, ClockUnfollow, ClockFollowing } from './capabilities.js' 4 | import { EventLink } from '@web3-storage/pail/clock/api' 5 | 6 | /** DID of a merkle clock. */ 7 | export type ClockDID = DID 8 | /** DID of an clock event emitter (usually an agent). */ 9 | export type EmitterDID = DID 10 | 11 | export interface Service { 12 | clock: ClockService 13 | } 14 | 15 | export interface ClockService { 16 | advance: ServiceMethod[] }, Failure> 17 | head: ServiceMethod[] }, Failure> 18 | // follow: ServiceMethod 19 | // unfollow: ServiceMethod 20 | // following: ServiceMethod, Failure> 21 | } 22 | -------------------------------------------------------------------------------- /src/capabilities.js: -------------------------------------------------------------------------------- 1 | import { capability, URI, Link, Schema } from '@ucanto/validator' 2 | 3 | /** 4 | * @typedef {import('@ucanto/interface').InferInvokedCapability} Clock 5 | * @typedef {import('@ucanto/interface').InferInvokedCapability} ClockFollow 6 | * @typedef {import('@ucanto/interface').InferInvokedCapability} ClockUnfollow 7 | * @typedef {import('@ucanto/interface').InferInvokedCapability} ClockFollowing 8 | * @typedef {import('@ucanto/interface').InferInvokedCapability} ClockAdvance 9 | * @typedef {import('@ucanto/interface').InferInvokedCapability} ClockHead 10 | */ 11 | 12 | export const clock = capability({ 13 | can: 'clock/*', 14 | with: URI.match({ protocol: 'did:' }) 15 | }) 16 | 17 | /** 18 | * Follow advances made by an agent to a clock. 19 | */ 20 | export const follow = capability({ 21 | can: 'clock/follow', 22 | with: URI.match({ protocol: 'did:' }), 23 | nb: Schema.struct({ 24 | iss: URI.match({ protocol: 'did:' }).optional(), 25 | with: URI.match({ protocol: 'did:' }).optional() 26 | }) 27 | }) 28 | 29 | /** 30 | * Stop following advances made by an agent to a clock. 31 | */ 32 | export const unfollow = capability({ 33 | can: 'clock/unfollow', 34 | with: URI.match({ protocol: 'did:' }), 35 | nb: Schema.struct({ 36 | iss: URI.match({ protocol: 'did:' }).optional(), 37 | with: URI.match({ protocol: 'did:' }).optional() 38 | }) 39 | }) 40 | 41 | /** 42 | * List the agents this clock is following advances from. 43 | */ 44 | export const following = capability({ 45 | can: 'clock/following', 46 | with: URI.match({ protocol: 'did:' }) 47 | }) 48 | 49 | /** 50 | * List the CIDs of the events at the head of this clock. 51 | */ 52 | export const head = capability({ 53 | can: 'clock/head', 54 | with: URI.match({ protocol: 'did:' }) 55 | }) 56 | 57 | /** 58 | * Advance the clock by adding an event. 59 | */ 60 | export const advance = capability({ 61 | can: 'clock/advance', 62 | with: URI.match({ protocol: 'did:' }), 63 | nb: Schema.struct({ 64 | event: Link.match({ version: 1 }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/client/api.js: -------------------------------------------------------------------------------- 1 | export {} // eslint-disable-line 2 | -------------------------------------------------------------------------------- /src/client/api.ts: -------------------------------------------------------------------------------- 1 | import { Signer, Proof, DID, Principal, ConnectionView, Block } from '@ucanto/interface' 2 | import { EventView } from '@web3-storage/pail/clock/api' 3 | import { Service } from '../api' 4 | 5 | export interface InvocationConfig { 6 | /** 7 | * Signing authority that is issuing the UCAN invocation(s). 8 | */ 9 | issuer: Signer 10 | /** 11 | * The principal delegated to in the current UCAN. 12 | */ 13 | audience?: Principal 14 | /** 15 | * The resource the invocation applies to. 16 | */ 17 | with: DID 18 | /** 19 | * Proof(s) the issuer has the capability to perform the action. 20 | */ 21 | proofs: Proof[] 22 | } 23 | 24 | export interface Connectable { 25 | connection?: ConnectionView> 26 | } 27 | 28 | export interface RequestOptions extends Connectable {} 29 | 30 | export interface FollowOptions extends RequestOptions { 31 | /** 32 | * Clock event issuer. 33 | */ 34 | issuer?: DID 35 | /** 36 | * Target clock. 37 | */ 38 | with?: DID 39 | } 40 | 41 | export interface AdvanceOptions extends RequestOptions { 42 | /** 43 | * Event blocks that may help the service to advance the clock. This are 44 | * optional because event blocks _should_ be made available to fetch directly 45 | * from the IPFS network. 46 | */ 47 | blocks?: Block>[] 48 | } 49 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import { connect as clientConnect } from '@ucanto/client' 2 | import { CAR, HTTP } from '@ucanto/transport' 3 | import * as DID from '@ipld/dag-ucan/did' 4 | import * as ClockCaps from '../capabilities.js' 5 | 6 | export const SERVICE_URL = 'https://clock.web3.storage' 7 | export const SERVICE_PRINCIPAL = 'did:web:clock.web3.storage' 8 | 9 | /** 10 | * Advance the clock by adding an event. 11 | * 12 | * @template T 13 | * @param {import('./api').InvocationConfig} conf 14 | * @param {import('@web3-storage/pail/clock/api').EventLink} event 15 | * @param {import('./api').AdvanceOptions} [options] 16 | */ 17 | export async function advance ({ issuer, with: resource, proofs, audience }, event, options) { 18 | const conn = options?.connection ?? connect() 19 | const facts = options?.blocks ? [Object.fromEntries(options.blocks.map(b => [b.cid.toString(), b.cid]))] : [] 20 | const invocation = ClockCaps.advance 21 | .invoke({ 22 | issuer, 23 | audience: audience ?? conn.id, 24 | with: resource, 25 | nb: { event }, 26 | proofs, 27 | facts 28 | }) 29 | 30 | for (const block of options?.blocks ?? []) { 31 | invocation.attach(block) 32 | } 33 | 34 | return invocation.execute(conn) 35 | } 36 | 37 | /** 38 | * Retrieve the clock head. 39 | * 40 | * @template T 41 | * @param {import('./api').InvocationConfig} conf 42 | * @param {import('./api').RequestOptions} [options] 43 | */ 44 | export async function head ({ issuer, with: resource, proofs, audience }, options) { 45 | const conn = options?.connection ?? connect() 46 | return await ClockCaps.head 47 | .invoke({ 48 | issuer, 49 | audience: audience ?? conn.id, 50 | with: resource, 51 | proofs 52 | }) 53 | .execute(conn) 54 | } 55 | 56 | // /** 57 | // * Instruct a clock to follow the issuer, or optionally a different issuer, 58 | // * contributing to a different clock. 59 | // * 60 | // * @template T 61 | // * @param {import('./api').InvocationConfig} conf 62 | // * @param {import('./api').FollowOptions} [options] 63 | // */ 64 | // export async function follow ({ issuer, with: resource, proofs, audience }, options) { 65 | // const conn = options?.connection ?? connect() 66 | // const result = await ClockCaps.follow 67 | // .invoke({ 68 | // issuer, 69 | // audience: audience ?? conn.id, 70 | // with: resource, 71 | // nb: { 72 | // ...(options?.issuer ? { iss: options.issuer } : {}), 73 | // ...(options?.with ? { with: options.with } : {}) 74 | // }, 75 | // proofs 76 | // }) 77 | // .execute(conn) 78 | 79 | // if (result.error) { 80 | // throw new Error(`failed ${ClockCaps.follow.can} invocation`, { cause: result }) 81 | // } 82 | 83 | // return result 84 | // } 85 | 86 | /** 87 | * @template T 88 | * @param {object} [options] 89 | * @param {import('@ucanto/interface').Principal} [options.servicePrincipal] 90 | * @param {URL} [options.serviceURL] 91 | * @returns {import('@ucanto/interface').ConnectionView>} 92 | */ 93 | export function connect (options) { 94 | const url = options?.serviceURL ?? new URL(SERVICE_URL) 95 | return clientConnect({ 96 | id: options?.servicePrincipal ?? DID.parse(SERVICE_PRINCIPAL), 97 | codec: CAR.outbound, 98 | channel: HTTP.open({ url, method: 'POST' }) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * as Server from './server/index.js' 2 | export * as Client from './client/index.js' 3 | export * as Capabilities from './capabilities.js' 4 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import { Verifier } from '@ucanto/principal' 2 | import * as Server from '@ucanto/server' 3 | import * as CAR from '@ucanto/transport/car' 4 | import { access, Schema, Failure } from '@ucanto/validator' 5 | 6 | /** 7 | * @template T 8 | * @param {import('@ucanto/interface').Signer} signer 9 | * @param {import('../api').Service} service 10 | */ 11 | export function createServer (signer, service) { 12 | return Server.create({ 13 | id: signer, 14 | codec: CAR.inbound, 15 | service, 16 | catch: err => console.error(err), 17 | // TODO: wire into revocations 18 | validateAuthorization: () => ({ ok: {} }), 19 | // @ts-expect-error 20 | authorities: [Verifier.parse('did:key:z6MkqdncRZ1wj8zxCTDUQ8CRT8NQWd63T7mZRvZUX8B7XDFi').withDID('did:web:web3.storage')] 21 | }) 22 | } 23 | 24 | /** 25 | * Function that can be used to define given capability provider. It decorates 26 | * passed handler and takes care of UCAN validation and only calls the handler 27 | * when validation succeeds. 28 | * 29 | * 30 | * @template {import('@ucanto/interface').Ability} A 31 | * @template {import('@ucanto/interface').URI} R 32 | * @template {import('@ucanto/interface').Caveats} C 33 | * @template {{}} O 34 | * @template {import('@ucanto/interface').Failure} X 35 | * @template {import('@ucanto/interface').Result} Result 36 | * @param {import('@ucanto/interface').CapabilityParser>>} capability 37 | * @param {(input:import('@ucanto/server').ProviderInput>) => import('@ucanto/interface').Await} handler 38 | * @returns {import('@ucanto/interface').ServiceMethod, O & Result['ok'], X & Result['error']>} 39 | */ 40 | export const provide = (capability, handler) => 41 | /** 42 | * @param {import('@ucanto/interface').Invocation>} invocation 43 | * @param {import('@ucanto/interface').InvocationContext & { authorities?: import('@ucanto/interface').Verifier[] }} options 44 | */ 45 | async (invocation, options) => { 46 | // If audience schema is not provided we expect the audience to match 47 | // the server id. Users could pass `schema.string()` if they want to accept 48 | // any audience. 49 | const audienceSchema = Schema.literal(options.id.did()) 50 | const result = audienceSchema.read(invocation.audience.did()) 51 | if (result.error) { 52 | return { error: new InvalidAudience({ cause: result.error }) } 53 | } 54 | 55 | for (const authority of options.authorities ?? []) { 56 | const authorization = await access(invocation, { ...options, authority, capability }) 57 | if (authorization.error) continue 58 | return handler({ 59 | capability: authorization.ok.capability, 60 | invocation, 61 | context: options 62 | }) 63 | } 64 | 65 | const authorization = await access(invocation, { 66 | ...options, 67 | authority: options.id, 68 | capability 69 | }) 70 | if (authorization.error) { 71 | return authorization 72 | } 73 | return handler({ 74 | capability: authorization.ok.capability, 75 | invocation, 76 | context: options 77 | }) 78 | } 79 | 80 | class InvalidAudience extends Failure { 81 | /** 82 | * @param {object} source 83 | * @param {import('@ucanto/interface').Failure} source.cause 84 | */ 85 | constructor ({ cause }) { 86 | super() 87 | /** @type {'InvalidAudience'} */ 88 | this.name = 'InvalidAudience' 89 | this.cause = cause 90 | } 91 | 92 | describe () { 93 | return this.cause.message 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/worker/block.js: -------------------------------------------------------------------------------- 1 | import retry from 'p-retry' 2 | import LRU from 'hashlru' 3 | import { sha256 } from 'multiformats/hashes/sha2' 4 | import { equals } from 'multiformats/bytes' 5 | 6 | export { MultiBlockFetcher } from '@web3-storage/pail/block' 7 | 8 | /** 9 | * @typedef {{ put: (block: import('multiformats').Block) => Promise }} BlockPutter 10 | */ 11 | 12 | export class MemoryBlockstore { 13 | /** @param {Array} [blocks] */ 14 | constructor (blocks = []) { 15 | /** @type {{ get: (k: string) => Uint8Array | undefined, set: (k: string, v: Uint8Array) => void }} */ 16 | this._data = new Map(blocks.map(b => [b.cid.toString(), b.bytes])) 17 | } 18 | 19 | /** @type { import('@web3-storage/pail/api').BlockFetcher['get']} */ 20 | async get (cid) { 21 | const bytes = this._data.get(cid.toString()) 22 | if (!bytes) return 23 | return { cid, bytes } 24 | } 25 | 26 | /** @param {import('multiformats').Block} block */ 27 | async put (block) { 28 | this._data.set(block.cid.toString(), block.bytes) 29 | } 30 | } 31 | 32 | export class LRUBlockstore extends MemoryBlockstore { 33 | /** @param {number} [max] */ 34 | constructor (max = 50) { 35 | super() 36 | this._data = LRU(max) 37 | } 38 | } 39 | 40 | /** 41 | * @param {import('@web3-storage/pail/api').BlockFetcher} fetcher 42 | * @param {import('@web3-storage/pail/api').BlockFetcher & BlockPutter} cache 43 | */ 44 | export function withCache (fetcher, cache) { 45 | return { 46 | /** @type { import('@web3-storage/pail/api').BlockFetcher['get']} */ 47 | async get (cid) { 48 | try { 49 | const block = await cache.get(cid) 50 | if (block) return block 51 | } catch {} 52 | const block = await fetcher.get(cid) 53 | if (block) { 54 | // @ts-expect-error 55 | await cache.put(block) 56 | } 57 | return block 58 | } 59 | } 60 | } 61 | 62 | export class GatewayBlockFetcher { 63 | #url 64 | 65 | /** @param {string|URL} [url] */ 66 | constructor (url) { 67 | this.#url = new URL(url ?? 'https://ipfs.io') 68 | } 69 | 70 | /** @type { import('@web3-storage/pail/api').BlockFetcher['get']} */ 71 | async get (cid) { 72 | return await retry(async () => { 73 | const controller = new AbortController() 74 | const timeoutID = setTimeout(() => controller.abort(), 10000) 75 | try { 76 | const res = await fetch(new URL(`/ipfs/${cid}?format=raw`, this.#url), { signal: controller.signal }) 77 | if (!res.ok) return 78 | const bytes = new Uint8Array(await res.arrayBuffer()) 79 | const digest = await sha256.digest(bytes) 80 | if (!equals(digest.digest, cid.multihash.digest)) { 81 | throw new Error(`failed sha2-256 content integrity check: ${cid}`) 82 | } 83 | return { cid, bytes } 84 | } catch (err) { 85 | throw new Error(`failed to fetch block: ${cid}`, { cause: err }) 86 | } finally { 87 | clearTimeout(timeoutID) 88 | } 89 | }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/worker/durable-clock.js: -------------------------------------------------------------------------------- 1 | import * as Clock from '@web3-storage/pail/clock' 2 | import { parse } from 'multiformats/link' 3 | import * as cbor from '@ipld/dag-cbor' 4 | import { GatewayBlockFetcher, LRUBlockstore, MemoryBlockstore, MultiBlockFetcher, withCache } from './block.js' 5 | 6 | /** 7 | * @typedef {{ method: string, args: any[] }} MethodCall 8 | * @typedef {import('../api.js').EmitterDID} EmitterDID DID of an clock event emitter (usually an agent). 9 | * @typedef {import('../api.js').ClockDID} ClockDID DID of a merkle clock. 10 | * @typedef {Map>} Followings Event emitters that this clock is following (and the associated clock DID they are contributing to). 11 | * @typedef {Map>} Subscribers Clocks that want to receive advances made to this clock. 12 | * @typedef {'follow'|'unfollow'|'following'|'subscribe'|'unsubscribe'|'subscribers'|'advance'|'head'} DurableClockAPIMethod 13 | */ 14 | 15 | const KEY_FOLLOWING = 'following' 16 | const KEY_SUBSCRIBERS = 'subscribers' 17 | const KEY_HEAD = 'head' 18 | /** @type {DurableClockAPIMethod[]} */ 19 | const API_METHODS = ['follow', 'unfollow', 'following', 'subscribe', 'unsubscribe', 'subscribers', 'advance', 'head'] 20 | 21 | /** @type {import('@cloudflare/workers-types').DurableObject} */ 22 | export class DurableClock { 23 | #state 24 | #fetcher 25 | #cache 26 | 27 | /** 28 | * @param {import('@cloudflare/workers-types').DurableObjectState} state 29 | * @param {import('./types').Environment} env 30 | */ 31 | constructor (state, env) { 32 | this.#state = state 33 | this.#cache = new LRUBlockstore(env.BLOCK_CACHE_SIZE ? parseInt(env.BLOCK_CACHE_SIZE) : undefined) 34 | this.#fetcher = new GatewayBlockFetcher(env.GATEWAY_URL) 35 | } 36 | 37 | /** 38 | * @param {import('@cloudflare/workers-types').Request} request 39 | * @returns {Promise} 40 | */ 41 | async fetch (request) { 42 | const body = /** @type {MethodCall} */ (cbor.decode(new Uint8Array(await request.arrayBuffer()))) 43 | const method = API_METHODS.find(m => m === body.method) 44 | if (!method) throw new Error(`invalid method: ${body.method}`) 45 | if (typeof this[method] !== 'function') throw new Error(`not implemented: ${method}`) 46 | const res = await this[method](...body.args) 47 | // @ts-expect-error 48 | return new Response(res && cbor.encode(res)) 49 | } 50 | 51 | /** 52 | * @param {ClockDID} target Clock to follow. 53 | * @param {EmitterDID} emitter Event emitter (usually an agent) who emits the events. 54 | */ 55 | async follow (target, emitter) { 56 | // TODO: maybe we could preload this into memory and update both? 57 | return await this.#state.blockConcurrencyWhile(async () => { 58 | /** @type {Followings} */ 59 | const followings = (await this.#state.storage.get(KEY_FOLLOWING)) ?? new Map() 60 | const emitters = followings.get(target) ?? new Set() 61 | if (!emitters.has(emitter)) { 62 | emitters.add(emitter) 63 | followings.set(target, emitters) 64 | await this.#state.storage.put(KEY_FOLLOWING, followings) 65 | } 66 | }) 67 | } 68 | 69 | /** 70 | * @param {ClockDID} target 71 | * @param {EmitterDID} emitter 72 | */ 73 | async unfollow (target, emitter) { 74 | return await this.#state.blockConcurrencyWhile(async () => { 75 | /** @type {Followings} */ 76 | const followings = (await this.#state.storage.get(KEY_FOLLOWING)) ?? new Map() 77 | const emitters = followings.get(target) ?? new Set() 78 | if (emitters.has(emitter)) { 79 | emitters.delete(emitter) 80 | if (emitters.size) { 81 | followings.set(target, emitters) 82 | } else { 83 | followings.delete(target) 84 | } 85 | await this.#state.storage.put(KEY_FOLLOWING, followings) 86 | } 87 | }) 88 | } 89 | 90 | /** @returns {Promise>} */ 91 | async following () { 92 | /** @type {Followings} */ 93 | const follows = (await this.#state.storage.get(KEY_FOLLOWING)) ?? new Map() 94 | return [...follows.entries()].map(([k, v]) => [k, [...v.values()]]) 95 | } 96 | 97 | /** 98 | * Subscribe to recieve advances made to this clock by the passed emitter. 99 | * @param {ClockDID} subscriber Subscriber clock. 100 | * @param {EmitterDID} emitter Event emitter. 101 | */ 102 | async subscribe (subscriber, emitter) { 103 | return await this.#state.blockConcurrencyWhile(async () => { 104 | // TODO: fail if this clock is not following emitter? 105 | /** @type {Subscribers} */ 106 | const subscribers = (await this.#state.storage.get(KEY_SUBSCRIBERS)) ?? new Map() 107 | const emitters = subscribers.get(subscriber) ?? new Set() 108 | if (!emitters.has(emitter)) { 109 | emitters.add(emitter) 110 | subscribers.set(subscriber, emitters) 111 | await this.#state.storage.put(KEY_SUBSCRIBERS, subscribers) 112 | } 113 | }) 114 | } 115 | 116 | /** 117 | * @param {ClockDID} subscriber 118 | * @param {EmitterDID} emitter 119 | */ 120 | async unsubscribe (subscriber, emitter) { 121 | return await this.#state.blockConcurrencyWhile(async () => { 122 | /** @type {Subscribers} */ 123 | const subscribers = (await this.#state.storage.get(KEY_SUBSCRIBERS)) ?? new Map() 124 | const emitters = subscribers.get(subscriber) ?? new Set() 125 | if (emitters.has(emitter)) { 126 | emitters.delete(emitter) 127 | subscribers.set(subscriber, emitters) 128 | await this.#state.storage.put(KEY_SUBSCRIBERS, subscribers) 129 | } 130 | }) 131 | } 132 | 133 | /** @returns {Promise>} */ 134 | async subscribers () { 135 | /** @type {Subscribers} */ 136 | const subscribers = (await this.#state.storage.get(KEY_SUBSCRIBERS)) ?? new Map() 137 | return [...subscribers.entries()].map(([k, v]) => [k, [...v.values()]]) 138 | } 139 | 140 | /** @param {import('@web3-storage/pail/clock/api').EventLink[]} head */ 141 | async #setHead (head) { 142 | await this.#state.storage.put(KEY_HEAD, head.map(h => String(h))) 143 | } 144 | 145 | /** @returns {Promise[]>} */ 146 | async head () { 147 | // TODO: keep sync'd copy in memory 148 | /** @type {string[]} */ 149 | const head = (await this.#state.storage.get(KEY_HEAD)) ?? [] 150 | return head.map(h => parse(h)) 151 | } 152 | 153 | /** 154 | * @param {import('@web3-storage/pail/clock/api').EventLink} event 155 | * @param {import('@web3-storage/pail/clock/api').EventBlockView>[]} [blocks] 156 | */ 157 | async advance (event, blocks) { 158 | return await this.#state.blockConcurrencyWhile(async () => { 159 | const fetcher = withCache( 160 | blocks?.length 161 | ? new MultiBlockFetcher(new MemoryBlockstore(blocks), this.#fetcher) 162 | : this.#fetcher, 163 | this.#cache 164 | ) 165 | const head = (await Clock.advance(fetcher, await this.head(), event)) 166 | await this.#setHead(head) 167 | return head 168 | }) 169 | } 170 | } 171 | 172 | /** 173 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 174 | * @param {ClockDID} clock Clock that should follow events by emitter. 175 | * @param {ClockDID} target Clock targetted by events emitted by emitter. 176 | * @param {EmitterDID} emitter Agent that is emitting events that contribute to the target. 177 | */ 178 | export async function follow (clockNamespace, clock, target, emitter) { 179 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 180 | const body = cbor.encode({ method: 'follow', args: [target, emitter] }) 181 | await stub.fetch('http://localhost', { method: 'POST', body }) 182 | if (clock !== target) { 183 | await subscribe(clockNamespace, target, clock, emitter) 184 | } 185 | } 186 | 187 | /** 188 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 189 | * @param {ClockDID} clock Clock that should unfollow events by emitter. 190 | * @param {ClockDID} target Clock targetted by events emitted by emitter. 191 | * @param {EmitterDID} emitter Agent that is emitting events that contribute to the target. 192 | */ 193 | export async function unfollow (clockNamespace, clock, target, emitter) { 194 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 195 | const body = cbor.encode({ method: 'unfollow', args: [target, emitter] }) 196 | await stub.fetch('http://localhost', { method: 'POST', body }) 197 | if (clock !== target) { 198 | await unsubscribe(clockNamespace, target, clock, emitter) 199 | } 200 | } 201 | 202 | /** 203 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 204 | * @param {import('@ucanto/interface').DID} clock Clock to get following emitters for. 205 | */ 206 | export async function following (clockNamespace, clock) { 207 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 208 | const body = cbor.encode({ method: 'following', args: [] }) 209 | const res = await stub.fetch('http://localhost', { method: 'POST', body }) 210 | const data = /** @type {Array<[ClockDID, EmitterDID[]]>} */ (cbor.decode(new Uint8Array(await res.arrayBuffer()))) 211 | return new Map(data.map(([k, v]) => [k, new Set(v)])) 212 | } 213 | 214 | /** 215 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 216 | * @param {ClockDID} clock Clock to add subscription to. 217 | * @param {ClockDID} subscriber Clock that should recieve events emitted by emitter. 218 | * @param {EmitterDID} emitter Agent that is emitting events that contribute to clock. 219 | */ 220 | async function subscribe (clockNamespace, clock, subscriber, emitter) { 221 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 222 | const body = cbor.encode({ method: 'subscribe', args: [subscriber, emitter] }) 223 | await stub.fetch('http://localhost', { method: 'POST', body }) 224 | } 225 | 226 | /** 227 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 228 | * @param {ClockDID} clock Clock to remove subscription from. 229 | * @param {ClockDID} subscriber Current subscription clock. 230 | * @param {EmitterDID} emitter Agent that is emitting events that contribute to clock. 231 | */ 232 | async function unsubscribe (clockNamespace, clock, subscriber, emitter) { 233 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 234 | const body = cbor.encode({ method: 'unsubscribe', args: [subscriber, emitter] }) 235 | await stub.fetch('http://localhost', { method: 'POST', body }) 236 | } 237 | 238 | /** 239 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 240 | * @param {ClockDID} clock Clock to get subscribers for. 241 | * @returns {Promise} 242 | */ 243 | async function subscribers (clockNamespace, clock) { 244 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 245 | const body = cbor.encode({ method: 'subscribers', args: [] }) 246 | const res = await stub.fetch('http://localhost', { method: 'POST', body }) 247 | const data = /** @type {Array<[ClockDID, EmitterDID[]]>} */ (cbor.decode(new Uint8Array(await res.arrayBuffer()))) 248 | return new Map(data.map(([k, v]) => [k, new Set(v)])) 249 | } 250 | 251 | /** 252 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 253 | * @param {ClockDID} clock Clock we want the head of. 254 | * @returns {Promise[]>} 255 | */ 256 | export async function head (clockNamespace, clock) { 257 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 258 | const body = cbor.encode({ method: 'head', args: [] }) 259 | const res = await stub.fetch('http://localhost', { method: 'POST', body }) 260 | return cbor.decode(new Uint8Array(await res.arrayBuffer())) 261 | } 262 | 263 | /** 264 | * Advance the clock with the passed event, created by the passed agent. 265 | * 266 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 267 | * @param {ClockDID} clock Clock to advance. 268 | * @param {EmitterDID} emitter Agent that is emitting events that contribute to the target. 269 | * @param {import('@web3-storage/pail/clock/api').EventLink} event Event to advance the clock with. 270 | * @param {import('multiformats').Block>[]} [blocks] Provided event blocks to advance the clock with. 271 | * @returns {Promise[]>} 272 | */ 273 | export async function advance (clockNamespace, clock, emitter, event, blocks) { 274 | return advanceAnyClock(clockNamespace, clock, clock, emitter, event, blocks) 275 | } 276 | 277 | /** 278 | * @param {import('@cloudflare/workers-types').DurableObjectNamespace} clockNamespace Durable Object API 279 | * @param {ClockDID} clock The clock that this event should advance. 280 | * @param {ClockDID} target The clock targetted by events emitted by emitter. 281 | * @param {EmitterDID} emitter Agent that is emitting events that contribute to the target. 282 | * @param {import('@web3-storage/pail/clock/api').EventLink} event Event to advance the clock with. 283 | * @param {import('multiformats').Block>[]} [blocks] Provided event blocks to advance the clock with. 284 | * @returns {Promise[]>} 285 | */ 286 | async function advanceAnyClock (clockNamespace, clock, target, emitter, event, blocks = []) { 287 | const stub = clockNamespace.get(clockNamespace.idFromName(clock)) 288 | const body = cbor.encode({ 289 | method: 'advance', 290 | args: [event, blocks.map(b => ({ cid: b.cid, bytes: b.bytes }))] 291 | }) 292 | const res = await stub.fetch('http://localhost', { method: 'POST', body }) 293 | const data = /** @type {import('@web3-storage/pail/clock/api').EventLink[]} */ (cbor.decode(new Uint8Array(await res.arrayBuffer()))) 294 | 295 | // advance subscribers of this clock 296 | if (clock === target) { 297 | const others = await subscribers(clockNamespace, clock) 298 | for (const [clock, emitters] of others) { 299 | if (emitters.has(emitter)) { 300 | await advanceAnyClock(clockNamespace, clock, target, emitter, event) 301 | } 302 | } 303 | } 304 | 305 | return data 306 | } 307 | -------------------------------------------------------------------------------- /src/worker/index.js: -------------------------------------------------------------------------------- 1 | import { DID } from '@ucanto/core' 2 | import { Signer } from '@ucanto/principal/ed25519' 3 | import { withCORSHeaders, withErrorHandler, withCORSPreflight, withHTTPPost, composeMiddleware } from './middleware.js' 4 | import { createServer, createService } from './service.js' 5 | 6 | export default { 7 | /** @type {import('./types').Handler} */ 8 | fetch (request, env, ctx) { 9 | const middleware = composeMiddleware( 10 | withCORSPreflight, 11 | withCORSHeaders, 12 | withErrorHandler, 13 | withHTTPPost 14 | ) 15 | return middleware(handler)(request, env, ctx) 16 | } 17 | } 18 | 19 | /** @type {import('./types').Handler} */ 20 | async function handler (request, env) { 21 | /** @type {import('@ucanto/interface').Signer} */ 22 | let signer = Signer.parse(env.PRIVATE_KEY) 23 | if (env.DID) { 24 | const did = DID.parse(env.DID).did() 25 | signer = signer.withDID(did) 26 | } 27 | const service = createService({ clockNamespace: env.CLOCK }) 28 | const server = createServer(signer, service) 29 | 30 | const { headers, body } = await server.request({ 31 | body: new Uint8Array(await request.arrayBuffer()), 32 | headers: Object.fromEntries(request.headers) 33 | }) 34 | 35 | return new Response(body, { headers }) 36 | } 37 | 38 | export { DurableClock as DurableClock0 } from './durable-clock.js' 39 | -------------------------------------------------------------------------------- /src/worker/middleware.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('./types').Context} Context */ 2 | 3 | /** 4 | * Adds CORS preflight headers to the response. 5 | * @type {import('./types').Middleware} 6 | */ 7 | export function withCORSPreflight (handler) { 8 | return async (request, env, ctx) => { 9 | if (request.method !== 'OPTIONS') { 10 | return handler(request, env, ctx) 11 | } 12 | 13 | const { headers } = request 14 | // Make sure the necessary headers are present for this to be a valid pre-flight request 15 | if ( 16 | headers.get('Origin') != null && 17 | headers.get('Access-Control-Request-Method') != null && 18 | headers.get('Access-Control-Request-Headers') != null 19 | ) { 20 | /** @type {Record} */ 21 | const respHeaders = { 22 | 'Content-Length': '0', 23 | 'Access-Control-Allow-Origin': headers.get('origin') || '*', 24 | 'Access-Control-Allow-Methods': 'POST,OPTIONS', 25 | 'Access-Control-Max-Age': '86400', 26 | // Allow all future content Request headers to go back to browser 27 | // such as Authorization (Bearer) or X-Client-Name-Version 28 | 'Access-Control-Allow-Headers': headers.get('Access-Control-Request-Headers') ?? '' 29 | } 30 | 31 | return new Response(undefined, { status: 204, headers: respHeaders }) 32 | } 33 | 34 | return new Response('non CORS options request not allowed', { status: 405 }) 35 | } 36 | } 37 | 38 | /** 39 | * Adds CORS headers to the response. 40 | * @type {import('./types').Middleware} 41 | */ 42 | export function withCORSHeaders (handler) { 43 | return async (request, env, ctx) => { 44 | const response = await handler(request, env, ctx) 45 | const origin = request.headers.get('origin') 46 | if (origin) { 47 | response.headers.set('Access-Control-Allow-Origin', origin) 48 | response.headers.set('Access-Control-Allow-Methods', 'POST,OPTIONS') 49 | response.headers.set('Vary', 'Origin') 50 | } else { 51 | response.headers.set('Access-Control-Allow-Origin', '*') 52 | } 53 | return response 54 | } 55 | } 56 | 57 | /** 58 | * Catches any errors, logs them and returns a suitable response. 59 | * @type {import('./types').Middleware} 60 | */ 61 | export function withErrorHandler (handler) { 62 | return async (request, env, ctx) => { 63 | try { 64 | return await handler(request, env, ctx) 65 | } catch (/** @type {any} */ err) { 66 | if (!err.status || err.status >= 500) console.error(err.stack) 67 | const msg = env.DEBUG === 'true' 68 | ? `${err.stack}${err?.cause?.stack ? `\n[cause]: ${err.cause.stack}` : ''}` 69 | : err.message 70 | return new Response(msg, { status: err.status || 500 }) 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Validates the request uses a HTTP POST method. 77 | * @type {import('./types').Middleware} 78 | */ 79 | export function withHTTPPost (handler) { 80 | return (request, env, ctx) => { 81 | if (request.method !== 'POST') { 82 | throw Object.assign(new Error('method not allowed'), { status: 405 }) 83 | } 84 | return handler(request, env, ctx) 85 | } 86 | } 87 | 88 | /** 89 | * @param {...import('./types').Middleware} middlewares 90 | * @returns {import('./types').Middleware} 91 | */ 92 | export function composeMiddleware (...middlewares) { 93 | return handler => middlewares.reduceRight((h, m) => m(h), handler) 94 | } 95 | -------------------------------------------------------------------------------- /src/worker/service.js: -------------------------------------------------------------------------------- 1 | import { parse } from '@ipld/dag-ucan/did' 2 | import * as dagCBOR from '@ipld/dag-cbor' 3 | import * as ClockCaps from '../capabilities.js' 4 | import * as Clock from './durable-clock.js' 5 | import { provide } from '../server/index.js' 6 | 7 | export { createServer } from '../server/index.js' 8 | 9 | /** 10 | * @template T 11 | * @param {{ clockNamespace: import('@cloudflare/workers-types').DurableObjectNamespace }} conf 12 | * @returns {import('../api.js').Service} 13 | */ 14 | export function createService ({ clockNamespace }) { 15 | return { 16 | clock: { 17 | // follow: provide( 18 | // ClockCaps.follow, 19 | // async ({ capability, invocation }) => { 20 | // const clock = capability.with 21 | // const target = capability.nb.with ?? capability.with 22 | // const emitter = capability.nb.iss ?? invocation.issuer.did() 23 | // // @ts-expect-error 24 | // await Clock.follow(clockNamespace, clock, target, emitter) 25 | // return {} 26 | // } 27 | // ), 28 | // unfollow: provide( 29 | // ClockCaps.unfollow, 30 | // async ({ capability, invocation }) => { 31 | // const clock = capability.with 32 | // const target = capability.nb.with ?? capability.with 33 | // const emitter = capability.nb.iss ?? invocation.issuer.did() 34 | // // @ts-expect-error 35 | // await Clock.unfollow(clockNamespace, clock, target, emitter) 36 | // return {} 37 | // } 38 | // ), 39 | // following: provide( 40 | // ClockCaps.following, 41 | // async ({ capability }) => { 42 | // // @ts-expect-error 43 | // const followings = await Clock.following(clockNamespace, capability.with) 44 | // return [...followings.entries()].map(([k, v]) => [k, [...v.values()]]) 45 | // } 46 | // ), 47 | advance: provide( 48 | ClockCaps.advance, 49 | async ({ capability, invocation }) => { 50 | const event = /** @type {import('@web3-storage/pail/clock/api').EventLink} */ (capability.nb.event) 51 | const blocks = filterEventBlocks(event, [...invocation.export()]) 52 | const resource = parse(capability.with).did() 53 | const head = await Clock.advance(clockNamespace, resource, invocation.issuer.did(), event, blocks) 54 | return { ok: { head } } 55 | } 56 | ), 57 | head: provide( 58 | ClockCaps.head, 59 | async ({ capability }) => { 60 | const resource = parse(capability.with).did() 61 | const head = await Clock.head(clockNamespace, resource) 62 | return { ok: { head } } 63 | } 64 | ) 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * @param {import('@web3-storage/pail/clock/api').EventLink} event 71 | * @param {import('@ucanto/interface').Block[]} blocks 72 | */ 73 | function filterEventBlocks (event, blocks) { 74 | /** @type {import('@ucanto/interface').Block>[]} */ 75 | const filteredBlocks = [] 76 | const cids = [event] 77 | while (true) { 78 | const cid = cids.shift() 79 | if (!cid) break 80 | const block = blocks.find(b => b.cid.equals(cid)) 81 | if (!block) continue 82 | try { 83 | /** @type {import('@web3-storage/pail/clock/api').EventView} */ 84 | const value = dagCBOR.decode(block.bytes) 85 | if (!Array.isArray(value.parents)) { 86 | throw new Error(`invalid merkle clock event: ${cid}`) 87 | } 88 | cids.push(...value.parents) 89 | } catch {} 90 | filteredBlocks.push(block) 91 | } 92 | return filteredBlocks 93 | } 94 | -------------------------------------------------------------------------------- /src/worker/types.d.ts: -------------------------------------------------------------------------------- 1 | import { DurableObjectNamespace } from '@cloudflare/workers-types' 2 | 3 | export interface Environment { 4 | DEBUG?: string 5 | /** Base64 encoded Ed25519 private key. */ 6 | PRIVATE_KEY: string 7 | /** Optional DID of the service, if different from the did:key. e.g. did:web:... */ 8 | DID?: string 9 | GATEWAY_URL?: string 10 | BLOCK_CACHE_SIZE?: string 11 | CLOCK: DurableObjectNamespace 12 | } 13 | 14 | export interface Context { 15 | waitUntil (promise: Promise): void 16 | } 17 | 18 | export interface Handler { 19 | (request: Request, env: E, ctx: C): Promise 20 | } 21 | 22 | /** 23 | * Middleware is a function that returns a handler with a possibly extended 24 | * context object. The first generic type is the "extended context". i.e. what 25 | * the context looks like after the middleware is run. The second generic type 26 | * is the "base context", or in other words the context _required_ by the 27 | * middleware for it to run. The third type is the environment, which should 28 | * not be modified. 29 | */ 30 | export interface Middleware { 31 | (h: Handler): Handler 32 | } 33 | -------------------------------------------------------------------------------- /test/helpers/durable-objects.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('@cloudflare/workers-types').DurableObjectState} DurableObjectState 3 | * @typedef {import('@cloudflare/workers-types').DurableObjectId} DurableObjectId 4 | * @typedef {import('@cloudflare/workers-types').DurableObjectStorage} DurableObjectStorage 5 | * @typedef {import('@cloudflare/workers-types').DurableObjectTransaction} DurableObjectTransaction 6 | * @typedef {import('@cloudflare/workers-types').DurableObjectNamespace} DurableObjectNamespace 7 | * @typedef {import('@cloudflare/workers-types').DurableObjectStub} DurableObjectStub 8 | */ 9 | 10 | /** @implements {DurableObjectId} */ 11 | export class MockId { 12 | #id 13 | #name 14 | 15 | /** 16 | * @param {string} id 17 | * @param {string} [name] 18 | */ 19 | constructor (id, name) { 20 | this.#id = id 21 | this.#name = name 22 | } 23 | 24 | toString () { 25 | return this.#id 26 | } 27 | 28 | /** @param {DurableObjectId} other */ 29 | equals (other) { 30 | return String(other) === this.#id 31 | } 32 | 33 | get name () { 34 | return this.#name 35 | } 36 | } 37 | 38 | class MockBasicStorage { 39 | /** @param {Map} data */ 40 | constructor (data = new Map()) { 41 | this.data = data 42 | /** @type {number|null} */ 43 | this.alarm = null 44 | } 45 | 46 | /** 47 | * @param {string|string[]} key 48 | * @param {import('@cloudflare/workers-types').DurableObjectGetOptions} [options] 49 | */ 50 | async get (key, options) { 51 | return Array.isArray(key) 52 | ? new Map(key.map(k => [k, this.data.get(k)])) 53 | : this.data.get(key) 54 | } 55 | 56 | /** 57 | * @template T 58 | * @param {import('@cloudflare/workers-types').DurableObjectGetOptions} [options] 59 | * @returns {Promise>} 60 | */ 61 | async list (options) { 62 | return this.data 63 | } 64 | 65 | /** 66 | * @template T 67 | * @param {string|Record} key 68 | * @param {T|import('@cloudflare/workers-types').DurableObjectPutOptions} [value] 69 | * @param {import('@cloudflare/workers-types').DurableObjectPutOptions} [options] 70 | */ 71 | async put (key, value, options) { 72 | if (typeof key === 'string') { 73 | this.data.set(key, value) 74 | return 75 | } 76 | Object.entries(key).forEach(([k, v]) => this.data.set(k, v)) 77 | } 78 | 79 | /** 80 | * @param {any} key 81 | * @param {import('@cloudflare/workers-types').DurableObjectPutOptions} [options] 82 | */ 83 | async delete (key, options) { 84 | return Array.isArray(key) 85 | ? key.reduce((n, k) => n + Number(this.data.delete(k)), 0) 86 | : this.data.delete(key) 87 | } 88 | 89 | /** 90 | * @param {import('@cloudflare/workers-types').DurableObjectPutOptions} [options] 91 | */ 92 | async deleteAll (options) { 93 | this.data.clear() 94 | } 95 | 96 | /** 97 | * @param {import('@cloudflare/workers-types').DurableObjectGetAlarmOptions} [options] 98 | */ 99 | async getAlarm (options) { 100 | return this.alarm 101 | } 102 | 103 | /** 104 | * @param {number | Date} scheduledTime 105 | * @param {import('@cloudflare/workers-types').DurableObjectSetAlarmOptions} [options] 106 | */ 107 | async setAlarm (scheduledTime, options) { 108 | this.alarm = typeof scheduledTime === 'number' ? scheduledTime : scheduledTime.getTime() 109 | } 110 | 111 | /** 112 | * @param {import('@cloudflare/workers-types').DurableObjectSetAlarmOptions} [options] 113 | */ 114 | async deleteAlarm (options) { 115 | this.alarm = null 116 | } 117 | } 118 | 119 | /** @implements {DurableObjectStorage} */ 120 | export class MockStorage extends MockBasicStorage { 121 | #inTxn = false 122 | 123 | /** 124 | * @template T 125 | * @param {(txn: DurableObjectTransaction) => Promise} closure 126 | */ 127 | async transaction (closure) { 128 | if (this.#inTxn) throw new Error('another transaction in progress') 129 | this.#inTxn = true 130 | const txn = new MockTransaction(new Map(...this.data)) 131 | const ret = await closure(txn) 132 | this.data = txn.data 133 | this.#inTxn = false 134 | return ret 135 | } 136 | 137 | async sync () {} 138 | } 139 | 140 | /** @implements {DurableObjectTransaction} */ 141 | export class MockTransaction extends MockBasicStorage { 142 | /** @type {Map} */ 143 | #prev 144 | 145 | /** @param {Map} data */ 146 | constructor (data) { 147 | super(new Map(...data)) 148 | this.#prev = data 149 | } 150 | 151 | rollback () { 152 | this.data = this.#prev 153 | // TODO: other functions should throw after rollback 154 | } 155 | } 156 | 157 | /** @implements {DurableObjectState} */ 158 | export class MockState { 159 | #id 160 | #storage 161 | 162 | /** 163 | * @param {DurableObjectId} id 164 | * @param {DurableObjectStorage} storage 165 | */ 166 | constructor (id, storage) { 167 | this.#id = id 168 | this.#storage = storage 169 | } 170 | 171 | get id () { 172 | return this.#id 173 | } 174 | 175 | get storage () { 176 | return this.#storage 177 | } 178 | 179 | waitUntil () {} 180 | 181 | /** 182 | * @template T 183 | * @param {() => Promise} callback 184 | * @returns {Promise} 185 | */ 186 | async blockConcurrencyWhile (callback) { 187 | return await callback() 188 | } 189 | } 190 | 191 | /** @implements {DurableObjectNamespace} */ 192 | export class MockNamespace { 193 | /** @type {Map} */ 194 | #objects = new Map() 195 | 196 | /** @param {import('@cloudflare/workers-types').DurableObjectNamespaceNewUniqueIdOptions} options */ 197 | newUniqueId (options) { 198 | return new MockId(`mock[${Math.random()}]`) 199 | } 200 | 201 | /** @param {string} name */ 202 | idFromName (name) { 203 | return new MockId(`mock[${name}]`, name) 204 | } 205 | 206 | /** @param {string} id */ 207 | idFromString (id) { 208 | return new MockId(`mock[${id}]`) 209 | } 210 | 211 | /** 212 | * @param {DurableObjectId} id 213 | * @param {import('@cloudflare/workers-types').DurableObjectNamespaceGetDurableObjectOptions} [options] 214 | */ 215 | get (id, options) { 216 | const obj = this.#objects.get(id.toString()) 217 | if (!obj) throw new Error('missing durable object') 218 | return new MockStub(id, obj, { name: id.name }) 219 | } 220 | 221 | /** 222 | * @param {DurableObjectId} id 223 | * @param {import('@cloudflare/workers-types').DurableObject} obj 224 | */ 225 | set (id, obj) { 226 | this.#objects.set(id.toString(), obj) 227 | } 228 | 229 | jurisdiction () { 230 | return this 231 | } 232 | } 233 | 234 | /** @implements {DurableObjectStub} */ 235 | class MockStub { 236 | /** @type {import('@cloudflare/workers-types').DurableObject} */ 237 | #obj 238 | 239 | /** 240 | * @param {DurableObjectId} id 241 | * @param {import('@cloudflare/workers-types').DurableObject} obj 242 | * @param {{ name?: string }} [options] 243 | */ 244 | constructor (id, obj, options) { 245 | this.id = id 246 | this.name = options?.name 247 | this.#obj = obj 248 | } 249 | 250 | /** 251 | * @param {import('@cloudflare/workers-types').RequestInfo} input 252 | * @param {import('@cloudflare/workers-types').RequestInit} [init] 253 | */ 254 | async fetch (input, init) { 255 | // @ts-expect-error 256 | return this.#obj.fetch(new Request(input, init)) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /test/helpers/ucanto.js: -------------------------------------------------------------------------------- 1 | import { connect } from '@ucanto/client' 2 | import * as CAR from '@ucanto/transport/car' 3 | import * as HTTP from '@ucanto/transport/http' 4 | 5 | /** 6 | * Create a ucanto connection to a miniflare worker. 7 | * @template {Record} T 8 | * @param {import('miniflare').Miniflare} miniflare 9 | * @param {import('@ucanto/interface').Principal} servicePrincipal 10 | * @returns {import('@ucanto/interface').ConnectionView} 11 | */ 12 | export function miniflareConnection (miniflare, servicePrincipal) { 13 | return connect({ 14 | id: servicePrincipal, 15 | codec: CAR.outbound, 16 | channel: HTTP.open({ 17 | url: new URL('http://localhost:8787'), 18 | method: 'POST', 19 | fetch: miniflare.dispatchFetch.bind(miniflare) 20 | }) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /test/worker/durable-clock.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha' 2 | import assert from 'assert' 3 | import { Signer } from '@ucanto/principal/ed25519' 4 | import { DurableClock, follow, following, unfollow } from '../../src/worker/durable-clock.js' 5 | import { MockState, MockStorage, MockNamespace } from '../helpers/durable-objects.js' 6 | 7 | describe('DurableClock', () => { 8 | it('follows', async () => { 9 | const sundial = await Signer.generate() 10 | const alice = await Signer.generate() 11 | 12 | const namespace = new MockNamespace() 13 | const id = namespace.idFromName(sundial.did()) 14 | const storage = new MockStorage() 15 | const state = new MockState(id, storage) 16 | const obj = new DurableClock(state, { DEBUG: 'true', PRIVATE_KEY: 'secret', CLOCK: namespace }) 17 | namespace.set(id, obj) 18 | 19 | await follow(namespace, sundial.did(), sundial.did(), alice.did()) 20 | 21 | const followings = await following(namespace, sundial.did()) 22 | assert.equal(followings.size, 1) 23 | const emitters = followings.get(sundial.did()) 24 | assert(emitters) 25 | assert.equal(emitters.size, 1) 26 | assert(emitters.has(alice.did())) 27 | }) 28 | 29 | it('follows a different clock', async () => { 30 | const sundial = await Signer.generate() 31 | const hourglass = await Signer.generate() 32 | const alice = await Signer.generate() 33 | 34 | const namespace = new MockNamespace() 35 | const clockID = namespace.idFromName(sundial.did()) 36 | const clockStorage = new MockStorage() 37 | const clockState = new MockState(clockID, clockStorage) 38 | const clockObj = new DurableClock(clockState, { DEBUG: 'true', PRIVATE_KEY: 'secret', CLOCK: namespace }) 39 | namespace.set(clockID, clockObj) 40 | 41 | // need a target DO for subscribing 42 | const targetID = namespace.idFromName(hourglass.did()) 43 | const targetStorage = new MockStorage() 44 | const targetState = new MockState(targetID, targetStorage) 45 | const targetObj = new DurableClock(targetState, { DEBUG: 'true', PRIVATE_KEY: 'secret', CLOCK: namespace }) 46 | namespace.set(targetID, targetObj) 47 | 48 | await follow(namespace, sundial.did(), hourglass.did(), alice.did()) 49 | 50 | const followings = await following(namespace, sundial.did()) 51 | assert.equal(followings.size, 1) 52 | const emitters = followings.get(hourglass.did()) 53 | assert(emitters) 54 | assert.equal(emitters.size, 1) 55 | assert(emitters.has(alice.did())) 56 | }) 57 | 58 | it('unfollows', async () => { 59 | const sundial = await Signer.generate() 60 | const alice = await Signer.generate() 61 | 62 | const namespace = new MockNamespace() 63 | const id = namespace.idFromName(sundial.did()) 64 | const storage = new MockStorage() 65 | const state = new MockState(id, storage) 66 | const obj = new DurableClock(state, { DEBUG: 'true', PRIVATE_KEY: 'secret', CLOCK: namespace }) 67 | namespace.set(id, obj) 68 | 69 | await follow(namespace, sundial.did(), sundial.did(), alice.did()) 70 | 71 | let followings = await following(namespace, sundial.did()) 72 | assert.equal(followings.size, 1) 73 | let emitters = followings.get(sundial.did()) 74 | assert(emitters) 75 | assert.equal(emitters.size, 1) 76 | assert(emitters.has(alice.did())) 77 | 78 | await unfollow(namespace, sundial.did(), sundial.did(), alice.did()) 79 | 80 | followings = await following(namespace, sundial.did()) 81 | assert.equal(followings.size, 0) 82 | emitters = followings.get(sundial.did()) 83 | assert(!emitters) 84 | }) 85 | 86 | it('unfollows a different clock', async () => { 87 | const sundial = await Signer.generate() 88 | const hourglass = await Signer.generate() 89 | const alice = await Signer.generate() 90 | 91 | const namespace = new MockNamespace() 92 | const clockID = namespace.idFromName(sundial.did()) 93 | const clockStorage = new MockStorage() 94 | const clockState = new MockState(clockID, clockStorage) 95 | const clockObj = new DurableClock(clockState, { DEBUG: 'true', PRIVATE_KEY: 'secret', CLOCK: namespace }) 96 | namespace.set(clockID, clockObj) 97 | 98 | // need a target DO for subscribing 99 | const targetID = namespace.idFromName(hourglass.did()) 100 | const targetStorage = new MockStorage() 101 | const targetState = new MockState(targetID, targetStorage) 102 | const targetObj = new DurableClock(targetState, { DEBUG: 'true', PRIVATE_KEY: 'secret', CLOCK: namespace }) 103 | namespace.set(targetID, targetObj) 104 | 105 | await follow(namespace, sundial.did(), hourglass.did(), alice.did()) 106 | 107 | let followings = await following(namespace, sundial.did()) 108 | assert.equal(followings.size, 1) 109 | let emitters = followings.get(hourglass.did()) 110 | assert(emitters) 111 | assert.equal(emitters.size, 1) 112 | assert(emitters.has(alice.did())) 113 | 114 | await unfollow(namespace, sundial.did(), hourglass.did(), alice.did()) 115 | 116 | followings = await following(namespace, sundial.did()) 117 | assert.equal(followings.size, 0) 118 | emitters = followings.get(sundial.did()) 119 | assert(!emitters) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/worker/service.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach } from 'mocha' 2 | import assert from 'assert' 3 | import { Miniflare } from 'miniflare' 4 | import { Signer } from '@ucanto/principal/ed25519' 5 | import { ShardBlock } from '@web3-storage/pail/shard' 6 | import { EventBlock } from '@web3-storage/pail/clock' 7 | import { parse } from 'multiformats/link' 8 | import * as ClockCaps from '../../src/capabilities.js' 9 | import { miniflareConnection } from '../helpers/ucanto.js' 10 | 11 | describe('UCAN service', () => { 12 | /** @type {Signer.EdSigner} */ 13 | let svc 14 | /** @type {Miniflare} */ 15 | let mf 16 | /** @type {import('@ucanto/interface').ConnectionView} */ 17 | let conn 18 | 19 | beforeEach(async () => { 20 | svc = await Signer.generate() 21 | 22 | mf = new Miniflare({ 23 | scriptPath: 'dist/worker.mjs', 24 | wranglerConfigPath: true, 25 | wranglerConfigEnv: 'test', 26 | modules: true, 27 | bindings: { PRIVATE_KEY: Signer.format(svc) } 28 | }) 29 | 30 | conn = miniflareConnection(mf, svc) 31 | }) 32 | 33 | it('advances', async () => { 34 | const sundial = await Signer.generate() 35 | const alice = await Signer.generate() 36 | 37 | const proofs = [ 38 | // delegate alice ability to advance the clock 39 | await ClockCaps.advance.delegate({ 40 | issuer: sundial, 41 | audience: alice, 42 | with: sundial.did() 43 | }), 44 | // delegate alice ability to ask the clock it's head 45 | await ClockCaps.head.delegate({ 46 | issuer: sundial, 47 | audience: alice, 48 | with: sundial.did() 49 | }) 50 | ] 51 | 52 | const shard = await ShardBlock.create() 53 | 54 | /** @type {import('@web3-storage/pail/crdt/api').Operation} */ 55 | const data = { type: 'put', root: shard.cid, key: 'guardian', value: parse('bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy') } 56 | const event = await EventBlock.create(data) 57 | 58 | const inv = ClockCaps.advance.invoke({ 59 | issuer: alice, 60 | audience: svc, 61 | with: sundial.did(), 62 | proofs, 63 | nb: { event: event.cid } 64 | }) 65 | 66 | // attach event block so the service does not call out to the network 67 | inv.attach(event) 68 | 69 | const res0 = await inv.execute(conn) 70 | 71 | assert(!res0.out.error) 72 | 73 | const res1 = await ClockCaps.head 74 | .invoke({ 75 | issuer: alice, 76 | audience: svc, 77 | with: sundial.did(), 78 | proofs 79 | }) 80 | .execute(conn) 81 | 82 | assert(!res1.out.error) 83 | assert(Array.isArray(res1.out.ok.head)) 84 | assert.equal(res1.out.ok.head.length, 1) 85 | assert.equal(res1.out.ok.head[0].toString(), event.cid.toString()) 86 | }) 87 | 88 | // it('follows', async () => { 89 | // const sundial = await Signer.generate() 90 | // const alice = await Signer.generate() 91 | 92 | // const proofs = [ 93 | // // delegate alice ability to add follows to the clock 94 | // await ClockCaps.follow.delegate({ 95 | // issuer: sundial, 96 | // audience: alice, 97 | // with: sundial.did() 98 | // }), 99 | // // delegate alice ability to ask the clock who it is following 100 | // await ClockCaps.following.delegate({ 101 | // issuer: sundial, 102 | // audience: alice, 103 | // with: sundial.did() 104 | // }) 105 | // ] 106 | 107 | // const res0 = await ClockCaps.follow 108 | // .invoke({ 109 | // issuer: alice, 110 | // audience: svc, 111 | // with: sundial.did(), 112 | // proofs, 113 | // nb: {} 114 | // }) 115 | // .execute(conn) 116 | 117 | // assert(!res0.error) 118 | 119 | // const res1 = await ClockCaps.following 120 | // .invoke({ 121 | // issuer: alice, 122 | // audience: svc, 123 | // with: sundial.did(), 124 | // proofs, 125 | // nb: {} 126 | // }) 127 | // .execute(conn) 128 | 129 | // assert(!res1.error) 130 | // assert(Array.isArray(res1)) 131 | // assert.equal(res1.length, 1) 132 | // assert.equal(res1[0][0], sundial.did()) 133 | // assert(Array.isArray(res1[0][1])) 134 | // assert.equal(res1[0][1][0], alice.did()) 135 | // }) 136 | 137 | // it('follows a different clock', async () => { 138 | // const sundial = await Signer.generate() 139 | // const hourglass = await Signer.generate() 140 | // const alice = await Signer.generate() 141 | 142 | // const proofs = [ 143 | // // delegate alice ability to add follows to sundial 144 | // await ClockCaps.follow.delegate({ 145 | // issuer: sundial, 146 | // audience: alice, 147 | // with: sundial.did() 148 | // }), 149 | // // delegate alice ability to ask sundial who it is following 150 | // await ClockCaps.following.delegate({ 151 | // issuer: sundial, 152 | // audience: alice, 153 | // with: sundial.did() 154 | // }) 155 | // ] 156 | 157 | // const res0 = await ClockCaps.follow 158 | // .invoke({ 159 | // issuer: alice, 160 | // audience: svc, 161 | // with: sundial.did(), 162 | // proofs, 163 | // nb: { 164 | // with: hourglass.did() 165 | // } 166 | // }) 167 | // .execute(conn) 168 | 169 | // assert(!res0.error) 170 | 171 | // const res1 = await ClockCaps.following 172 | // .invoke({ 173 | // issuer: alice, 174 | // audience: svc, 175 | // with: sundial.did(), 176 | // proofs, 177 | // nb: {} 178 | // }) 179 | // .execute(conn) 180 | 181 | // assert(!res1.error) 182 | // assert(Array.isArray(res1)) 183 | // assert.equal(res1.length, 1) 184 | // assert.equal(res1[0][0], hourglass.did()) 185 | // assert(Array.isArray(res1[0][1])) 186 | // assert.equal(res1[0][1][0], alice.did()) 187 | // }) 188 | 189 | // it('follows delegated clock and issuer', async () => { 190 | // const sundial = await Signer.generate() 191 | // const hourglass = await Signer.generate() 192 | // const alice = await Signer.generate() 193 | // const bob = await Signer.generate() 194 | 195 | // const proofs = [ 196 | // // delegate alice ability to add follows to sundial for the hourglass and bob 197 | // await ClockCaps.follow.delegate({ 198 | // issuer: sundial, 199 | // audience: alice, 200 | // with: sundial.did(), 201 | // nb: { 202 | // iss: bob.did(), 203 | // with: hourglass.did() 204 | // } 205 | // }), 206 | // // delegate alice ability to ask sundial who it is following 207 | // await ClockCaps.following.delegate({ 208 | // issuer: sundial, 209 | // audience: alice, 210 | // with: sundial.did() 211 | // }) 212 | // ] 213 | 214 | // // alice should not be able to add alice (implicit) 215 | // const res0 = await ClockCaps.follow 216 | // .invoke({ 217 | // issuer: alice, 218 | // audience: svc, 219 | // with: sundial.did(), 220 | // proofs, 221 | // nb: { 222 | // with: hourglass.did() 223 | // } 224 | // }) 225 | // .execute(conn) 226 | 227 | // assert(res0.error) 228 | // assert.equal(res0.name, 'Unauthorized') 229 | // assert.ok(res0.message.includes('missing nb.iss on claimed capability')) 230 | 231 | // // alice should not be able to add follow for alice (explicit) 232 | // const res1 = await ClockCaps.follow 233 | // .invoke({ 234 | // issuer: alice, 235 | // audience: svc, 236 | // with: sundial.did(), 237 | // proofs, 238 | // nb: { 239 | // iss: alice.did(), 240 | // with: hourglass.did() 241 | // } 242 | // }) 243 | // .execute(conn) 244 | 245 | // assert(res1.error) 246 | // assert.equal(res1.name, 'Unauthorized') 247 | // assert.ok(res1.message.includes('mismatched nb.iss')) 248 | 249 | // // alice should not be able to add follow for sundial (implicit) 250 | // const res2 = await ClockCaps.follow 251 | // .invoke({ 252 | // issuer: alice, 253 | // audience: svc, 254 | // with: sundial.did(), 255 | // proofs, 256 | // nb: { 257 | // iss: alice.did() 258 | // } 259 | // }) 260 | // .execute(conn) 261 | 262 | // assert(res2.error) 263 | // assert.equal(res2.name, 'Unauthorized') 264 | // assert.ok(res2.message.includes('missing nb.with on claimed capability')) 265 | 266 | // // alice should not be able to add follow for sundial (explicit) 267 | // const res3 = await ClockCaps.follow 268 | // .invoke({ 269 | // issuer: alice, 270 | // audience: svc, 271 | // with: sundial.did(), 272 | // proofs, 273 | // nb: { 274 | // iss: alice.did(), 275 | // with: sundial.did() 276 | // } 277 | // }) 278 | // .execute(conn) 279 | 280 | // assert(res3.error) 281 | // assert.equal(res3.name, 'Unauthorized') 282 | // assert.ok(res3.message.includes('mismatched nb.with')) 283 | 284 | // // alice should be able to add follow for hourglass and bob (explicit) 285 | // const res4 = await ClockCaps.follow 286 | // .invoke({ 287 | // issuer: alice, 288 | // audience: svc, 289 | // with: sundial.did(), 290 | // proofs, 291 | // nb: { 292 | // iss: bob.did(), 293 | // with: hourglass.did() 294 | // } 295 | // }) 296 | // .execute(conn) 297 | 298 | // assert(!res4.error) 299 | 300 | // const res5 = await ClockCaps.following 301 | // .invoke({ 302 | // issuer: alice, 303 | // audience: svc, 304 | // with: sundial.did(), 305 | // proofs, 306 | // nb: {} 307 | // }) 308 | // .execute(conn) 309 | 310 | // assert(!res5.error) 311 | // assert(Array.isArray(res5)) 312 | // assert.equal(res5.length, 1) 313 | // assert.equal(res5[0][0], hourglass.did()) 314 | // assert(Array.isArray(res5[0][1])) 315 | // assert.equal(res5[0][1][0], bob.did()) 316 | // }) 317 | 318 | // it('unfollows', async () => { 319 | // const sundial = await Signer.generate() 320 | // const alice = await Signer.generate() 321 | 322 | // const proofs = [ 323 | // // delegate alice ability to add follows to sundial 324 | // await ClockCaps.follow.delegate({ 325 | // issuer: sundial, 326 | // audience: alice, 327 | // with: sundial.did() 328 | // }), 329 | // // delegate alice ability to remove follows from sundial 330 | // await ClockCaps.unfollow.delegate({ 331 | // issuer: sundial, 332 | // audience: alice, 333 | // with: sundial.did() 334 | // }), 335 | // // delegate alice ability to ask sundial who it is following 336 | // await ClockCaps.following.delegate({ 337 | // issuer: sundial, 338 | // audience: alice, 339 | // with: sundial.did() 340 | // }) 341 | // ] 342 | 343 | // const res0 = await ClockCaps.following 344 | // .invoke({ 345 | // issuer: alice, 346 | // audience: svc, 347 | // with: sundial.did(), 348 | // proofs, 349 | // nb: {} 350 | // }) 351 | // .execute(conn) 352 | 353 | // assert(!res0.error) 354 | // assert(Array.isArray(res0)) 355 | // assert.equal(res0.length, 0) 356 | 357 | // const res1 = await ClockCaps.follow 358 | // .invoke({ 359 | // issuer: alice, 360 | // audience: svc, 361 | // with: sundial.did(), 362 | // proofs, 363 | // nb: {} 364 | // }) 365 | // .execute(conn) 366 | 367 | // assert(!res1.error) 368 | 369 | // const res2 = await ClockCaps.following 370 | // .invoke({ 371 | // issuer: alice, 372 | // audience: svc, 373 | // with: sundial.did(), 374 | // proofs, 375 | // nb: {} 376 | // }) 377 | // .execute(conn) 378 | 379 | // assert(!res2.error) 380 | // assert(Array.isArray(res2)) 381 | // assert.equal(res2.length, 1) 382 | // assert.equal(res2[0][0], sundial.did()) 383 | // assert(Array.isArray(res2[0][1])) 384 | // assert.equal(res2[0][1][0], alice.did()) 385 | 386 | // const res3 = await ClockCaps.unfollow 387 | // .invoke({ 388 | // issuer: alice, 389 | // audience: svc, 390 | // with: sundial.did(), 391 | // proofs, 392 | // nb: {} 393 | // }) 394 | // .execute(conn) 395 | 396 | // assert(!res3.error) 397 | 398 | // const res4 = await ClockCaps.following 399 | // .invoke({ 400 | // issuer: alice, 401 | // audience: svc, 402 | // with: sundial.did(), 403 | // proofs, 404 | // nb: {} 405 | // }) 406 | // .execute(conn) 407 | 408 | // assert(!res4.error) 409 | // assert(Array.isArray(res4)) 410 | // assert.equal(res4.length, 0) 411 | // }) 412 | }) 413 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | "incremental": true, /* Enable incremental compilation */ 7 | "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./dist", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["ES2022", "WebWorker", "Webworker.Iterable"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "ES2020", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./dist/", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 75 | 76 | /* Type Checking */ 77 | "strict": true, /* Enable all strict type-checking options. */ 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "include": [ 102 | "src" 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "w3clock" 2 | main = "dist/worker.mjs" 3 | compatibility_date = "2023-02-28" 4 | 5 | [build] 6 | command = "npm run build:worker:debug" 7 | 8 | [durable_objects] 9 | bindings = [ 10 | { name = "CLOCK", class_name = "DurableClock0" } 11 | ] 12 | 13 | [[migrations]] 14 | tag = "v1" 15 | new_classes = ["DurableClock0"] 16 | 17 | # Production! 18 | [env.production] 19 | workers_dev = false 20 | account_id = "fffa4b4363a7e5250af8357087263b3a" 21 | 22 | [env.production.build] 23 | command = "npm run build" 24 | 25 | [env.production.vars] 26 | DID = "did:web:clock.web3.storage" 27 | 28 | [env.production.durable_objects] 29 | bindings = [ 30 | { name = "CLOCK", class_name = "DurableClock0" } 31 | ] 32 | 33 | # Staging! 34 | [env.staging] 35 | workers_dev = false 36 | account_id = "fffa4b4363a7e5250af8357087263b3a" 37 | 38 | [env.staging.build] 39 | command = "npm run build" 40 | 41 | [env.staging.vars] 42 | DID = "did:web:clock-staging.web3.storage" 43 | 44 | [env.staging.durable_objects] 45 | bindings = [ 46 | { name = "CLOCK", class_name = "DurableClock0" } 47 | ] 48 | 49 | # Test! 50 | [env.test] 51 | workers_dev = true 52 | 53 | [env.test.vars] 54 | DEBUG = "true" 55 | 56 | # Developers Developers Developers! 57 | 58 | [env.alanshaw] 59 | workers_dev = true 60 | account_id = "4fe12d085474d33bdcfd8e9bed4d8f95" 61 | 62 | [env.alanshaw.vars] 63 | DEBUG = "true" 64 | --------------------------------------------------------------------------------