├── .envrc ├── .eslintrc.cjs ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── changelog.yml │ ├── docs.yaml │ └── main.yaml ├── .gitignore ├── .mocharc.cjs ├── .npmignore ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix ├── package-lock.json ├── package.json ├── scripts ├── build-minified.js └── gen-version.js ├── shell.nix ├── src ├── appInfo.ts ├── apps │ └── index.ts ├── capabilities.ts ├── common │ ├── arrbufs.node.test.ts │ ├── arrbufs.ts │ ├── base64.node.test.ts │ ├── base64.ts │ ├── blob.ts │ ├── browser.ts │ ├── cid.node.test.ts │ ├── cid.ts │ ├── event-emitter.ts │ ├── fission.ts │ ├── hex.node.test.ts │ ├── hex.ts │ ├── identifiers.ts │ ├── index.ts │ ├── root-key.ts │ ├── semver.ts │ ├── type-check.node.test.ts │ ├── type-checks.ts │ ├── types.ts │ ├── util.node.test.ts │ ├── util.ts │ └── version.ts ├── components.ts ├── components │ ├── auth │ │ ├── channel.ts │ │ ├── implementation.ts │ │ └── implementation │ │ │ ├── README.md │ │ │ ├── base.ts │ │ │ ├── fission-base-production.ts │ │ │ ├── fission-base-staging.ts │ │ │ ├── fission-base.ts │ │ │ ├── fission-wnfs-production.ts │ │ │ ├── fission-wnfs-staging.ts │ │ │ ├── fission-wnfs.ts │ │ │ ├── fission │ │ │ ├── blocklist.ts │ │ │ ├── channel.ts │ │ │ ├── index.node.test.ts │ │ │ └── index.ts │ │ │ └── wnfs.ts │ ├── capabilities │ │ ├── implementation.ts │ │ └── implementation │ │ │ ├── fission-lobby-production.ts │ │ │ ├── fission-lobby-staging.ts │ │ │ └── fission-lobby.ts │ ├── crypto │ │ ├── implementation.ts │ │ └── implementation │ │ │ └── browser.ts │ ├── depot │ │ ├── implementation.ts │ │ └── implementation │ │ │ ├── fission-ipfs-production.ts │ │ │ ├── fission-ipfs-staging.ts │ │ │ ├── ipfs-default-pkg.ts │ │ │ ├── ipfs.ts │ │ │ └── ipfs │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── node.ts │ │ │ ├── node │ │ │ └── repo.ts │ │ │ └── package.ts │ ├── manners │ │ ├── implementation.ts │ │ └── implementation │ │ │ └── base.ts │ ├── reference │ │ ├── dns-over-https.ts │ │ ├── implementation.ts │ │ └── implementation │ │ │ ├── base.ts │ │ │ ├── fission-base.ts │ │ │ ├── fission-production.ts │ │ │ ├── fission-staging.ts │ │ │ └── fission │ │ │ ├── data-root.ts │ │ │ └── did.ts │ └── storage │ │ ├── implementation.ts │ │ └── implementation │ │ ├── browser.ts │ │ ├── keys │ │ └── default.ts │ │ └── memory.ts ├── configuration.ts ├── dag │ ├── codecs.ts │ └── index.ts ├── did │ ├── index.ts │ ├── local.ts │ ├── transformers.ts │ └── util.ts ├── events.ts ├── extension │ └── index.ts ├── filesystem.ts ├── fs │ ├── README.md │ ├── bare │ │ ├── file.ts │ │ └── tree.ts │ ├── base │ │ ├── file.ts │ │ └── tree.ts │ ├── data.ts │ ├── errors.ts │ ├── filesystem.ts │ ├── index.ts │ ├── link.ts │ ├── metadata.ts │ ├── protocol │ │ ├── basic.ts │ │ ├── index.ts │ │ ├── private │ │ │ ├── index.ts │ │ │ ├── mmpt.node.test.ts │ │ │ ├── mmpt.ts │ │ │ ├── namefilter.test.ts │ │ │ ├── namefilter.ts │ │ │ ├── types.ts │ │ │ └── types │ │ │ │ └── check.ts │ │ ├── public │ │ │ ├── index.ts │ │ │ ├── skeleton.ts │ │ │ └── types.ts │ │ └── shared │ │ │ ├── entry-index.ts │ │ │ └── key.ts │ ├── root │ │ └── tree.ts │ ├── share.ts │ ├── types.ts │ ├── types │ │ ├── check.ts │ │ └── params.ts │ ├── v1 │ │ ├── PrivateFile.ts │ │ ├── PrivateHistory.ts │ │ ├── PrivateTree.ts │ │ ├── PublicFile.ts │ │ ├── PublicHistory.ts │ │ └── PublicTree.ts │ ├── v3 │ │ ├── DepotBlockStore.ts │ │ └── PublicRootWasm.ts │ └── versions.ts ├── index.ts ├── linking │ ├── common.ts │ ├── consumer.test.ts │ ├── consumer.ts │ ├── index.ts │ ├── producer.test.ts │ └── producer.ts ├── path │ ├── index.node.test.ts │ └── index.ts ├── permissions.ts ├── repositories │ ├── README.md │ ├── cid-log.node.test.ts │ ├── cid-log.ts │ └── ucans.ts ├── repository.ts ├── session.ts └── ucan │ ├── index.ts │ ├── token.ts │ └── types.ts ├── tests ├── auth │ └── linking.node.test.ts ├── did │ ├── ed25519.node.test.ts │ └── pubkeyToDid.node.test.ts ├── encoding.node.test.ts ├── fixtures │ ├── odd-integration-test-v1-0-0.car │ └── odd-integration-test-v2-0-0.car ├── fs │ ├── api.private.node.test.ts │ ├── api.public.node.test.ts │ ├── concurrency.node.test.ts │ ├── data.node.test.ts │ ├── exchange.node.test.ts │ ├── integration.node.test.ts │ ├── share.node.test.ts │ ├── tree.node.test.ts │ ├── versioning.node.test.ts │ └── wasm │ │ └── public.test.ts ├── helpers │ ├── components.ts │ ├── fileContent.ts │ ├── filesystem.ts │ ├── loadCAR.ts │ ├── localforage │ │ └── in-memory-storage.ts │ └── paths.ts ├── index.node.test.ts ├── mocha-hook.ts └── ucan │ └── ucan.node.test.ts ├── tsconfig.eslint.json ├── tsconfig.json └── typedoc.json /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: `./tsconfig.eslint.json`, 6 | extraFileExtensions: "cjs", 7 | }, 8 | plugins: [ 9 | "@typescript-eslint", 10 | ], 11 | extends: [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | ], 16 | rules: { 17 | "@typescript-eslint/member-delimiter-style": ["error", { 18 | "multiline": { 19 | "delimiter": "none", 20 | "requireLast": false 21 | }, 22 | }], 23 | "@typescript-eslint/no-use-before-define": ["off"], 24 | "@typescript-eslint/semi": ["error", "never"], 25 | "@typescript-eslint/ban-ts-comment": 1, 26 | "@typescript-eslint/quotes": ["error", "double", { 27 | allowTemplateLiterals: true 28 | }], 29 | // If you want to *intentionally* run a promise without awaiting, prepend it with "void " instead of "await " 30 | "@typescript-eslint/no-floating-promises": ["error"], 31 | '@typescript-eslint/no-unused-vars': [ 32 | 'warn', // or error 33 | { 34 | argsIgnorePattern: '^_', 35 | varsIgnorePattern: '^_', 36 | caughtErrorsIgnorePattern: '^_', 37 | }, 38 | ], 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: "\U0001F41B bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Summary 11 | 12 | ## Problem 13 | 14 | Describe the immediate problem 15 | 16 | ### Impact 17 | 18 | The impact that this bug has 19 | 20 | ## Solution 21 | 22 | Describe the sort of fix that would solve the issue 23 | 24 | # Detail 25 | 26 | **Describe the bug** 27 | A clear and concise description of what the bug is. 28 | 29 | **To Reproduce** 30 | Steps to reproduce the behavior: 31 | 1. Go to '...' 32 | 2. Click on '....' 33 | 3. Scroll down to '....' 34 | 4. See error 35 | 36 | **Expected behavior** 37 | A clear and concise description of what you expected to happen. 38 | 39 | **Screenshots** 40 | If applicable, add screenshots to help explain your problem. 41 | 42 | **Desktop (please complete the following information):** 43 | - OS: [e.g. iOS] 44 | - Browser [e.g. chrome, safari] 45 | - Version [e.g. 22] 46 | 47 | **Smartphone (please complete the following information):** 48 | - Device: [e.g. iPhone6] 49 | - OS: [e.g. iOS8.1] 50 | - Browser [e.g. stock browser, safari] 51 | - Version [e.g. 22] 52 | 53 | **Additional context** 54 | Add any other context about the problem here. 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: "\U0001F497 enhancement" 6 | assignees: '' 7 | 8 | --- 9 | 10 | NB: Feature requests will only be considered if they solve a pain 11 | 12 | # Summary 13 | 14 | ## Problem 15 | 16 | Describe the pain that this feature will solve 17 | 18 | ### Impact 19 | 20 | The impact of not having this feature 21 | 22 | ## Solution 23 | 24 | Describe the solution 25 | 26 | # Detail 27 | 28 | **Is your feature request related to a problem? Please describe.** 29 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 30 | 31 | **Describe the solution you'd like** 32 | A clear and concise description of what you want to happen. 33 | 34 | **Describe alternatives you've considered** 35 | A clear and concise description of any alternative solutions or features you've considered. 36 | 37 | **Additional context** 38 | Add any other context or screenshots about the feature request here. 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | A similar PR may already be submitted! 2 | Please search among the [Pull request](../) before creating one. 3 | 4 | Thanks for submitting a pull request! Please provide enough information so that others can review your pull request: 5 | 6 | For more information, see the `CONTRIBUTING` guide. 7 | 8 | 9 | ## Summary 10 | 11 | 12 | This PR fixes/implements the following **bugs/features** 13 | 14 | * [ ] Bug 1 15 | * [ ] Bug 2 16 | * [ ] Feature 1 17 | * [ ] Feature 2 18 | * [ ] Breaking changes 19 | 20 | 21 | 22 | Explain the **motivation** for making this change. What existing problem does the pull request solve? 23 | 24 | 25 | 26 | ## Test plan (required) 27 | 28 | Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes UI. 29 | 30 | 31 | 32 | 33 | ## Closing issues 34 | 35 | 36 | Fixes # 37 | 38 | ## After Merge 39 | * [ ] Does this change invalidate any docs or tutorials? _If so ensure the changes needed are either made or recorded_ 40 | * [ ] Does this change require a release to be made? Is so please create and deploy the release 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Changelog Check 2 | on: 3 | pull_request: 4 | branches: [ main ] 5 | 6 | # Allows you to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | name: Check Actions 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Changelog check 16 | uses: Zomzog/changelog-checker@v1.2.0 17 | with: 18 | fileName: CHANGELOG.md 19 | noChangelogLabel: "no changelog" 20 | checkNotification: Simple 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-and-deploy: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Node Environment 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: '18' 21 | 22 | - name: Install Dependencies 23 | run: npm install 24 | 25 | - name: Generate docs 26 | run: npm run docs 27 | 28 | - name: Publish to Fission 29 | uses: fission-suite/publish-action@v1 30 | with: 31 | machine_key: ${{ secrets.FISSION_PRODUCTION_KEY }} 32 | build_dir: ./docs 33 | app_url: odd.fission.app 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the main branch 6 | push: 7 | branches: [ main ] 8 | 9 | pull_request: 10 | branches: [ main ] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | build-and-test: 18 | # The type of runner that the job will run on 19 | runs-on: ubuntu-latest 20 | 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 24 | - name: Check out repository 25 | uses: actions/checkout@v2 26 | 27 | - name: Setup Node Environment 28 | uses: actions/setup-node@v2 29 | with: 30 | node-version: '18' 31 | 32 | - name: Install Dependencies 33 | run: npm install 34 | 35 | - name: Build & Test 36 | run: npm run test:prod 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | /lib 4 | /docs 5 | /node_modules 6 | /src/vendor 7 | /yarn-error.log 8 | /fission-ipfs 9 | 10 | 11 | # Created by https://www.toptal.com/developers/gitignore/api/yarn,node,elm 12 | # Edit at https://www.toptal.com/developers/gitignore?templates=yarn,node,elm 13 | 14 | ### Elm ### 15 | # elm-package generated files 16 | elm-stuff 17 | # elm-repl generated files 18 | repl-temp-* 19 | 20 | ### Node ### 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | lerna-debug.log* 28 | 29 | # Diagnostic reports (https://nodejs.org/api/report.html) 30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | *.lcov 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # TypeScript v1 declaration files 56 | typings/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Optional stylelint cache 68 | .stylelintcache 69 | 70 | # Microbundle cache 71 | .rpt2_cache/ 72 | .rts2_cache_cjs/ 73 | .rts2_cache_es/ 74 | .rts2_cache_umd/ 75 | 76 | # Optional REPL history 77 | .node_repl_history 78 | 79 | # Output of 'npm pack' 80 | *.tgz 81 | 82 | # Yarn Integrity file 83 | .yarn-integrity 84 | 85 | # dotenv environment variables file 86 | .env 87 | .env.test 88 | .env*.local 89 | 90 | # TernJS port file 91 | .tern-port 92 | 93 | # Stores VSCode versions used for testing VSCode extensions 94 | .vscode-test 95 | 96 | # Temporary folders 97 | tmp/ 98 | temp/ 99 | 100 | ### yarn ### 101 | # https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored 102 | 103 | .yarn/* 104 | !.yarn/releases 105 | !.yarn/plugins 106 | !.yarn/sdks 107 | !.yarn/versions 108 | 109 | # if you are NOT using Zero-installs, then: 110 | # comment the following lines 111 | !.yarn/cache 112 | 113 | # and uncomment the following lines 114 | # .pnp.* 115 | 116 | # End of https://www.toptal.com/developers/gitignore/api/yarn,node,elm 117 | 118 | .DS_Store 119 | /.direnv -------------------------------------------------------------------------------- /.mocharc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extension: [ "ts" ], 3 | spec: [ 4 | "tests/**/*.test.ts", 5 | "tests/*.test.ts", 6 | "src/**/*.test.ts", 7 | "src/*.test.ts", 8 | ], 9 | require: [ "ts-node/register", "tests/mocha-hook.ts" ], 10 | timeout: 120000, 11 | loader: "ts-node/esm", 12 | } 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | /*.config.ts 3 | /*.config.js 4 | Justfile 5 | /*.nix 6 | tsconfig.json 7 | tslint.json 8 | typedoc.json 9 | *.lock 10 | /tests 11 | /node_modules 12 | /nix 13 | /.github 14 | /src 15 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 15.14.0 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | **TL;DR Be kind, inclusive, and considerate.** 4 | 5 | In the interest of fostering an open, inclusive, and welcoming environment, all 6 | members, contributors, and maintainers interacting within our online community 7 | (including Discord, Discourse, etc.), on affiliated projects and repositories 8 | (including issues, pull requests, and discussions on Github), and/or involved 9 | with associated events pledge to accept and observe the following Code of 10 | Conduct. 11 | 12 | As members, contributors, and maintainers, we pledge to make participation in 13 | our projects and community a harassment-free experience, ensuring a safe 14 | environment for all, regardless of background, gender, gender identity and 15 | expression, age, sexual orientation, disability, physical appearance, body size, 16 | race, ethnicity, religion (or lack thereof), or any other dimension of 17 | diversity. 18 | 19 | Sexual language and imagery will not be accepted in any way. Be kind to others. 20 | Do not insult or put down people within the community. Behave professionally. 21 | Remember that harassment and sexist, racist, or exclusionary jokes are not 22 | appropriate in any form. Participants violating these rules may be sanctioned or 23 | expelled from the community and related projects. 24 | 25 | ## Spelling it out. 26 | 27 | Harassment includes offensive verbal comments or actions related to or involving 28 | 29 | - background 30 | - gender 31 | - gender identity and expression 32 | - age 33 | - sexual orientation 34 | - disability 35 | - physical appearance 36 | - body size 37 | - race 38 | - ethnicity 39 | - religion (or lack thereof) 40 | - economic status 41 | - geographic location 42 | - technology choices 43 | - sexual imagery 44 | - deliberate intimidation 45 | - violence and threats of violence 46 | - stalking 47 | - doxing 48 | - inappropriate or unwelcome physical contact in public spaces 49 | - unwelcomed sexual attention 50 | - influencing unacceptable behavior 51 | - any other dimension of diversity 52 | 53 | ## Our Responsibilities 54 | 55 | Maintainers of the community and associated projects are not only subject to the 56 | anti-harassment policy, but also responsible for executing the policy, 57 | moderating related forums, and for taking appropriate and fair corrective action 58 | in response to any instances of unacceptable behavior that breach the policy. 59 | 60 | Maintainers have the right to remove and reject comments, threads, commits, 61 | code, documentation, pull requests, issues, and contributions not aligned with 62 | this Code of Conduct. 63 | 64 | ## Scope 65 | 66 | This Code of Conduct applies within all project and community spaces, as well as 67 | in any public spaces where an individual representing the community is involved. 68 | This covers: 69 | 70 | - Interactions on the Github repository, including discussions, issues, pull 71 | requests, commits, and wikis 72 | - Interactions on any affiliated Discord, Slack, IRC, or related online 73 | communities and forums like Discourse, etc. 74 | - Any official project emails and social media posts 75 | - Individuals representing the community at public events like meetups, talks, 76 | and presentations 77 | 78 | ## Enforcement 79 | 80 | All instances of abusive, harassing, or otherwise unacceptable behavior should 81 | be reported by contacting the project and community maintainers at 82 | [support@fission.codes][support-email]. All complaints will be reviewed and 83 | investigated and will result in a response that is deemed necessary and 84 | appropriate to the circumstances. 85 | 86 | Maintainers of the community and associated projects are obligated to maintain 87 | confidentiality with regard to the reporter of an incident. Further details of 88 | specific enforcement policies may be posted separately. 89 | 90 | Anyone asked to stop abusive, harassing, or otherwise unacceptable behavior are 91 | expected to comply immediately and accept the response decided on by the 92 | maintainers of the community and associated projects. 93 | 94 | ## Need help? 95 | 96 | If you are experiencing harassment, witness an incident or have concerns about 97 | content please contact us at [support@fission.codes][support-email]. 98 | 99 | ## Attribution 100 | 101 | This Code of Conduct is adapted from the [Contributor Covenant, v2.1][contributor-cov], 102 | among other sources like [!!con’s Code of Conduct][!!con] and 103 | [Mozilla’s Community Participation Guidelines][mozilla]. 104 | 105 | [!!con]: https://bangbangcon.com/conduct.html 106 | [contributor-cov]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ 107 | [mozilla]: https://www.mozilla.org/en-US/about/governance/policies/participation/ 108 | [support-email]: mailto:support@fission.codes 109 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1650374568, 7 | "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "b4a34015c698c7793d592d66adbab377907a2be8", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-utils": { 20 | "locked": { 21 | "lastModified": 1656928814, 22 | "narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", 23 | "owner": "numtide", 24 | "repo": "flake-utils", 25 | "rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "numtide", 30 | "repo": "flake-utils", 31 | "type": "github" 32 | } 33 | }, 34 | "nixpkgs": { 35 | "locked": { 36 | "lastModified": 1657135982, 37 | "narHash": "sha256-NwnpH69LpHktFQBQlL7v8rZF9i6+vB7IV1sCnhYfsJk=", 38 | "owner": "nixos", 39 | "repo": "nixpkgs", 40 | "rev": "a52fe94f98e7de7bfd602c9209fbc38d6da52671", 41 | "type": "github" 42 | }, 43 | "original": { 44 | "owner": "nixos", 45 | "ref": "release-22.05", 46 | "repo": "nixpkgs", 47 | "type": "github" 48 | } 49 | }, 50 | "root": { 51 | "inputs": { 52 | "flake-compat": "flake-compat", 53 | "flake-utils": "flake-utils", 54 | "nixpkgs": "nixpkgs" 55 | } 56 | } 57 | }, 58 | "root": "root", 59 | "version": 7 60 | } 61 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "oddjs"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/release-22.05"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | flake-compat = { 8 | url = "github:edolstra/flake-compat"; 9 | flake = false; 10 | }; 11 | }; 12 | 13 | outputs = { self, nixpkgs, flake-utils, ... }: 14 | flake-utils.lib.eachDefaultSystem (system: 15 | let pkgs = nixpkgs.legacyPackages.${system}; 16 | in 17 | { 18 | devShell = pkgs.mkShell { 19 | name = "oddjs"; 20 | buildInputs = with pkgs; [ nodejs-18_x ]; 21 | }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oddjs/odd", 3 | "version": "0.37.2", 4 | "description": "ODD SDK", 5 | "keywords": [ 6 | "WebCrypto", 7 | "auth", 8 | "files", 9 | "distributed", 10 | "DAG", 11 | "DID", 12 | "IPFS", 13 | "IPLD", 14 | "UCAN", 15 | "WNFS" 16 | ], 17 | "type": "module", 18 | "main": "lib/index.js", 19 | "exports": { 20 | ".": "./lib/index.js", 21 | "./package.json": "./package.json", 22 | "./lib/*": [ 23 | "./lib/*.js", 24 | "./lib/*", 25 | "./lib/*/index.js" 26 | ], 27 | "./*": [ 28 | "./lib/*.js", 29 | "./lib/*", 30 | "./lib/*/index.js", 31 | "./*" 32 | ] 33 | }, 34 | "types": "lib/index.d.ts", 35 | "typesVersions": { 36 | "*": { 37 | "lib/index.d.ts": [ 38 | "lib/index.d.ts" 39 | ], 40 | "*": [ 41 | "lib/*" 42 | ] 43 | } 44 | }, 45 | "files": [ 46 | "lib", 47 | "dist", 48 | "docs", 49 | "src", 50 | "README.md", 51 | "CHANGELOG.md", 52 | "LICENSE", 53 | "package.json", 54 | "!*.test.ts" 55 | ], 56 | "repository": { 57 | "type": "git", 58 | "url": "https://github.com/oddsdk/ts-odd" 59 | }, 60 | "homepage": "https://odd.dev", 61 | "license": "Apache-2.0", 62 | "engines": { 63 | "node": ">=16" 64 | }, 65 | "scripts": { 66 | "docs": "rimraf docs && typedoc", 67 | "lint": "eslint src/**/*.ts src/*.ts tests/**/*.ts tests/*.ts", 68 | "prebuild": "rimraf lib dist && node scripts/gen-version.js", 69 | "build": "tsc && npm run build:minified", 70 | "build:minified": "node scripts/build-minified.js", 71 | "start": "tsc -w", 72 | "test": "mocha", 73 | "test:gh-action": "TEST_ENV=gh-action npm run test", 74 | "test:imports": "madge src --ts-config tsconfig.json --extensions ts --circular --warning", 75 | "test:prod": "npm run build && npm run lint && npm run test:imports && npm run test:gh-action", 76 | "test:types": "cp -RT tests/types/ lib/ && npm run tsd", 77 | "test:unit": "mocha --watch --testPathPattern=src/", 78 | "test:wasm": "WNFS_WASM=true mocha", 79 | "prepare": "npm run build && npm run docs", 80 | "publish-dry": "npm publish --dry-run", 81 | "publish-alpha": "npm publish --tag alpha", 82 | "publish-latest": "npm publish --tag latest" 83 | }, 84 | "dependencies": { 85 | "@ipld/dag-cbor": "^8.0.0", 86 | "@ipld/dag-pb": "^3.0.1", 87 | "@libp2p/interface-keys": "^1.0.4", 88 | "@libp2p/peer-id": "^1.1.17", 89 | "@multiformats/multiaddr": "^11.1.0", 90 | "blockstore-core": "^2.0.2", 91 | "blockstore-datastore-adapter": "^4.0.0", 92 | "datastore-core": "^8.0.2", 93 | "datastore-level": "^9.0.4", 94 | "events": "^3.3.0", 95 | "fission-bloom-filters": "1.7.1", 96 | "ipfs-core-types": "0.13.0", 97 | "ipfs-repo": "^16.0.0", 98 | "keystore-idb": "^0.15.5", 99 | "localforage": "^1.10.0", 100 | "multiformats": "^10.0.2", 101 | "one-webcrypto": "^1.0.3", 102 | "throttle-debounce": "^3.0.1", 103 | "tweetnacl": "^1.0.3", 104 | "uint8arrays": "^3.0.0", 105 | "wnfs": "0.1.7" 106 | }, 107 | "devDependencies": { 108 | "@esbuild-plugins/node-globals-polyfill": "^0.1.1", 109 | "@ipld/car": "^5.0.0", 110 | "@types/expect": "^24.3.0", 111 | "@types/mocha": "^10.0.0", 112 | "@types/node": "^18.11.9", 113 | "@types/throttle-debounce": "^2.1.0", 114 | "@typescript-eslint/eslint-plugin": "^5.10.0", 115 | "@typescript-eslint/parser": "^5.10.0", 116 | "copyfiles": "^2.4.1", 117 | "esbuild": "^0.15.13", 118 | "eslint": "^8.7.0", 119 | "expect": "^27.4.6", 120 | "fast-check": "^3.3.0", 121 | "globby": "^13.1.2", 122 | "ipfs-core": "0.17.0", 123 | "localforage-driver-memory": "^1.0.5", 124 | "madge": "^5.0.1", 125 | "mocha": "^10.1.0", 126 | "rimraf": "^3.0.2", 127 | "ts-node": "^10.9.1", 128 | "tslib": "^2.4.1", 129 | "typedoc": "^0.23.24", 130 | "typedoc-plugin-missing-exports": "^1.0.0", 131 | "typescript": "^4.8.4", 132 | "util": "^0.12.4" 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /scripts/build-minified.js: -------------------------------------------------------------------------------- 1 | import { NodeGlobalsPolyfillPlugin } from "@esbuild-plugins/node-globals-polyfill" 2 | import { globby } from "globby" 3 | import esbuild from "esbuild" 4 | import fs from "fs" 5 | import zlib from "zlib" 6 | 7 | 8 | const globalName = "oddjs" 9 | 10 | 11 | console.log("📦 Bundling & minifying...") 12 | 13 | await esbuild.build({ 14 | entryPoints: [ "src/index.ts" ], 15 | outdir: "dist", 16 | bundle: true, 17 | splitting: true, 18 | minify: true, 19 | sourcemap: true, 20 | platform: "browser", 21 | format: "esm", 22 | target: "es2020", 23 | globalName, 24 | define: { 25 | "global": "globalThis", 26 | "globalThis.process.env.NODE_ENV": "production" 27 | }, 28 | plugins: [ 29 | NodeGlobalsPolyfillPlugin({ 30 | buffer: true 31 | }) 32 | ] 33 | }) 34 | 35 | 36 | fs.renameSync("dist/index.js", "dist/index.esm.min.js") 37 | fs.renameSync("dist/index.js.map", "dist/index.esm.min.js.map") 38 | 39 | 40 | 41 | // UMD 42 | 43 | 44 | const UMD = { 45 | banner: 46 | `(function (root, factory) { 47 | if (typeof define === 'function' && define.amd) { 48 | // AMD. Register as an anonymous module. 49 | define([], factory); 50 | } else if (typeof module === 'object' && module.exports) { 51 | // Node. Does not work with strict CommonJS, but 52 | // only CommonJS-like environments that support module.exports, 53 | // like Node. 54 | module.exports = factory(); 55 | } else { 56 | // Browser globals (root is window) 57 | root.${globalName} = factory(); 58 | } 59 | }(typeof self !== 'undefined' ? self : this, function () { `, 60 | footer: 61 | `return ${globalName}; 62 | }));` 63 | } 64 | 65 | await esbuild.build({ 66 | entryPoints: [ "src/index.ts" ], 67 | outfile: "dist/index.umd.min.js", 68 | bundle: true, 69 | minify: true, 70 | sourcemap: true, 71 | platform: "browser", 72 | format: "iife", 73 | target: "es2020", 74 | globalName, 75 | define: { 76 | "global": "globalThis", 77 | "globalThis.process.env.NODE_ENV": "production" 78 | }, 79 | plugins: [ 80 | NodeGlobalsPolyfillPlugin({ 81 | buffer: true 82 | }) 83 | ], 84 | banner: { js: UMD.banner }, 85 | footer: { js: UMD.footer }, 86 | }) 87 | 88 | 89 | 90 | // GZIP 91 | 92 | 93 | const glob = await globby("dist/*.js") 94 | 95 | 96 | glob.forEach(jsFile => { 97 | const outfile = jsFile 98 | const outfileGz = `${outfile}.gz` 99 | 100 | console.log(`📝 Wrote ${outfile} and ${outfile}.map`) 101 | console.log("💎 Compressing into .gz") 102 | const fileContents = fs.createReadStream(outfile) 103 | const writeStream = fs.createWriteStream(outfileGz) 104 | const gzip = zlib.createGzip() 105 | 106 | fileContents.pipe(gzip).pipe(writeStream) 107 | 108 | console.log(`📝 Wrote ${outfileGz}`) 109 | }) 110 | -------------------------------------------------------------------------------- /scripts/gen-version.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs" 2 | 3 | import pkg from "../package.json" assert { type: "json" }; 4 | import lock from "../package-lock.json" assert { type: "json" }; 5 | 6 | 7 | const version = pkg.version 8 | const wnfsVersionBound = pkg.dependencies.wnfs 9 | 10 | if (wnfsVersionBound == null) { 11 | throw new Error(`Expected 'wnfs' in dependencies, but not found`) 12 | } 13 | 14 | const resolvedWasmWnfsVersion = lock.packages[ "node_modules/wnfs" ].version 15 | 16 | if (resolvedWasmWnfsVersion == null) { 17 | throw new Error(`Couldn't find resolved wnfs version in package-lock.json file`) 18 | } 19 | 20 | let versionModule = "" 21 | versionModule += `export const VERSION = ${JSON.stringify(version, null, 4)}\n` 22 | versionModule += `export const WASM_WNFS_VERSION = ${JSON.stringify(resolvedWasmWnfsVersion, null, 4)}\n` 23 | 24 | await fs.writeFile("src/common/version.ts", versionModule, { encoding: "utf-8" }) -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import 2 | ( 3 | let 4 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 5 | in 6 | fetchTarball { 7 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 8 | sha256 = lock.nodes.flake-compat.locked.narHash; 9 | } 10 | ) 11 | { 12 | src = ./.; 13 | }).shellNix 14 | -------------------------------------------------------------------------------- /src/appInfo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Information about your app. 3 | */ 4 | export type AppInfo = { 5 | name: string 6 | creator: string 7 | } -------------------------------------------------------------------------------- /src/common/arrbufs.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check" 2 | import expect from "expect" 3 | import { equal } from "./arrbufs.js" 4 | 5 | 6 | describe("arrbufs", () => { 7 | 8 | it("supports equal", () => { 9 | fc.assert( 10 | fc.property(fc.uint8Array(), data => { 11 | expect(equal(data.buffer, data.buffer)).toBe(true) 12 | }) 13 | ) 14 | }) 15 | 16 | it("supports not equal", () => { 17 | fc.assert( 18 | fc.property( 19 | fc.tuple( 20 | fc.uint8Array({ minLength: 3 }), 21 | fc.uint8Array({ minLength: 3 }) 22 | ), data => { 23 | expect(equal(data[ 0 ].buffer, data[ 1 ].buffer)).toBe(false) 24 | }) 25 | ) 26 | }) 27 | 28 | }) -------------------------------------------------------------------------------- /src/common/arrbufs.ts: -------------------------------------------------------------------------------- 1 | export const equal = (aBuf: ArrayBuffer, bBuf: ArrayBuffer): boolean => { 2 | const a = new Uint8Array(aBuf) 3 | const b = new Uint8Array(bBuf) 4 | if (a.length !== b.length) return false 5 | for (let i = 0; i < a.length; i++) { 6 | if (a[i] !== b[i]) return false 7 | } 8 | return true 9 | } 10 | -------------------------------------------------------------------------------- /src/common/base64.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as base64 from "./base64.js" 2 | import * as fc from "fast-check" 3 | import expect from "expect" 4 | 5 | 6 | describe("base64", () => { 7 | 8 | it("round trip encodes and decodes a url", () => { 9 | fc.assert( 10 | fc.property(fc.string({ maxLength: 4000 }), data => { 11 | const encodedData = base64.urlEncode(data) 12 | const decodedData = base64.urlDecode(encodedData) 13 | expect(data).toEqual(decodedData) 14 | }) 15 | ) 16 | }) 17 | 18 | }) -------------------------------------------------------------------------------- /src/common/base64.ts: -------------------------------------------------------------------------------- 1 | import * as uint8arrays from "uint8arrays" 2 | 3 | export function decode(base64: string): string { 4 | return uint8arrays.toString(uint8arrays.fromString(base64, "base64pad")) 5 | } 6 | 7 | export function encode(str: string): string { 8 | return uint8arrays.toString(uint8arrays.fromString(str), "base64pad") 9 | } 10 | 11 | export function urlDecode(base64: string): string { 12 | return decode(makeUrlUnsafe(base64)) 13 | } 14 | 15 | export function urlEncode(str: string): string { 16 | return makeUrlSafe(encode(str)) 17 | } 18 | 19 | export function makeUrlSafe(a: string): string { 20 | return a.replace(/\//g, "_").replace(/\+/g, "-").replace(/=+$/, "") 21 | } 22 | 23 | export function makeUrlUnsafe(a: string): string { 24 | return a.replace(/_/g, "/").replace(/-/g, "+") 25 | } 26 | -------------------------------------------------------------------------------- /src/common/blob.ts: -------------------------------------------------------------------------------- 1 | import * as uint8arrays from "uint8arrays" 2 | 3 | export const toUint8Array = async (blob: Blob): Promise => { 4 | return new Promise((resolve, reject) => { 5 | const fail: (() => void) = () => reject(new Error("Failed to read file")) 6 | const reader = new FileReader() 7 | reader.addEventListener("load", (e) => { 8 | const arrbuf = e?.target?.result || null 9 | if (arrbuf == null) { 10 | fail() 11 | return 12 | } 13 | if (typeof arrbuf === "string") { 14 | resolve(uint8arrays.fromString(arrbuf)) 15 | return 16 | } 17 | resolve(new Uint8Array(arrbuf)) 18 | }) 19 | reader.addEventListener("error", () => reader.abort()) 20 | reader.addEventListener("abort", fail) 21 | reader.readAsArrayBuffer(blob) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/common/browser.ts: -------------------------------------------------------------------------------- 1 | export const isBrowser = typeof self !== "undefined" && typeof self.location === "object" 2 | 3 | export const assertBrowser = (method: string): void => { 4 | if (!isBrowser) { 5 | throw new Error(`Must be in browser to use method. Provide a node-compatible implementation for ${method}`) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/common/cid.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | import { decodeCID } from "./cid.js" 3 | 4 | 5 | describe("CIDs", () => { 6 | 7 | it("decodes DAG-JSON cids", () => { 8 | const CID_STRING = "bafkreicu646jao2xjpkbmk3buom6hmxsexmbwyju22k6wmtnky2ljisv3e" 9 | const cidInstance = decodeCID({ "/": CID_STRING }) 10 | 11 | expect(cidInstance.toString()).toEqual(CID_STRING) 12 | }) 13 | 14 | }) -------------------------------------------------------------------------------- /src/common/cid.ts: -------------------------------------------------------------------------------- 1 | import { CID, Version } from "multiformats/cid" 2 | import { decode as decodeMultihash } from "multiformats/hashes/digest" 3 | import { hasProp, isNum, isObject } from "./type-checks.js" 4 | 5 | 6 | export { CID } 7 | 8 | 9 | /** 10 | * CID representing an empty string. We use this to speed up DNS propagation 11 | * However, we treat that as a null value in the code 12 | */ 13 | export const EMPTY_CID = "Qmc5m94Gu7z62RC8waSKkZUrCCBJPyHbkpmGzEePxy2oXJ" 14 | 15 | /** 16 | * Decode a possibly string-encoded CID. 17 | * Passing an already decoded CID instance works too. 18 | * @throws Throws an error if a CID cannot be decoded! 19 | */ 20 | export function decodeCID(val: CID | object | string): CID { 21 | const cid = CID.asCID(val) 22 | if (cid) return cid 23 | 24 | // String format 25 | if (typeof val === "string") return CID.parse(val) 26 | 27 | // CID.toJSON() returns an object in the form of { version, code, hash } 28 | // `hash` was `multihash` previously. 29 | if (typeof val === "object" && "version" in val && "code" in val && "multihash" in val) { 30 | return CID.create(val.version, val.code, val.multihash) 31 | } 32 | 33 | // Older version of the above 34 | if ( 35 | typeof val === "object" && 36 | hasProp(val, "version") && 37 | hasProp(val, "code") && isNum(val.code) && 38 | hasProp(val, "hash") && isObject(val.hash) && Object.values(val.hash).every(isNum) 39 | ) { 40 | const multihash = decodeMultihash(new Uint8Array( 41 | Object.values(val.hash) as number[] 42 | )) 43 | 44 | return CID.create(val.version as Version, val.code, multihash) 45 | } 46 | 47 | // Sometimes we can encounter a DAG-JSON encoded CID 48 | // https://github.com/oddsdk/ts-odd/issues/459 49 | // Related to the `ensureSkeletonStringCIDs` function in the `PrivateTree` class 50 | if ( 51 | typeof val === "object" && 52 | hasProp(val, "/") && 53 | typeof val[ "/" ] === "string" 54 | ) { 55 | return CID.parse(val[ "/" ]) 56 | } 57 | 58 | // Unrecognisable CID 59 | throw new Error(`Could not decode CID: ${JSON.stringify(val)}`) 60 | } 61 | 62 | /** 63 | * Encode a CID as a string. 64 | */ 65 | export function encodeCID(cid: CID | string): string { 66 | return typeof cid === "string" ? cid : cid.toString() 67 | } 68 | -------------------------------------------------------------------------------- /src/common/event-emitter.ts: -------------------------------------------------------------------------------- 1 | export type EventListener = (event: E) => void 2 | 3 | 4 | export class EventEmitter { 5 | private readonly events: Map>> = new Map() 6 | 7 | public addListener(eventName: K, listener: EventListener): void { 8 | const eventSet = this.events.get(eventName) 9 | 10 | if (eventSet === undefined) { 11 | this.events.set(eventName, new Set([ listener ]) as Set>) 12 | } else { 13 | eventSet.add(listener as EventListener) 14 | } 15 | } 16 | 17 | public removeListener(eventName: K, listener: EventListener): void { 18 | const eventSet = this.events.get(eventName) 19 | if (eventSet === undefined) return 20 | 21 | eventSet.delete(listener as EventListener) 22 | 23 | if (eventSet.size === 0) { 24 | this.events.delete(eventName) 25 | } 26 | } 27 | 28 | on = this.addListener 29 | off = this.removeListener 30 | 31 | public emit(eventName: K, event: EventMap[ K ]): void { 32 | this.events.get(eventName)?.forEach((listener: EventListener) => { 33 | listener.apply(this, [ event ]) 34 | }) 35 | } 36 | } -------------------------------------------------------------------------------- /src/common/fission.ts: -------------------------------------------------------------------------------- 1 | import * as DOH from "../components/reference/dns-over-https.js" 2 | import { ShareDetails } from "../fs/types.js" 3 | 4 | 5 | /** 6 | * Fission endpoints. 7 | * 8 | * `apiPath` Path of the API on the Fission server. 9 | * `lobby` Location of the authentication lobby. 10 | * `server` Location of the Fission server. 11 | * `userDomain` User's domain to use, will be prefixed by username. 12 | */ 13 | export type Endpoints = { 14 | apiPath: string 15 | lobby: string 16 | server: string 17 | userDomain: string 18 | } 19 | 20 | 21 | export const PRODUCTION: Endpoints = { 22 | apiPath: "/v2/api", 23 | lobby: "https://auth.fission.codes", 24 | server: "https://runfission.com", 25 | userDomain: "fission.name" 26 | } 27 | 28 | 29 | export const STAGING: Endpoints = { 30 | apiPath: "/v2/api", 31 | lobby: "https://auth.runfission.net", 32 | server: "https://runfission.net", 33 | userDomain: "fissionuser.net" 34 | } 35 | 36 | 37 | export function apiUrl(endpoints: Endpoints, suffix?: string): string { 38 | return `${endpoints.server}${endpoints.apiPath}${suffix?.length ? "/" + suffix.replace(/^\/+/, "") : ""}` 39 | } 40 | 41 | 42 | 43 | // API 44 | 45 | 46 | const didCache: { 47 | did: string | null 48 | host: string | null 49 | lastFetched: number 50 | } = { 51 | did: null, 52 | host: null, 53 | lastFetched: 0, 54 | } 55 | 56 | 57 | /** 58 | * Lookup the DID of a Fission API. 59 | * This function caches the DID for 3 hours. 60 | */ 61 | export async function did(endpoints: Endpoints): Promise { 62 | let host 63 | try { 64 | host = new URL(endpoints.server).host 65 | } catch (e) { 66 | throw new Error("Unable to parse API Endpoint") 67 | } 68 | const now = Date.now() // in milliseconds 69 | 70 | if ( 71 | didCache.host !== host || 72 | didCache.lastFetched + 1000 * 60 * 60 * 3 <= now 73 | ) { 74 | didCache.did = await DOH.lookupTxtRecord("_did." + host) 75 | didCache.host = host 76 | didCache.lastFetched = now 77 | } 78 | 79 | if (!didCache.did) throw new Error("Couldn't get the Fission API DID") 80 | return didCache.did 81 | } 82 | 83 | 84 | /** 85 | * Create a share link. 86 | * There people can "accept" a share, 87 | * copying the soft links into their private filesystem. 88 | */ 89 | export function shareLink(endpoints: Endpoints, details: ShareDetails): string { 90 | return endpoints.lobby + 91 | "/#/share/" + 92 | encodeURIComponent(details.sharedBy.username) + "/" + 93 | encodeURIComponent(details.shareId) + "/" 94 | } 95 | -------------------------------------------------------------------------------- /src/common/hex.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check" 2 | import { fromBytes, toBytes } from "./hex.js" 3 | import expect from "expect" 4 | 5 | 6 | describe("hex", () => { 7 | 8 | it("round trips to bytes and back out", () => { 9 | fc.assert( 10 | fc.property(fc.array(fc.integer({ min: 16, max: 255 })), data => { 11 | const hexData: string[] = [] 12 | const buffers: Uint8Array[] = [] 13 | const returnData: string[] = [] 14 | 15 | for (const num of data) { 16 | hexData.push(num.toString(16)) 17 | } 18 | 19 | for (const hex of hexData) { 20 | buffers.push(toBytes(hex)) 21 | } 22 | 23 | for (const buffer of buffers) { 24 | returnData.push(fromBytes(buffer)) 25 | } 26 | 27 | expect(returnData).toEqual(hexData) 28 | }) 29 | ) 30 | }) 31 | 32 | }) -------------------------------------------------------------------------------- /src/common/hex.ts: -------------------------------------------------------------------------------- 1 | export const fromBytes = (bytes: Uint8Array): string => { 2 | return Array.prototype.map.call( 3 | bytes, 4 | x => ("00" + x.toString(16)).slice(-2) // '00' is for left padding 5 | ).join("") 6 | } 7 | 8 | export const toBytes = (hex: string): Uint8Array => { 9 | const arr = new Uint8Array(hex.length/2) 10 | for(let i=0; i < arr.length; i++) { 11 | arr[i] = parseInt(hex.slice(i*2, i*2 + 2), 16) 12 | } 13 | return arr 14 | } 15 | -------------------------------------------------------------------------------- /src/common/identifiers.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8Arrays from "uint8arrays" 2 | 3 | import { DistinctivePath } from "../path/index.js" 4 | 5 | import * as Crypto from "../components/crypto/implementation.js" 6 | import * as Path from "../path/index.js" 7 | 8 | 9 | type Arguments = { 10 | crypto: Crypto.Implementation 11 | accountDID: string 12 | path: DistinctivePath 13 | } 14 | 15 | 16 | export async function bareNameFilter( 17 | { crypto, accountDID, path }: Arguments 18 | ): Promise { 19 | return `wnfs:${accountDID}:bareNameFilter:${await pathHash(crypto, path)}` 20 | } 21 | 22 | export async function readKey( 23 | { crypto, accountDID, path }: Arguments 24 | ): Promise { 25 | return `wnfs:${accountDID}:readKey:${await pathHash(crypto, path)}` 26 | } 27 | 28 | 29 | 30 | // 🛠 31 | 32 | 33 | async function pathHash(crypto: Crypto.Implementation, path: DistinctivePath): Promise { 34 | return Uint8Arrays.toString( 35 | await crypto.hash.sha256( 36 | Uint8Arrays.fromString("/" + Path.unwrap(path).join("/"), "utf8") 37 | ), 38 | "base64pad" 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | import * as arrbufs from "./arrbufs.js" 2 | import * as base64 from "./base64.js" 3 | import * as blob from "./blob.js" 4 | 5 | export * from "./cid.js" 6 | export * from "./types.js" 7 | export * from "./type-checks.js" 8 | export * from "./util.js" 9 | export * from "./version.js" 10 | export * from "./browser.js" 11 | export { arrbufs, base64, blob } 12 | -------------------------------------------------------------------------------- /src/common/root-key.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | 3 | import * as Crypto from "../components/crypto/implementation.js" 4 | import * as Identifiers from "./identifiers.js" 5 | import * as Path from "../path/index.js" 6 | 7 | 8 | // STORAGE 9 | 10 | 11 | export async function exists({ crypto, accountDID }: { 12 | crypto: Crypto.Implementation 13 | accountDID: string 14 | }): Promise { 15 | const rootKeyId = await identifier(crypto, accountDID) 16 | return crypto.keystore.keyExists(rootKeyId) 17 | } 18 | 19 | 20 | export async function retrieve({ crypto, accountDID }: { 21 | crypto: Crypto.Implementation 22 | accountDID: string 23 | }): Promise { 24 | const rootKeyId = await identifier(crypto, accountDID) 25 | return crypto.keystore.exportSymmKey(rootKeyId) 26 | } 27 | 28 | 29 | export async function store({ crypto, accountDID, readKey }: { 30 | crypto: Crypto.Implementation 31 | readKey: Uint8Array 32 | accountDID: string 33 | }): Promise { 34 | const rootKeyId = await identifier(crypto, accountDID) 35 | return crypto.keystore.importSymmKey(readKey, rootKeyId) 36 | } 37 | 38 | 39 | 40 | // ENCODING 41 | 42 | 43 | export function fromString(a: string): Uint8Array { 44 | return Uint8arrays.fromString(a, "base64pad") 45 | } 46 | 47 | 48 | export function toString(a: Uint8Array): string { 49 | return Uint8arrays.toString(a, "base64pad") 50 | } 51 | 52 | 53 | 54 | // ㊙️ 55 | 56 | 57 | function identifier(crypto: Crypto.Implementation, accountDID: string): Promise { 58 | const path = Path.directory(Path.RootBranch.Private) 59 | return Identifiers.readKey({ crypto, path, accountDID }) 60 | } -------------------------------------------------------------------------------- /src/common/semver.ts: -------------------------------------------------------------------------------- 1 | // TYPES 2 | 3 | 4 | export type SemVer = { 5 | major: number 6 | minor: number 7 | patch: number 8 | } 9 | 10 | 11 | 12 | // FUNCTIONS 13 | 14 | 15 | export const encode = (major: number, minor: number, patch: number): SemVer => { 16 | return { 17 | major, 18 | minor, 19 | patch 20 | } 21 | } 22 | 23 | export const fromString = (str: string): SemVer | null => { 24 | const parts = str.split(".").map(x => parseInt(x)) // dont shorten this because parseInt has a second param 25 | if (parts.length !== 3 || parts.some(p => typeof p !== "number")) { 26 | return null 27 | } 28 | return { 29 | major: parts[ 0 ], 30 | minor: parts[ 1 ], 31 | patch: parts[ 2 ] 32 | } 33 | } 34 | 35 | export const toString = (version: SemVer): string => { 36 | const { major, minor, patch } = version 37 | return `${major}.${minor}.${patch}` 38 | } 39 | 40 | export const equals = (a: SemVer, b: SemVer): boolean => { 41 | return a.major === b.major 42 | && a.minor === b.minor 43 | && a.patch === b.patch 44 | } 45 | 46 | export const isSmallerThan = (a: SemVer, b: SemVer): boolean => { 47 | if (a.major != b.major) return a.major < b.major 48 | if (a.minor != b.minor) return a.minor < b.minor 49 | return a.patch < b.patch 50 | } 51 | 52 | export const isBiggerThan = (a: SemVer, b: SemVer): boolean => { 53 | return isSmallerThan(b, a) 54 | } 55 | 56 | export function isBiggerThanOrEqualTo(a: SemVer, b: SemVer): boolean { 57 | return isSmallerThan(b, a) || equals(a, b) 58 | } 59 | -------------------------------------------------------------------------------- /src/common/type-checks.ts: -------------------------------------------------------------------------------- 1 | export function hasProp(data: unknown, prop: K): data is Record { 2 | return typeof data === "object" && data != null && prop in data 3 | } 4 | 5 | export const isDefined = (val: T | undefined): val is T => { 6 | return val !== undefined 7 | } 8 | 9 | export const notNull = (val: T | null): val is T => { 10 | return val !== null 11 | } 12 | 13 | export const isJust = notNull 14 | 15 | export const isValue = (val: T | undefined | null): val is T => { 16 | return isDefined(val) && notNull(val) 17 | } 18 | 19 | export const isBool = (val: unknown): val is boolean => { 20 | return typeof val === "boolean" 21 | } 22 | 23 | export function isCryptoKey(val: unknown): val is CryptoKey { 24 | return hasProp(val, "algorithm") && hasProp(val, "extractable") && hasProp(val, "type") 25 | } 26 | 27 | export const isNum = (val: unknown): val is number => { 28 | return typeof val === "number" 29 | } 30 | 31 | export const isString = (val: unknown): val is string => { 32 | return typeof val === "string" 33 | } 34 | 35 | export const isObject = (val: unknown): val is Record => { 36 | return val !== null && typeof val === "object" 37 | } 38 | 39 | export const isBlob = (val: unknown): val is Blob => { 40 | if (typeof Blob === "undefined") return false 41 | return val instanceof Blob || (isObject(val) && val?.constructor?.name === "Blob") 42 | } 43 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null 2 | 3 | // https://codemix.com/opaque-types-in-javascript/ 4 | export type Opaque = T & { __TYPE__: K } 5 | 6 | export type Result = 7 | | { ok: true; value: T } 8 | | { ok: false; error: E } -------------------------------------------------------------------------------- /src/common/util.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | import * as util from "./util.js" 3 | 4 | 5 | describe("removes a key from an object", () => { 6 | it("removes a key from an object", () => { 7 | const obj = { a: 1, b: 2 } 8 | expect(util.removeKeyFromObj(obj, "b")).toEqual({ a: 1 }) 9 | }) 10 | 11 | it("removes the last key and returns an empty object", () => { 12 | const obj = { a: 1 } 13 | expect(util.removeKeyFromObj(obj, "a")).toEqual({}) 14 | }) 15 | 16 | it("returns the same object when the key is missing", () => { 17 | const obj = { a: 1 } 18 | expect(util.removeKeyFromObj(obj, "b")).toEqual({ a: 1 }) 19 | }) 20 | }) 21 | 22 | describe("updates a value or removes a key from an object", () => { 23 | it("updates an object", () => { 24 | const obj = { a: 1 } 25 | expect(util.updateOrRemoveKeyFromObj(obj, "a", 2)).toEqual({ a: 2 }) 26 | }) 27 | 28 | it("removes a key from an object", () => { 29 | const obj = { a: 1, b: 2 } 30 | expect(util.updateOrRemoveKeyFromObj(obj, "b", null)).toEqual({ a: 1 }) 31 | }) 32 | 33 | it("adds a key when missing on an object", () => { 34 | const obj = { a: 1 } 35 | expect(util.updateOrRemoveKeyFromObj(obj, "b", 2)).toEqual({ a: 1, b: 2 }) 36 | }) 37 | 38 | it("does not add a key when the update value is null", () => { 39 | const obj = { a: 1 } 40 | expect(util.updateOrRemoveKeyFromObj(obj, "b", null)).toEqual({ a: 1 }) 41 | }) 42 | }) 43 | 44 | describe("maps over an object", () => { 45 | it("adds one to each entry in an object", () => { 46 | const obj = { a: 1, b: 2, c: 3 } 47 | expect(util.mapObj(obj, ((val, key) => val + 1))).toEqual({ a: 2, b: 3, c: 4 }) 48 | }) 49 | 50 | it("nullifies each entry in an object", () => { 51 | const obj = { a: 1, b: 2, c: 3 } 52 | expect(util.mapObj(obj, ((val, key) => null))).toEqual({ a: null, b: null, c: null }) 53 | }) 54 | 55 | it("has no effect on an empty object", () => { 56 | const obj = {} 57 | expect(util.mapObj(obj, ((val, key) => null))).toEqual({}) 58 | }) 59 | 60 | it("sets each entries value to its key", () => { 61 | const obj = { a: 1, b: 2 } 62 | expect(util.mapObj(obj, ((val, key) => key))).toEqual({ a: "a", b: "b" }) 63 | }) 64 | }) 65 | 66 | describe("async maps over an object", () => { 67 | it("adds one to each entry in an object", async () => { 68 | const obj = { a: 1, b: 2, c: 3 } 69 | async function addOne(val: number, key: string) { return val + 1 } 70 | expect(await util.mapObjAsync(obj, addOne)).toEqual({ a: 2, b: 3, c: 4 }) 71 | }) 72 | 73 | it("nullifies each entry in an object", async () => { 74 | const obj = { a: 1, b: 2, c: 3 } 75 | async function nullify(val: number, key: string) { return null } 76 | expect(await util.mapObjAsync(obj, nullify)).toEqual({ a: null, b: null, c: null }) 77 | }) 78 | 79 | it("has no effect on an empty object", async () => { 80 | const obj = {} 81 | async function nullify(val: number, key: string) { return null } 82 | expect(await util.mapObjAsync(obj, nullify)).toEqual({}) 83 | }) 84 | 85 | it("sets each entries value to its key", async () => { 86 | const obj = { a: 1, b: 2 } 87 | async function setToKey(val: number, key: string) { return key } 88 | expect(await util.mapObjAsync(obj, setToKey)).toEqual({ a: "a", b: "b" }) 89 | }) 90 | }) 91 | 92 | describe("array contains", () => { 93 | it("returns true when an array contains an entry", () => { 94 | const arr = [ 1, 2, 3 ] 95 | expect(util.arrContains(arr, 2)).toBe(true) 96 | }) 97 | 98 | it("returns false when an array does not contain an entry", () => { 99 | const arr = [ 1, 2, 3 ] 100 | expect(util.arrContains(arr, 0)).toBe(false) 101 | }) 102 | 103 | it("returns false when an array is empty", () => { 104 | const arr: number[] = [] 105 | expect(util.arrContains(arr, 1)).toBe(false) 106 | }) 107 | }) 108 | 109 | describe("async waterfall", () => { 110 | it("accumulates values returned from async calls", async () => { 111 | async function addOne(val: number) { return val + 1 } 112 | async function addTwo(val: number) { return val + 2 } 113 | async function addThree(val: number) { return val + 3 } 114 | expect(await util.asyncWaterfall(0, [ addOne, addTwo, addThree ])).toEqual(6) 115 | }) 116 | 117 | it("concatenates characters returned from async calls", async () => { 118 | async function concatB(val: string) { return val + "b" } 119 | async function concatC(val: string) { return val + "c" } 120 | async function concatD(val: string) { return val + "d" } 121 | expect(await util.asyncWaterfall("a", [ concatB, concatC, concatD ])).toEqual("abcd") 122 | }) 123 | 124 | it("returns the initial value when no waterfall", async () => { 125 | expect(await util.asyncWaterfall(0, [])).toEqual(0) 126 | }) 127 | }) -------------------------------------------------------------------------------- /src/common/util.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from "./types.js" 2 | 3 | export const removeKeyFromObj = ( 4 | obj: {[key: string]: T}, 5 | key: string 6 | ): {[key: string]: T} => { 7 | const { [key]: omit, ...rest } = obj // eslint-disable-line 8 | return rest 9 | } 10 | 11 | export const updateOrRemoveKeyFromObj = ( 12 | obj: {[key: string]: T}, 13 | key: string, 14 | val: Maybe 15 | ): {[key: string]: T} => ( 16 | val === null 17 | ? removeKeyFromObj(obj, key) 18 | : { 19 | ...obj, 20 | [key]: val 21 | } 22 | ) 23 | 24 | export const mapObj = ( 25 | obj: {[key: string]: T}, 26 | fn: (val: T, key: string) => S 27 | ): {[key: string]: S} => { 28 | const newObj = {} as {[key: string]: S} 29 | Object.entries(obj).forEach(([key, value]) => { 30 | newObj[key] = fn(value, key) 31 | }) 32 | return newObj 33 | } 34 | 35 | export const mapObjAsync = async ( 36 | obj: {[key: string]: T}, 37 | fn: (val: T, key: string) => Promise 38 | ): Promise<{[key: string]: S}> => { 39 | const newObj = {} as {[key: string]: S} 40 | await Promise.all( 41 | Object.entries(obj).map(async ([key, value]) => { 42 | newObj[key] = await fn(value, key) 43 | }) 44 | ) 45 | return newObj 46 | } 47 | 48 | export const arrContains = (arr: T[], val: T): boolean => { 49 | return arr.indexOf(val) > -1 50 | } 51 | 52 | export const asyncWaterfall = async (val: T, operations: ((val: T) => Promise)[]): Promise => { 53 | let acc = val 54 | for(let i=0; i 15 | capabilities: Capabilities.Implementation 16 | crypto: Crypto.Implementation 17 | depot: Depot.Implementation 18 | manners: Manners.Implementation 19 | reference: Reference.Implementation 20 | storage: Storage.Implementation 21 | } 22 | 23 | 24 | 25 | // CONVENIENCE EXPORTS 26 | 27 | 28 | export { Auth, Capabilities, Crypto, Depot, Manners, Reference, Storage } -------------------------------------------------------------------------------- /src/components/auth/channel.ts: -------------------------------------------------------------------------------- 1 | import * as Reference from "../reference/implementation.js" 2 | import type { Maybe } from "../../common/types.js" 3 | 4 | 5 | // TYPES 6 | 7 | 8 | export type Channel = { 9 | close: () => void 10 | send: (data: ChannelData) => void 11 | } 12 | 13 | export type ChannelOptions = { 14 | handleMessage: (event: MessageEvent) => void 15 | username: string 16 | } 17 | 18 | export type ChannelData = string | ArrayBufferLike | Blob | ArrayBufferView 19 | 20 | 21 | 22 | // FUNCTIONS 23 | 24 | 25 | export const createWssChannel = async ( 26 | reference: Reference.Implementation, 27 | socketEndpoint: ({ rootDID }: { rootDID: string }) => string, 28 | options: ChannelOptions 29 | ): Promise => { 30 | const { username, handleMessage } = options 31 | 32 | const rootDID = await waitForRootDid(reference, username) 33 | if (!rootDID) { 34 | throw new Error(`Failed to lookup DID for ${username}`) 35 | } 36 | 37 | const topic = `deviceLink#${rootDID}` 38 | console.log("Opening channel", topic) 39 | 40 | const socket: Maybe = new WebSocket(socketEndpoint({ rootDID })) 41 | await waitForOpenConnection(socket) 42 | socket.onmessage = handleMessage 43 | 44 | const send = publishOnWssChannel(socket) 45 | const close = closeWssChannel(socket) 46 | 47 | return { 48 | send, 49 | close 50 | } 51 | } 52 | 53 | const waitForRootDid = async ( 54 | reference: Reference.Implementation, 55 | username: string, 56 | ): Promise => { 57 | let rootDid = await reference.didRoot.lookup(username).catch(() => { 58 | console.warn("Could not fetch root DID. Retrying.") 59 | return null 60 | }) 61 | if (rootDid) { 62 | return rootDid 63 | } 64 | 65 | return new Promise((resolve, reject) => { 66 | const maxRetries = 10 67 | let tries = 0 68 | 69 | const rootDidInterval = setInterval(async () => { 70 | rootDid = await reference.didRoot.lookup(username).catch(() => { 71 | console.warn("Could not fetch root DID. Retrying.") 72 | return null 73 | }) 74 | 75 | if (!rootDid && tries < maxRetries) { 76 | tries++ 77 | return 78 | } else if (!rootDid && tries === maxRetries) { 79 | reject("Failed to fetch root DID.") 80 | } 81 | 82 | clearInterval(rootDidInterval) 83 | resolve(rootDid) 84 | }, 1000) 85 | }) 86 | } 87 | 88 | const waitForOpenConnection = async (socket: WebSocket): Promise => { 89 | return new Promise((resolve, reject) => { 90 | socket.onopen = () => resolve() 91 | socket.onerror = () => reject("Websocket channel could not be opened") 92 | }) 93 | } 94 | 95 | export const closeWssChannel = (socket: Maybe): () => void => { 96 | return function () { 97 | if (socket) socket.close(1000) 98 | } 99 | } 100 | 101 | export const publishOnWssChannel = (socket: WebSocket): (data: ChannelData) => void => { 102 | return function (data: ChannelData) { 103 | const binary = typeof data === "string" 104 | ? new TextEncoder().encode(data).buffer 105 | : data 106 | 107 | socket?.send(binary) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/components/auth/implementation.ts: -------------------------------------------------------------------------------- 1 | import type { Channel, ChannelOptions } from "./channel.js" 2 | 3 | import * as Events from "../../events.js" 4 | import { Configuration } from "../../configuration.js" 5 | import { Maybe } from "../../common/types.js" 6 | import { Session } from "../../session.js" 7 | 8 | 9 | export type Implementation = { 10 | type: string 11 | 12 | // `Session` producer 13 | session: ( 14 | components: C, 15 | authenticatedUsername: Maybe, 16 | config: Configuration, 17 | eventEmitters: { fileSystem: Events.Emitter; session: Events.Emitter> } 18 | ) => Promise> 19 | 20 | // Account creation 21 | isUsernameAvailable: (username: string) => Promise 22 | isUsernameValid: (username: string) => Promise 23 | register: (options: { username: string; email?: string }) => Promise<{ success: boolean }> 24 | 25 | // Account delegation 26 | canDelegateAccount: (username: string) => Promise 27 | delegateAccount: (username: string, audience: string) => Promise> 28 | linkDevice: (username: string, data: Record) => Promise 29 | 30 | // Primitives 31 | createChannel: (options: ChannelOptions) => Promise 32 | } 33 | -------------------------------------------------------------------------------- /src/components/auth/implementation/README.md: -------------------------------------------------------------------------------- 1 | # Implementations 2 | 3 | `base`: Basic functionality for account delegation and device linking, all implementations inherit from this. A UCAN is issued to the other device, giving it full rights. 4 | `wnfs`: Extends `base` so that it also provides the linked device with the root read key of a user's filesystem. Self-authorises an additional UCAN so that it can write to the filesystem. Linking only occurs between devices that have access to the root read key of a filesystem. 5 | 6 | These implementations don't have a default user registration system, that's where Fission comes in. -------------------------------------------------------------------------------- /src/components/auth/implementation/base.ts: -------------------------------------------------------------------------------- 1 | import * as Crypto from "../../crypto/implementation.js" 2 | import * as Reference from "../../reference/implementation.js" 3 | import * as Storage from "../../storage/implementation.js" 4 | 5 | import * as Did from "../../../did/index.js" 6 | import * as Events from "../../../events.js" 7 | import * as SessionMod from "../../../session.js" 8 | import * as Ucan from "../../../ucan/index.js" 9 | 10 | import { Components } from "../../../components.js" 11 | import { Configuration } from "../../../configuration.js" 12 | import { Implementation } from "../implementation.js" 13 | import { Maybe } from "../../../common/types.js" 14 | import { Session } from "../../../session.js" 15 | 16 | 17 | // 🏔 18 | 19 | 20 | export const TYPE = "webCrypto" 21 | 22 | 23 | export type Dependencies = { 24 | crypto: Crypto.Implementation 25 | reference: Reference.Implementation 26 | storage: Storage.Implementation 27 | } 28 | 29 | 30 | 31 | // 🛠 32 | 33 | 34 | export async function canDelegateAccount( 35 | dependencies: Dependencies, 36 | username: string 37 | ): Promise { 38 | const didFromDNS = await dependencies.reference.didRoot.lookup(username) 39 | const maybeUcan: string | null = await dependencies.storage.getItem(dependencies.storage.KEYS.ACCOUNT_UCAN) 40 | 41 | if (maybeUcan) { 42 | const rootIssuerDid = Ucan.rootIssuer(maybeUcan) 43 | const decodedUcan = Ucan.decode(maybeUcan) 44 | const { ptc } = decodedUcan.payload 45 | 46 | return didFromDNS === rootIssuerDid && ptc === "SUPER_USER" 47 | } else { 48 | const rootDid = await Did.write(dependencies.crypto) 49 | 50 | return didFromDNS === rootDid 51 | } 52 | } 53 | 54 | export async function delegateAccount( 55 | dependencies: Dependencies, 56 | username: string, 57 | audience: string 58 | ): Promise> { 59 | const proof: string | undefined = await dependencies.storage.getItem( 60 | dependencies.storage.KEYS.ACCOUNT_UCAN 61 | ) ?? undefined 62 | 63 | // UCAN 64 | const u = await Ucan.build({ 65 | dependencies, 66 | 67 | audience, 68 | issuer: await Did.write(dependencies.crypto), 69 | lifetimeInSeconds: 60 * 60 * 24 * 30 * 12 * 1000, // 1000 years 70 | potency: "SUPER_USER", 71 | proof, 72 | 73 | // TODO: UCAN v0.7.0 74 | // proofs: [ await localforage.getItem(dependencies.storage.KEYS.ACCOUNT_UCAN) ] 75 | }) 76 | 77 | return { token: Ucan.encode(u) } 78 | } 79 | 80 | export async function linkDevice( 81 | dependencies: Dependencies, 82 | username: string, 83 | data: Record 84 | ): Promise { 85 | const { token } = data 86 | const u = Ucan.decode(token as string) 87 | 88 | if (await Ucan.isValid(dependencies.crypto, u)) { 89 | await dependencies.storage.setItem(dependencies.storage.KEYS.ACCOUNT_UCAN, token) 90 | await SessionMod.provide(dependencies.storage, { type: TYPE, username }) 91 | } 92 | } 93 | 94 | /** 95 | * Doesn't quite register an account yet, 96 | * needs to be implemented properly by other implementations. 97 | * 98 | * NOTE: This base function should be called by other implementations, 99 | * because it's the foundation for sessions. 100 | */ 101 | export async function register( 102 | dependencies: Dependencies, 103 | options: { username: string; email?: string; type?: string } 104 | ): Promise<{ success: boolean }> { 105 | await SessionMod.provide(dependencies.storage, { type: options.type || TYPE, username: options.username }) 106 | return { success: true } 107 | } 108 | 109 | export async function session( 110 | components: Components, 111 | authedUsername: Maybe, 112 | config: Configuration, 113 | eventEmitters: { session: Events.Emitter> } 114 | ): Promise> { 115 | if (authedUsername) { 116 | const session = new Session({ 117 | crypto: components.crypto, 118 | storage: components.storage, 119 | eventEmitter: eventEmitters.session, 120 | type: TYPE, 121 | username: authedUsername 122 | }) 123 | 124 | return session 125 | 126 | } else { 127 | return null 128 | 129 | } 130 | } 131 | 132 | 133 | 134 | // 🛳 135 | 136 | 137 | export function implementation(dependencies: Dependencies): Implementation { 138 | return { 139 | type: TYPE, 140 | 141 | canDelegateAccount: (...args) => canDelegateAccount(dependencies, ...args), 142 | delegateAccount: (...args) => delegateAccount(dependencies, ...args), 143 | linkDevice: (...args) => linkDevice(dependencies, ...args), 144 | register: (...args) => register(dependencies, ...args), 145 | session: session, 146 | 147 | // Have to be implemented properly by other implementations 148 | createChannel: () => { throw new Error("Not implemented") }, 149 | isUsernameValid: () => { throw new Error("Not implemented") }, 150 | isUsernameAvailable: () => { throw new Error("Not implemented") }, 151 | } 152 | } -------------------------------------------------------------------------------- /src/components/auth/implementation/fission-base-production.ts: -------------------------------------------------------------------------------- 1 | import type { Components } from "../../../components.js" 2 | import type { Dependencies } from "./base.js" 3 | import type { Implementation } from "../implementation.js" 4 | 5 | import * as FissionBase from "./fission-base.js" 6 | import * as FissionEndpoints from "../../../common/fission.js" 7 | 8 | 9 | export function implementation(dependencies: Dependencies): Implementation { 10 | return FissionBase.implementation(FissionEndpoints.PRODUCTION, dependencies) 11 | } -------------------------------------------------------------------------------- /src/components/auth/implementation/fission-base-staging.ts: -------------------------------------------------------------------------------- 1 | import type { Components } from "../../../components.js" 2 | import type { Dependencies } from "./base.js" 3 | import type { Implementation } from "../implementation.js" 4 | 5 | import * as FissionBase from "./fission-base.js" 6 | import * as FissionEndpoints from "../../../common/fission.js" 7 | 8 | 9 | export function implementation(dependencies: Dependencies): Implementation { 10 | return FissionBase.implementation(FissionEndpoints.STAGING, dependencies) 11 | } -------------------------------------------------------------------------------- /src/components/auth/implementation/fission-base.ts: -------------------------------------------------------------------------------- 1 | import type { Components } from "../../../components.js" 2 | import type { Dependencies } from "./base.js" 3 | import type { Channel, ChannelOptions } from "../channel.js" 4 | import type { Implementation } from "../implementation.js" 5 | 6 | import * as Base from "./base.js" 7 | import * as ChannelFission from "./fission/channel.js" 8 | import * as ChannelMod from "../channel.js" 9 | import * as Fission from "./fission/index.js" 10 | 11 | 12 | export function createChannel( 13 | endpoints: Fission.Endpoints, 14 | dependencies: Dependencies, 15 | options: ChannelOptions 16 | ): Promise { 17 | return ChannelMod.createWssChannel( 18 | dependencies.reference, 19 | ChannelFission.endpoint( 20 | `${endpoints.server}${endpoints.apiPath}`.replace(/^https?:\/\//, "wss://") 21 | ), 22 | options 23 | ) 24 | } 25 | 26 | export const isUsernameAvailable = async (endpoints: Fission.Endpoints, username: string): Promise => { 27 | return Fission.isUsernameAvailable(endpoints, username) 28 | } 29 | 30 | export const isUsernameValid = async (username: string): Promise => { 31 | return Fission.isUsernameValid(username) 32 | } 33 | 34 | export const register = async ( 35 | endpoints: Fission.Endpoints, 36 | dependencies: Dependencies, 37 | options: { username: string; email?: string } 38 | ): Promise<{ success: boolean }> => { 39 | const { success } = await Fission.createAccount(endpoints, dependencies, options) 40 | if (success) return Base.register(dependencies, { ...options, type: Base.TYPE }) 41 | return { success: false } 42 | } 43 | 44 | 45 | 46 | // 🛳 47 | 48 | 49 | export function implementation( 50 | endpoints: Fission.Endpoints, 51 | dependencies: Dependencies 52 | ): Implementation { 53 | const base = Base.implementation(dependencies) 54 | 55 | return { 56 | type: base.type, 57 | 58 | canDelegateAccount: base.canDelegateAccount, 59 | delegateAccount: base.delegateAccount, 60 | linkDevice: base.linkDevice, 61 | session: base.session, 62 | 63 | isUsernameValid, 64 | 65 | createChannel: (...args) => createChannel(endpoints, dependencies, ...args), 66 | isUsernameAvailable: (...args) => isUsernameAvailable(endpoints, ...args), 67 | register: (...args) => register(endpoints, dependencies, ...args) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/auth/implementation/fission-wnfs-production.ts: -------------------------------------------------------------------------------- 1 | import type { Components } from "../../../components.js" 2 | import type { Dependencies } from "./base.js" 3 | import type { Implementation } from "../implementation.js" 4 | 5 | import * as FissionWnfs from "./fission-wnfs.js" 6 | import * as FissionEndpoints from "../../../common/fission.js" 7 | 8 | 9 | export function implementation(dependencies: Dependencies): Implementation { 10 | return FissionWnfs.implementation(FissionEndpoints.PRODUCTION, dependencies) 11 | } -------------------------------------------------------------------------------- /src/components/auth/implementation/fission-wnfs-staging.ts: -------------------------------------------------------------------------------- 1 | import type { Components } from "../../../components.js" 2 | import type { Dependencies } from "./base.js" 3 | import type { Implementation } from "../implementation.js" 4 | 5 | import * as FissionWnfs from "./fission-wnfs.js" 6 | import * as FissionEndpoints from "../../../common/fission.js" 7 | 8 | 9 | export function implementation(dependencies: Dependencies): Implementation { 10 | return FissionWnfs.implementation(FissionEndpoints.STAGING, dependencies) 11 | } -------------------------------------------------------------------------------- /src/components/auth/implementation/fission-wnfs.ts: -------------------------------------------------------------------------------- 1 | import type { Components } from "../../../components.js" 2 | import type { Dependencies } from "./base.js" 3 | import type { Implementation } from "../implementation.js" 4 | 5 | import * as Fission from "./fission/index.js" 6 | import * as FissionBase from "./fission-base.js" 7 | import * as Wnfs from "./wnfs.js" 8 | 9 | 10 | // 🛳 11 | 12 | 13 | export function implementation( 14 | endpoints: Fission.Endpoints, 15 | dependencies: Dependencies 16 | ): Implementation { 17 | const fissionBase = FissionBase.implementation(endpoints, dependencies) 18 | const wnfs = Wnfs.implementation(dependencies) 19 | 20 | return { 21 | type: wnfs.type, 22 | 23 | canDelegateAccount: wnfs.canDelegateAccount, 24 | delegateAccount: wnfs.delegateAccount, 25 | linkDevice: wnfs.linkDevice, 26 | session: wnfs.session, 27 | 28 | createChannel: fissionBase.createChannel, 29 | isUsernameValid: fissionBase.isUsernameValid, 30 | isUsernameAvailable: fissionBase.isUsernameAvailable, 31 | register: fissionBase.register, 32 | } 33 | } -------------------------------------------------------------------------------- /src/components/auth/implementation/fission/channel.ts: -------------------------------------------------------------------------------- 1 | export function endpoint(host: string) { 2 | return ({ rootDID }: { rootDID: string }): string => { 3 | return `${host}/user/link/${rootDID}` 4 | } 5 | } -------------------------------------------------------------------------------- /src/components/auth/implementation/fission/index.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | import { isUsernameValid } from "./index.js" 3 | 4 | 5 | describe("isUsernameValid", () => { 6 | 7 | it("allows basic usernames", () => { 8 | expect(isUsernameValid("simple")).toBe(true) 9 | }) 10 | 11 | it("allows internal hyphens", () => { 12 | expect(isUsernameValid("happy-name")).toBe(true) 13 | }) 14 | 15 | it("allows numbers", () => { 16 | expect(isUsernameValid("not-the-90s-anymore")).toBe(true) 17 | }) 18 | 19 | it("allows internal underscores", () => { 20 | expect(isUsernameValid("under_score")).toBe(true) 21 | }) 22 | 23 | it("does not allow blocklisted words", () => { 24 | expect(isUsernameValid("recovery")).toBe(false) 25 | }) 26 | 27 | it("is not case sensitive", () => { 28 | expect(isUsernameValid("reCovErY")).toBe(false) 29 | }) 30 | 31 | it("does not allow empty strings", () => { 32 | expect(isUsernameValid("")).toBe(false) 33 | }) 34 | 35 | it("does not allow special characters", () => { 36 | expect(isUsernameValid("plus+plus")).toBe(false) 37 | }) 38 | 39 | it("does not allow prefixed hyphens", () => { 40 | expect(isUsernameValid("-startswith")).toBe(false) 41 | }) 42 | 43 | it("does not allow suffixed hyphens", () => { 44 | expect(isUsernameValid("endswith-")).toBe(false) 45 | }) 46 | 47 | it("does not allow prefixed underscores", () => { 48 | expect(isUsernameValid("_startswith")).toBe(false) 49 | }) 50 | 51 | it("does not allow spaces", () => { 52 | expect(isUsernameValid("with space")).toBe(false) 53 | }) 54 | 55 | it("does not allow dots", () => { 56 | expect(isUsernameValid("with.dot")).toBe(false) 57 | }) 58 | 59 | it("does not allow two dots", () => { 60 | expect(isUsernameValid("has.two.dots")).toBe(false) 61 | }) 62 | 63 | it("does not allow special characters", () => { 64 | expect(isUsernameValid("name&with#chars")).toBe(false) 65 | }) 66 | 67 | }) 68 | -------------------------------------------------------------------------------- /src/components/auth/implementation/fission/index.ts: -------------------------------------------------------------------------------- 1 | import type { Dependencies } from "../base.js" 2 | 3 | import * as Crypto from "../../../crypto/implementation.js" 4 | import * as DID from "../../../../did/index.js" 5 | import * as Fission from "../../../../common/fission.js" 6 | import * as Reference from "../../../reference/implementation.js" 7 | import * as Ucan from "../../../../ucan/index.js" 8 | 9 | import { USERNAME_BLOCKLIST } from "./blocklist.js" 10 | import { Endpoints } from "../../../../common/fission.js" 11 | 12 | 13 | export * from "../../../../common/fission.js" 14 | 15 | 16 | /** 17 | * Create a user account. 18 | */ 19 | export async function createAccount( 20 | endpoints: Endpoints, 21 | dependencies: Dependencies, 22 | userProps: { 23 | username: string 24 | email?: string 25 | } 26 | ): Promise<{ success: boolean }> { 27 | const jwt = Ucan.encode(await Ucan.build({ 28 | audience: await Fission.did(endpoints), 29 | dependencies: dependencies, 30 | issuer: await DID.ucan(dependencies.crypto), 31 | })) 32 | 33 | const response = await fetch(Fission.apiUrl(endpoints, "/user"), { 34 | method: "PUT", 35 | headers: { 36 | "authorization": `Bearer ${jwt}`, 37 | "content-type": "application/json" 38 | }, 39 | body: JSON.stringify(userProps) 40 | }) 41 | 42 | return { 43 | success: response.status < 300 44 | } 45 | } 46 | 47 | 48 | /** 49 | * Check if a username is available. 50 | */ 51 | export async function isUsernameAvailable( 52 | endpoints: Endpoints, 53 | username: string 54 | ): Promise { 55 | const resp = await fetch( 56 | Fission.apiUrl(endpoints, `/user/data/${username}`) 57 | ) 58 | 59 | return !resp.ok 60 | } 61 | 62 | 63 | /** 64 | * Check if a username is valid. 65 | */ 66 | export function isUsernameValid(username: string): boolean { 67 | return !username.startsWith("-") && 68 | !username.endsWith("-") && 69 | !username.startsWith("_") && 70 | /^[a-zA-Z0-9_-]+$/.test(username) && 71 | !USERNAME_BLOCKLIST.includes(username.toLowerCase()) 72 | } 73 | 74 | 75 | /** 76 | * Ask the fission server to send another verification email to the 77 | * user currently logged in. 78 | * 79 | * Throws if the user is not logged in. 80 | */ 81 | export async function resendVerificationEmail( 82 | endpoints: Endpoints, 83 | crypto: Crypto.Implementation, 84 | reference: Reference.Implementation 85 | ): Promise<{ success: boolean }> { 86 | // We've not implemented an "administer account" resource/ucan, so authenticating 87 | // with any kind of ucan will work server-side 88 | const localUcan = (await reference.repositories.ucans.getAll())[ 0 ] 89 | if (localUcan === null) { 90 | throw "Could not find your local UCAN" 91 | } 92 | 93 | const jwt = Ucan.encode(await Ucan.build({ 94 | audience: await Fission.did(endpoints), 95 | dependencies: { crypto }, 96 | issuer: await DID.ucan(crypto), 97 | proof: localUcan, 98 | potency: null 99 | })) 100 | 101 | const response = await fetch( 102 | Fission.apiUrl(endpoints, "/user/email/resend"), 103 | { 104 | method: "POST", 105 | headers: { 106 | "authorization": `Bearer ${jwt}` 107 | } 108 | } 109 | ) 110 | 111 | return { 112 | success: response.status < 300 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/components/capabilities/implementation.ts: -------------------------------------------------------------------------------- 1 | import { Capabilities } from "../../capabilities.js" 2 | import { Maybe } from "../../common/types.js" 3 | import { Permissions } from "../../permissions.js" 4 | 5 | 6 | export type RequestOptions = { 7 | extraParams?: Record 8 | permissions?: Permissions 9 | returnUrl?: string 10 | } 11 | 12 | export type Implementation = { 13 | collect: () => Promise> 14 | request: (options: RequestOptions) => Promise 15 | } -------------------------------------------------------------------------------- /src/components/capabilities/implementation/fission-lobby-production.ts: -------------------------------------------------------------------------------- 1 | import type { Dependencies } from "./fission-lobby.js" 2 | import type { Implementation } from "../implementation.js" 3 | 4 | import * as FissionLobby from "./fission-lobby.js" 5 | import * as Fission from "../../../common/fission.js" 6 | 7 | 8 | // 🛳 9 | 10 | 11 | export function implementation( 12 | dependencies: Dependencies 13 | ): Implementation { 14 | return FissionLobby.implementation( 15 | Fission.PRODUCTION, 16 | dependencies 17 | ) 18 | } -------------------------------------------------------------------------------- /src/components/capabilities/implementation/fission-lobby-staging.ts: -------------------------------------------------------------------------------- 1 | import type { Dependencies } from "./fission-lobby.js" 2 | import type { Implementation } from "../implementation.js" 3 | 4 | import * as FissionLobby from "./fission-lobby.js" 5 | import * as Fission from "../../../common/fission.js" 6 | 7 | 8 | // 🛳 9 | 10 | 11 | export function implementation( 12 | dependencies: Dependencies 13 | ): Implementation { 14 | return FissionLobby.implementation( 15 | Fission.STAGING, 16 | dependencies 17 | ) 18 | } -------------------------------------------------------------------------------- /src/components/crypto/implementation.ts: -------------------------------------------------------------------------------- 1 | import { SymmAlg } from "keystore-idb/types.js" 2 | 3 | 4 | export { SymmAlg } 5 | 6 | 7 | export type ImplementationOptions = { 8 | exchangeKeyName: string 9 | storeName: string 10 | writeKeyName: string 11 | } 12 | 13 | 14 | export type Implementation = { 15 | aes: { 16 | // Related to AES-GCM, this should be able to decrypt both: 17 | // (a) with the `iv` prefixed in the cipher text (first 16 bytes), and (b) with a given `iv` 18 | decrypt: (encrypted: Uint8Array, key: CryptoKey | Uint8Array, alg: SymmAlg, iv?: Uint8Array) => Promise 19 | 20 | // Related to AES-GCM, this will produce a cipher text with 21 | // a random `iv` prefixed into it. Unless, you provide the `iv` as a parameter. 22 | encrypt: (data: Uint8Array, key: CryptoKey | Uint8Array, alg: SymmAlg, iv?: Uint8Array) => Promise 23 | exportKey: (key: CryptoKey) => Promise 24 | genKey: (alg: SymmAlg) => Promise 25 | } 26 | 27 | did: { 28 | /** 29 | * Using the key type as the record property name (ie. string = key type) 30 | * 31 | * The magic bytes are the `code` found in https://github.com/multiformats/multicodec/blob/master/table.csv 32 | * encoded as a variable integer (more info about that at https://github.com/multiformats/unsigned-varint). 33 | * 34 | * The key type is also found in that table. 35 | * It's the name of the codec minus the `-pub` suffix. 36 | * 37 | * Example 38 | * ------- 39 | * Ed25519 public key 40 | * Key type: "ed25519" 41 | * Magic bytes: [ 0xed, 0x01 ] 42 | */ 43 | keyTypes: Record< 44 | string, { 45 | magicBytes: Uint8Array 46 | verify: (args: VerifyArgs) => Promise 47 | } 48 | > 49 | } 50 | 51 | hash: { 52 | sha256: (bytes: Uint8Array) => Promise 53 | } 54 | 55 | keystore: { 56 | clearStore: () => Promise 57 | decrypt: (encrypted: Uint8Array) => Promise 58 | exportSymmKey: (name: string) => Promise 59 | getAlgorithm: () => Promise // This goes hand in hand with the DID keyTypes record 60 | getUcanAlgorithm: () => Promise 61 | importSymmKey: (key: Uint8Array, name: string) => Promise 62 | keyExists: (keyName: string) => Promise 63 | publicExchangeKey: () => Promise 64 | publicWriteKey: () => Promise 65 | sign: (message: Uint8Array) => Promise 66 | } 67 | 68 | misc: { 69 | randomNumbers: (options: { amount: number }) => Uint8Array 70 | } 71 | 72 | rsa: { 73 | // Used for exchange keys only 74 | decrypt: (data: Uint8Array, privateKey: CryptoKey | Uint8Array) => Promise 75 | encrypt: (message: Uint8Array, publicKey: CryptoKey | Uint8Array) => Promise 76 | exportPublicKey: (key: CryptoKey) => Promise 77 | genKey: () => Promise 78 | } 79 | } 80 | 81 | 82 | export type VerifyArgs = { 83 | message: Uint8Array 84 | publicKey: Uint8Array 85 | signature: Uint8Array 86 | } 87 | -------------------------------------------------------------------------------- /src/components/depot/implementation.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | import { CodecIdentifier } from "../../dag/codecs.js" 3 | 4 | 5 | export type Implementation = { 6 | // Get the data behind a CID 7 | getBlock: (cid: CID) => Promise 8 | getUnixFile: (cid: CID) => Promise 9 | getUnixDirectory: (cid: CID) => Promise 10 | 11 | // Keep data around 12 | putBlock: (data: Uint8Array, codec: CodecIdentifier) => Promise 13 | putChunked: (data: Uint8Array) => Promise 14 | 15 | // Stats 16 | size: (cid: CID) => Promise 17 | } 18 | 19 | export type DirectoryItem = { 20 | isFile: boolean 21 | cid: CID 22 | name: string 23 | size: number 24 | } 25 | 26 | export type PutResult = { 27 | cid: CID 28 | size: number 29 | isFile: boolean 30 | } -------------------------------------------------------------------------------- /src/components/depot/implementation/fission-ipfs-production.ts: -------------------------------------------------------------------------------- 1 | import { Implementation } from "../implementation.js" 2 | 3 | import * as FissionEndpoints from "../../../common/fission.js" 4 | import * as IPFS from "./ipfs-default-pkg.js" 5 | import { Dependencies } from "./ipfs/node.js" 6 | 7 | 8 | // 🛳 9 | 10 | 11 | export async function implementation(dependencies: Dependencies, repoName: string): Promise { 12 | return IPFS.implementation(dependencies, FissionEndpoints.PRODUCTION.server + "/ipfs/peers", repoName) 13 | } -------------------------------------------------------------------------------- /src/components/depot/implementation/fission-ipfs-staging.ts: -------------------------------------------------------------------------------- 1 | import { Implementation } from "../implementation.js" 2 | 3 | import * as FissionEndpoints from "../../../common/fission.js" 4 | import * as IPFS from "./ipfs-default-pkg.js" 5 | import { Dependencies } from "./ipfs/node.js" 6 | 7 | 8 | // 🛳 9 | 10 | 11 | export async function implementation(dependencies: Dependencies, repoName: string): Promise { 12 | return IPFS.implementation(dependencies, FissionEndpoints.STAGING.server + "/ipfs/peers", repoName) 13 | } -------------------------------------------------------------------------------- /src/components/depot/implementation/ipfs-default-pkg.ts: -------------------------------------------------------------------------------- 1 | import type { IPFS } from "ipfs-core-types" 2 | import type { IPFSRepo } from "ipfs-repo" 3 | 4 | import * as Ipfs from "./ipfs/index.js" 5 | import * as IpfsBase from "./ipfs.js" 6 | import { Dependencies } from "./ipfs/node.js" 7 | import { Implementation } from "../implementation.js" 8 | import { Maybe } from "../../../common/types.js" 9 | 10 | 11 | // 🛳 12 | 13 | 14 | export async function implementation( 15 | dependencies: Dependencies, 16 | peersUrl: string, 17 | repoName: string 18 | ): Promise { 19 | let instance: Maybe<{ ipfs: IPFS; repo: IPFSRepo }> = null 20 | 21 | return IpfsBase.implementation(async () => { 22 | if (instance) return instance 23 | 24 | instance = await Ipfs.nodeWithPkg( 25 | dependencies, 26 | await Ipfs.pkgFromCDN(Ipfs.DEFAULT_CDN_URL), 27 | peersUrl, 28 | repoName, 29 | false 30 | ) 31 | 32 | return instance 33 | }) 34 | } -------------------------------------------------------------------------------- /src/components/depot/implementation/ipfs.ts: -------------------------------------------------------------------------------- 1 | import * as uint8arrays from "uint8arrays" 2 | import type { IPFS } from "ipfs-core-types" 3 | import type { IPFSRepo } from "ipfs-repo" 4 | import { CID } from "multiformats/cid" 5 | import { sha256 } from "multiformats/hashes/sha2" 6 | 7 | import * as Codecs from "../../../dag/codecs.js" 8 | import { CodecIdentifier } from "../../../dag/codecs.js" 9 | import { DirectoryItem, Implementation, PutResult } from "../implementation.js" 10 | 11 | 12 | // 🛳 13 | 14 | 15 | export async function implementation( 16 | getIpfs: () => Promise<{ ipfs: IPFS, repo: IPFSRepo }> 17 | ): Promise { 18 | return { 19 | 20 | // GET 21 | 22 | getBlock: async (cid: CID): Promise => { 23 | const { ipfs } = await getIpfs() 24 | return ipfs.block.get(cid) 25 | }, 26 | getUnixDirectory: async (cid: CID): Promise => { 27 | const { ipfs } = await getIpfs() 28 | const entries = [] 29 | 30 | for await (const entry of ipfs.ls(cid)) { 31 | const { name = "", cid, size, type } = entry 32 | 33 | entries.push({ 34 | name, 35 | cid: cid, 36 | size, 37 | isFile: type !== "dir" 38 | }) 39 | } 40 | 41 | return entries 42 | }, 43 | getUnixFile: async (cid: CID): Promise => { 44 | const { ipfs } = await getIpfs() 45 | const chunks = [] 46 | 47 | for await (const chunk of ipfs.cat(cid)) { 48 | chunks.push(chunk) 49 | } 50 | 51 | return uint8arrays.concat(chunks) 52 | }, 53 | 54 | // PUT 55 | 56 | putBlock: async (data: Uint8Array, codecId: CodecIdentifier): Promise => { 57 | const { repo } = await getIpfs() 58 | 59 | const codec = Codecs.getByIdentifier(codecId) 60 | const multihash = await sha256.digest(data) 61 | const cid = CID.createV1(codec.code, multihash) 62 | 63 | await repo.blocks.put( 64 | cid, 65 | data 66 | ) 67 | 68 | return cid 69 | }, 70 | putChunked: async (data: Uint8Array): Promise => { 71 | const { ipfs } = await getIpfs() 72 | const addResult = await ipfs.add(data, { 73 | cidVersion: 1, 74 | hashAlg: "sha2-256", 75 | rawLeaves: true, 76 | wrapWithDirectory: false, 77 | preload: false, 78 | pin: false, 79 | }) 80 | 81 | return { ...addResult, isFile: true } 82 | }, 83 | 84 | // STATS 85 | 86 | size: async (cid: CID) => { 87 | const { ipfs } = await getIpfs() 88 | const stat = await ipfs.files.stat(`/ipfs/${cid}`) 89 | return stat.cumulativeSize 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/components/depot/implementation/ipfs/config.ts: -------------------------------------------------------------------------------- 1 | import type { IPFS } from "ipfs-core-types" 2 | import type { IPFSRepo } from "ipfs-repo" 3 | 4 | import * as ipfsNode from "./node.js" 5 | import { Dependencies } from "../../../../fs/filesystem.js" 6 | import { IPFSPackage } from "./package.js" 7 | 8 | 9 | export const DEFAULT_CDN_URL = "https://unpkg.com/ipfs-core@0.17.0/dist/index.min.js" 10 | 11 | 12 | /** 13 | * Create an IPFS Node given a `IPFSPackage`, 14 | * which you can get from `pkgFromCDN` or `pkgFromBundle`. 15 | */ 16 | export const nodeWithPkg = ( 17 | dependencies: ipfsNode.Dependencies, 18 | pkg: IPFSPackage, 19 | peersUrl: string, 20 | repoName: string, 21 | logging: boolean 22 | ): Promise<{ ipfs: IPFS, repo: IPFSRepo }> => { 23 | return ipfsNode.createAndConnect(dependencies, pkg, peersUrl, repoName, logging) 24 | } 25 | 26 | /** 27 | * Loads ipfs-core from a CDN. 28 | * NOTE: Make sure to cache this URL with a service worker if you want to make your app available offline. 29 | */ 30 | export const pkgFromCDN = async (cdn_url: string): Promise => { 31 | if (!cdn_url) throw new Error("This function requires a URL to a CDN") 32 | return import(/* @vite-ignore *//* webpackIgnore: true */ cdn_url).then(_ => (self as any).IpfsCore as IPFSPackage) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/depot/implementation/ipfs/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./config.js" 2 | export * as node from "./node.js" 3 | -------------------------------------------------------------------------------- /src/components/depot/implementation/ipfs/node/repo.ts: -------------------------------------------------------------------------------- 1 | import * as dagCBOR from "@ipld/dag-cbor" 2 | import * as dagPB from "@ipld/dag-pb" 3 | import * as raw from "multiformats/codecs/raw" 4 | import { createRepo, Datastore, IPFSRepo } from "ipfs-repo" 5 | import { BlockCodec } from "multiformats/codecs/interface" 6 | import { BlockstoreDatastoreAdapter } from "blockstore-datastore-adapter" 7 | import { MemoryDatastore } from "datastore-core/memory" 8 | import { LevelDatastore } from "datastore-level" 9 | 10 | 11 | export function create(repoName: string): IPFSRepo { 12 | const memoryDs = new MemoryDatastore() 13 | 14 | return createRepo( 15 | repoName, 16 | codeOrName => { 17 | const lookup: Record> = { 18 | [ dagPB.code ]: dagPB, 19 | [ dagPB.name ]: dagPB, 20 | [ dagCBOR.code ]: dagCBOR, 21 | [ dagCBOR.name ]: dagCBOR, 22 | [ raw.code ]: raw, 23 | [ raw.name ]: raw, 24 | } 25 | 26 | return Promise.resolve(lookup[ codeOrName ]) 27 | }, { 28 | root: new LevelDatastore(`${repoName}/root`, { prefix: "", version: 2 }), 29 | blocks: new BlockstoreDatastoreAdapter(new LevelDatastore(`${repoName}/blocks`, { prefix: "", version: 2 })), 30 | keys: new LevelDatastore(`${repoName}/keys`, { prefix: "", version: 2 }), 31 | datastore: memoryDs, 32 | pins: new LevelDatastore(`${repoName}/pins`, { prefix: "", version: 2 }), 33 | }, { 34 | repoLock: { 35 | lock: async () => ({ close: async () => { return } }), 36 | locked: async () => false 37 | }, 38 | autoMigrate: false, 39 | } 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/depot/implementation/ipfs/package.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "ipfs-core-types/src/config/index.js" 2 | import type { IPFS } from "ipfs-core-types" 3 | import type { IPFSRepo } from "ipfs-repo" 4 | import type { KeyType } from "@libp2p/interface-keys" 5 | import type { Libp2pOptions } from "libp2p" 6 | import type { PeerId } from "@libp2p/interface-peer-id" 7 | 8 | 9 | /** 10 | * See https://github.com/ipfs/js-ipfs/blob/6be59068cc99c517526bfa123ad475ae05fcbaef/packages/ipfs-core/src/types.ts#L15 for more info. 11 | */ 12 | export type Options = { 13 | config?: Config 14 | init?: { 15 | algorithm?: KeyType 16 | allowNew?: boolean 17 | bits?: number 18 | emptyRepo?: boolean 19 | privateKey?: PeerId | string 20 | profiles?: string[] 21 | } 22 | libp2p?: Partial 23 | pass?: string 24 | preload?: { 25 | enabled?: boolean 26 | cache?: number 27 | addresses?: string[] 28 | } 29 | relay?: { 30 | enabled?: boolean 31 | hop?: { 32 | enabled?: boolean 33 | active?: boolean 34 | } 35 | } 36 | repo?: IPFSRepo 37 | repoAutoMigrate?: boolean 38 | silent?: boolean 39 | } 40 | 41 | 42 | export type IPFSPackage = { 43 | create: (options?: Options) => IPFS 44 | } -------------------------------------------------------------------------------- /src/components/manners/implementation.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats" 2 | 3 | import type { Configuration } from "../../configuration.js" 4 | 5 | import * as Crypto from "../../components/crypto/implementation.js" 6 | import * as Depot from "../../components/depot/implementation.js" 7 | import * as Reference from "../../components/reference/implementation.js" 8 | import * as Storage from "../../components/storage/implementation.js" 9 | 10 | import * as FileSystem from "../../fs/types.js" 11 | 12 | 13 | export type ImplementationOptions = { 14 | configuration: Configuration 15 | } 16 | 17 | 18 | export type DataComponents = { 19 | crypto: Crypto.Implementation 20 | depot: Depot.Implementation 21 | reference: Reference.Implementation 22 | storage: Storage.Implementation 23 | } 24 | 25 | 26 | export type Implementation = { 27 | log: (...args: unknown[]) => void 28 | warn: (...args: unknown[]) => void 29 | 30 | /** 31 | * Configure how the wnfs wasm module should be loaded. 32 | * 33 | * This only has an effect if you're using file systems of version 3 or higher. 34 | * 35 | * By default this loads the required version of the wasm wnfs module from unpkg.com. 36 | */ 37 | wnfsWasmLookup: (wnfsVersion: string) => Promise 38 | 39 | /** 40 | * File system. 41 | */ 42 | fileSystem: { 43 | /** 44 | * Various file system hooks. 45 | */ 46 | hooks: { 47 | afterLoadExisting: (fs: FileSystem.API, account: FileSystem.AssociatedIdentity, dataComponents: DataComponents) => Promise 48 | afterLoadNew: (fs: FileSystem.API, account: FileSystem.AssociatedIdentity, dataComponents: DataComponents) => Promise 49 | beforeLoadExisting: (cid: CID, account: FileSystem.AssociatedIdentity, dataComponents: DataComponents) => Promise 50 | beforeLoadNew: (account: FileSystem.AssociatedIdentity, dataComponents: DataComponents) => Promise 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/components/manners/implementation/base.ts: -------------------------------------------------------------------------------- 1 | import type { Implementation, ImplementationOptions } from "../implementation.js" 2 | import * as FileSystem from "../../../fs/types.js" 3 | 4 | 5 | // 🛳 6 | 7 | 8 | export function implementation(opts: ImplementationOptions): Implementation { 9 | return { 10 | log: opts.configuration.debug ? console.log : () => { }, 11 | warn: opts.configuration.debug ? console.warn : () => { }, 12 | 13 | // WASM 14 | wnfsWasmLookup: wnfsVersion => fetch(`https://unpkg.com/wnfs@${wnfsVersion}/wasm_wnfs_bg.wasm`), 15 | 16 | // File system 17 | fileSystem: { 18 | hooks: { 19 | afterLoadExisting: async () => { }, 20 | afterLoadNew: async (fs: FileSystem.API) => { await fs.publish() }, 21 | beforeLoadExisting: async () => { }, 22 | beforeLoadNew: async () => { }, 23 | }, 24 | }, 25 | } 26 | } -------------------------------------------------------------------------------- /src/components/reference/dns-over-https.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Lookup a DNS TXT record. 3 | * 4 | * Race lookups to Google & Cloudflare, return the first to finish 5 | * 6 | * @param domain The domain to get the TXT record from. 7 | * @returns Contents of the TXT record. 8 | */ 9 | export async function lookupTxtRecord(domain: string): Promise { 10 | return Promise.any([ 11 | googleLookup(domain), 12 | cloudflareLookup(domain) 13 | ]) 14 | } 15 | 16 | /** 17 | * Lookup DNS TXT record using Google DNS-over-HTTPS 18 | * 19 | * @param domain The domain to get the TXT record from. 20 | * @returns Contents of the TXT record. 21 | */ 22 | export async function googleLookup(domain: string): Promise { 23 | return dnsOverHttps(`https://dns.google/resolve?name=${domain}&type=txt`) 24 | } 25 | 26 | /** 27 | * Lookup DNS TXT record using Cloudflare DNS-over-HTTPS 28 | * 29 | * @param domain The domain to get the TXT record from. 30 | * @returns Contents of the TXT record. 31 | */ 32 | export function cloudflareLookup(domain: string): Promise { 33 | return dnsOverHttps(`https://cloudflare-dns.com/dns-query?name=${domain}&type=txt`) 34 | } 35 | 36 | /** 37 | * Lookup a DNS TXT record. 38 | * 39 | * If there are multiple records, they will be joined together. 40 | * Records are sorted by a decimal prefix before they are joined together. 41 | * Prefixes have a format of `001;` → `999;` 42 | * 43 | * @param url The DNS-over-HTTPS endpoint to hit. 44 | * @returns Contents of the TXT record. 45 | */ 46 | export function dnsOverHttps(url: string): Promise { 47 | return fetch(url, { 48 | headers: { 49 | "accept": "application/dns-json" 50 | } 51 | }) 52 | .then(r => r.json()) 53 | .then(r => { 54 | if (r.Answer) { 55 | // Remove double-quotes from beginning and end of the resulting string (if present) 56 | const answers: Array = r.Answer.map((a: { data: string }) => { 57 | return (a.data || "").replace(/^"+|"+$/g, "") 58 | }) 59 | 60 | // Sort by prefix, if prefix is present, 61 | // and then add the answers together as one string. 62 | if (answers[ 0 ][ 3 ] === ";") { 63 | return answers 64 | .sort((a, b) => a.slice(0, 4).localeCompare(b.slice(0, 4))) 65 | .map(a => a.slice(4)) 66 | .join("") 67 | 68 | } else { 69 | return answers.join("") 70 | 71 | } 72 | 73 | } else { 74 | return null 75 | 76 | } 77 | }) 78 | } 79 | 80 | /** 81 | * Lookup a DNSLink. 82 | * 83 | * @param domain The domain to get the DNSLink from. 84 | * @returns Contents of the DNSLink with the "ipfs/" prefix removed. 85 | */ 86 | export async function lookupDnsLink(domain: string): Promise { 87 | const txt = await lookupTxtRecord( 88 | domain.startsWith("_dnslink.") 89 | ? domain 90 | : `_dnslink.${domain}` 91 | ) 92 | 93 | return txt && !txt.includes("/ipns/") 94 | ? txt.replace(/^dnslink=/, "").replace(/^\/ipfs\//, "") 95 | : null 96 | } 97 | -------------------------------------------------------------------------------- /src/components/reference/implementation.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | 3 | import * as CIDLog from "../../repositories/cid-log.js" 4 | import * as Ucans from "../../repositories/ucans.js" 5 | 6 | import { Ucan } from "../../ucan/types.js" 7 | 8 | 9 | export type Implementation = { 10 | dataRoot: { 11 | domain: (username: string) => string // DNSLink domain 12 | lookup: (username: string) => Promise 13 | update: (cid: CID, proof: Ucan) => Promise<{ success: boolean }> 14 | } 15 | didRoot: { 16 | lookup: (username: string) => Promise 17 | } 18 | dns: { 19 | lookupDnsLink: (domain: string) => Promise 20 | lookupTxtRecord: (domain: string) => Promise 21 | } 22 | repositories: { 23 | cidLog: CIDLog.Repo 24 | ucans: Ucans.Repo 25 | } 26 | } -------------------------------------------------------------------------------- /src/components/reference/implementation/base.ts: -------------------------------------------------------------------------------- 1 | import { Implementation } from "../implementation" 2 | 3 | import * as Crypto from "../../../components/crypto/implementation.js" 4 | import * as Manners from "../../../components/manners/implementation.js" 5 | import * as Storage from "../../../components/storage/implementation.js" 6 | 7 | import * as CIDLogRepo from "../../../repositories/cid-log.js" 8 | import * as UcansRepo from "../../../repositories/ucans.js" 9 | 10 | import * as DID from "../../../did/index.js" 11 | import * as DOH from "../dns-over-https.js" 12 | import * as Ucan from "../../../ucan/index.js" 13 | 14 | 15 | // 🧩 16 | 17 | 18 | export type Dependencies = { 19 | crypto: Crypto.Implementation 20 | manners: Manners.Implementation 21 | storage: Storage.Implementation 22 | } 23 | 24 | 25 | 26 | // 🛠 27 | 28 | 29 | export async function didRootLookup(dependencies: Dependencies, username: string) { 30 | const maybeUcan: string | null = await dependencies.storage.getItem(dependencies.storage.KEYS.ACCOUNT_UCAN) 31 | return maybeUcan ? Ucan.rootIssuer(maybeUcan) : await DID.write(dependencies.crypto) 32 | } 33 | 34 | 35 | 36 | // 🛳 37 | 38 | 39 | export async function implementation(dependencies: Dependencies): Promise { 40 | return { 41 | dataRoot: { 42 | domain: () => { throw new Error("Not implemented") }, 43 | lookup: () => { throw new Error("Not implemented") }, 44 | update: () => { throw new Error("Not implemented") } 45 | }, 46 | didRoot: { 47 | lookup: (...args) => didRootLookup(dependencies, ...args) 48 | }, 49 | dns: { 50 | lookupDnsLink: DOH.lookupDnsLink, 51 | lookupTxtRecord: DOH.lookupTxtRecord, 52 | }, 53 | repositories: { 54 | cidLog: await CIDLogRepo.create({ storage: dependencies.storage }), 55 | ucans: await UcansRepo.create({ storage: dependencies.storage }) 56 | }, 57 | } 58 | } -------------------------------------------------------------------------------- /src/components/reference/implementation/fission-base.ts: -------------------------------------------------------------------------------- 1 | import type { Dependencies } from "./base.js" 2 | import type { Endpoints } from "../../../common/fission.js" 3 | import type { Implementation } from "../implementation.js" 4 | 5 | import * as Base from "./base.js" 6 | import * as DataRoot from "./fission/data-root.js" 7 | import * as DID from "./fission/did.js" 8 | 9 | 10 | // 🛳 11 | 12 | 13 | export async function implementation(endpoints: Endpoints, dependencies: Dependencies): Promise { 14 | const base = await Base.implementation(dependencies) 15 | 16 | base.dataRoot.domain = (username: string) => `${username}.files.${endpoints.userDomain}` 17 | base.dataRoot.lookup = (...args) => DataRoot.lookup(endpoints, dependencies, ...args) 18 | base.dataRoot.update = (...args) => DataRoot.update(endpoints, dependencies, ...args) 19 | base.didRoot.lookup = (...args) => DID.root(endpoints, ...args) 20 | 21 | return base 22 | } -------------------------------------------------------------------------------- /src/components/reference/implementation/fission-production.ts: -------------------------------------------------------------------------------- 1 | import type { Dependencies } from "./base.js" 2 | import type { Implementation } from "../implementation.js" 3 | 4 | import * as FissionBase from "./fission-base.js" 5 | import * as FissionEndpoints from "../../../common/fission.js" 6 | 7 | 8 | export function implementation(dependencies: Dependencies): Promise { 9 | return FissionBase.implementation(FissionEndpoints.PRODUCTION, dependencies) 10 | } -------------------------------------------------------------------------------- /src/components/reference/implementation/fission-staging.ts: -------------------------------------------------------------------------------- 1 | import type { Dependencies } from "./base.js" 2 | import type { Implementation } from "../implementation.js" 3 | 4 | import * as FissionBase from "./fission-base.js" 5 | import * as FissionEndpoints from "../../../common/fission.js" 6 | 7 | 8 | export function implementation(dependencies: Dependencies): Promise { 9 | return FissionBase.implementation(FissionEndpoints.STAGING, dependencies) 10 | } -------------------------------------------------------------------------------- /src/components/reference/implementation/fission/data-root.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | 3 | import * as DID from "../../../../did/index.js" 4 | import * as DNS from "../../dns-over-https.js" 5 | import * as Fission from "../../../../common/fission.js" 6 | import * as TypeChecks from "../../../../common/type-checks.js" 7 | import * as Ucan from "../../../../ucan/index.js" 8 | 9 | import { decodeCID } from "../../../../common/cid.js" 10 | import { Dependencies } from "../base.js" 11 | 12 | 13 | /** 14 | * Get the CID of a user's data root. 15 | * First check Fission server, then check DNS 16 | * 17 | * @param username The username of the user that we want to get the data root of. 18 | */ 19 | export async function lookup( 20 | endpoints: Fission.Endpoints, 21 | dependencies: Dependencies, 22 | username: string 23 | ): Promise { 24 | const maybeRoot = await lookupOnFisson(endpoints, dependencies, username) 25 | if (!maybeRoot) return null 26 | if (maybeRoot !== null) return maybeRoot 27 | 28 | try { 29 | const cid = await DNS.lookupDnsLink(username + ".files." + endpoints.userDomain) 30 | return !cid ? null : decodeCID(cid) 31 | } catch (err) { 32 | console.error(err) 33 | throw new Error("Could not locate user root in DNS") 34 | } 35 | } 36 | 37 | /** 38 | * Get the CID of a user's data root from the Fission server. 39 | * 40 | * @param username The username of the user that we want to get the data root of. 41 | */ 42 | export async function lookupOnFisson( 43 | endpoints: Fission.Endpoints, 44 | dependencies: Dependencies, 45 | username: string 46 | ): Promise { 47 | try { 48 | const resp = await fetch( 49 | Fission.apiUrl(endpoints, `user/data/${username}`), 50 | { cache: "reload" } // don't use cache 51 | ) 52 | const cid = await resp.json() 53 | return decodeCID(cid) 54 | 55 | } catch (err) { 56 | dependencies.manners.log( 57 | "Could not locate user root on Fission server: ", 58 | TypeChecks.hasProp(err, "toString") ? (err as any).toString() : err 59 | ) 60 | return null 61 | 62 | } 63 | } 64 | 65 | /** 66 | * Update a user's data root. 67 | * 68 | * @param cid The CID of the data root. 69 | * @param proof The proof to use in the UCAN sent to the API. 70 | */ 71 | export async function update( 72 | endpoints: Fission.Endpoints, 73 | dependencies: Dependencies, 74 | cidInstance: CID, 75 | proof: Ucan.Ucan 76 | ): Promise<{ success: boolean }> { 77 | const cid = cidInstance.toString() 78 | 79 | // Debug 80 | dependencies.manners.log("🌊 Updating your DNSLink:", cid) 81 | 82 | // Make API call 83 | return await fetchWithRetry(Fission.apiUrl(endpoints, `user/data/${cid}`), { 84 | headers: async () => { 85 | const jwt = Ucan.encode(await Ucan.build({ 86 | dependencies: dependencies, 87 | 88 | audience: await Fission.did(endpoints), 89 | issuer: await DID.ucan(dependencies.crypto), 90 | potency: "APPEND", 91 | proof: Ucan.encode(proof), 92 | 93 | // TODO: Waiting on API change. 94 | // Should be `username.fission.name/*` 95 | resource: proof.payload.rsc 96 | })) 97 | 98 | return { "authorization": `Bearer ${jwt}` } 99 | }, 100 | retries: 100, 101 | retryDelay: 5000, 102 | retryOn: [ 502, 503, 504 ], 103 | 104 | }, { 105 | method: "PUT" 106 | 107 | }).then((response: Response) => { 108 | if (response.status < 300) dependencies.manners.log("🪴 DNSLink updated:", cid) 109 | else dependencies.manners.log("🔥 Failed to update DNSLink for:", cid) 110 | return { success: response.status < 300 } 111 | 112 | }).catch(err => { 113 | dependencies.manners.log("🔥 Failed to update DNSLink for:", cid) 114 | console.error(err) 115 | return { success: false } 116 | 117 | }) 118 | } 119 | 120 | 121 | 122 | // ㊙️ 123 | 124 | 125 | type RetryOptions = { 126 | headers: () => Promise<{ [ _: string ]: string }> 127 | retries: number 128 | retryDelay: number 129 | retryOn: Array 130 | } 131 | 132 | 133 | async function fetchWithRetry( 134 | url: string, 135 | retryOptions: RetryOptions, 136 | fetchOptions: RequestInit, 137 | retry = 0 138 | ): Promise { 139 | const headers = await retryOptions.headers() 140 | const response = await fetch(url, { 141 | ...fetchOptions, 142 | headers: { ...fetchOptions.headers, ...headers } 143 | }) 144 | 145 | if (retryOptions.retryOn.includes(response.status)) { 146 | if (retry < retryOptions.retries) { 147 | return await new Promise((resolve, reject) => setTimeout( 148 | () => fetchWithRetry(url, retryOptions, fetchOptions, retry + 1).then(resolve, reject), 149 | retryOptions.retryDelay 150 | )) 151 | } else { 152 | throw new Error("Too many retries for fetch") 153 | } 154 | } 155 | 156 | return response 157 | } 158 | -------------------------------------------------------------------------------- /src/components/reference/implementation/fission/did.ts: -------------------------------------------------------------------------------- 1 | import * as DOH from "../../dns-over-https.js" 2 | import * as Fission from "../../../../common/fission.js" 3 | 4 | 5 | /** 6 | * Get the root write-key DID for a user. 7 | * Stored at `_did.${username}.${endpoints.user}` 8 | */ 9 | export async function root( 10 | endpoints: Fission.Endpoints, 11 | username: string 12 | ): Promise { 13 | try { 14 | const maybeDid = await DOH.lookupTxtRecord(`_did.${username}.${endpoints.userDomain}`) 15 | if (maybeDid !== null) return maybeDid 16 | } catch (_err) { 17 | // lookup failed 18 | } 19 | 20 | throw new Error("Could not locate user DID in DNS.") 21 | } 22 | -------------------------------------------------------------------------------- /src/components/storage/implementation.ts: -------------------------------------------------------------------------------- 1 | export type ImplementationOptions = { 2 | name: string 3 | } 4 | 5 | 6 | export type Implementation = { 7 | KEYS: { 8 | ACCOUNT_UCAN: string 9 | CID_LOG: string 10 | UCANS: string 11 | SESSION: string 12 | } 13 | 14 | getItem: (key: string) => Promise 15 | setItem: (key: string, val: T) => Promise 16 | removeItem: (key: string) => Promise 17 | clear: () => Promise 18 | } 19 | -------------------------------------------------------------------------------- /src/components/storage/implementation/browser.ts: -------------------------------------------------------------------------------- 1 | import localforage from "localforage" 2 | 3 | import { KEYS } from "./keys/default.js" 4 | import { Implementation, ImplementationOptions } from "../implementation.js" 5 | import { assertBrowser } from "../../../common/browser.js" 6 | 7 | 8 | export function getItem(db: LocalForage, key: string): Promise { 9 | assertBrowser("storage.getItem") 10 | return db.getItem(key) 11 | } 12 | 13 | export function setItem(db: LocalForage, key: string, val: T): Promise { 14 | assertBrowser("storage.setItem") 15 | return db.setItem(key, val) 16 | } 17 | 18 | export function removeItem(db: LocalForage, key: string): Promise { 19 | assertBrowser("storage.removeItem") 20 | return db.removeItem(key) 21 | } 22 | 23 | export async function clear(db: LocalForage): Promise { 24 | assertBrowser("storage.clear") 25 | return db.clear() 26 | } 27 | 28 | 29 | 30 | // 🛳 31 | 32 | 33 | export function implementation({ name }: ImplementationOptions): Implementation { 34 | const db = localforage.createInstance({ name }) 35 | 36 | return { 37 | KEYS, 38 | 39 | getItem: (...args) => getItem(db, ...args), 40 | setItem: (...args) => setItem(db, ...args), 41 | removeItem: (...args) => removeItem(db, ...args), 42 | clear: (...args) => clear(db, ...args), 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/storage/implementation/keys/default.ts: -------------------------------------------------------------------------------- 1 | import { Implementation } from "../../implementation.js" 2 | 3 | 4 | export const KEYS: Implementation[ "KEYS" ] = { 5 | ACCOUNT_UCAN: "account-ucan", 6 | CID_LOG: "cid-log", 7 | SESSION: "session", 8 | UCANS: "permissioned-ucans", 9 | } -------------------------------------------------------------------------------- /src/components/storage/implementation/memory.ts: -------------------------------------------------------------------------------- 1 | import { KEYS } from "./keys/default.js" 2 | import { Implementation, ImplementationOptions } from "../implementation.js" 3 | 4 | 5 | export async function getItem(mem: Record, key: string): Promise { 6 | return mem[ key ] 7 | } 8 | 9 | export async function setItem(mem: Record, key: string, val: T): Promise { 10 | mem[ key ] = val 11 | return val 12 | } 13 | 14 | export async function removeItem(mem: Record, key: string): Promise { 15 | delete mem[ key ] 16 | } 17 | 18 | export async function clear(mem: Record): Promise { 19 | for (const k in mem) delete mem[ k ] 20 | } 21 | 22 | 23 | 24 | // 🛳 25 | 26 | 27 | export function implementation(): Implementation { 28 | const mem: Record = {} 29 | 30 | return { 31 | KEYS, 32 | 33 | getItem: (...args) => getItem(mem, ...args), 34 | setItem: (...args) => setItem(mem, ...args), 35 | removeItem: (...args) => removeItem(mem, ...args), 36 | clear: (...args) => clear(mem, ...args), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/configuration.ts: -------------------------------------------------------------------------------- 1 | import { AppInfo } from "./appInfo.js" 2 | import { isString } from "./common/type-checks.js" 3 | import { appId, Permissions, ROOT_FILESYSTEM_PERMISSIONS } from "./permissions.js" 4 | 5 | 6 | // CONFIGURATION 7 | 8 | 9 | export type Configuration = { 10 | namespace: string | AppInfo 11 | 12 | /** 13 | * Enable debug mode. 14 | * 15 | * @default false 16 | */ 17 | debug?: boolean 18 | 19 | /** 20 | * Debugging settings. 21 | */ 22 | debugging?: { 23 | /** 24 | * Should I emit window post messages with session and filesystem information? 25 | * 26 | * @default true 27 | */ 28 | emitWindowPostMessages?: boolean 29 | 30 | /** 31 | * Should I add programs to the global context while in debugging mode? 32 | * 33 | * @default true 34 | */ 35 | injectIntoGlobalContext?: boolean 36 | } 37 | 38 | /** 39 | * File system settings. 40 | */ 41 | fileSystem?: { 42 | /** 43 | * Should I load the filesystem immediately? 44 | * 45 | * @default true 46 | */ 47 | loadImmediately?: boolean 48 | 49 | /** 50 | * Set the file system version. 51 | * 52 | * This will only affect new file systems created. 53 | * Existing file systems (whether loaded from another device or loaded locally) continue 54 | * using the same version. 55 | * If you're looking to migrate an existing file system to a new file system version, 56 | * please look for migration tooling. 57 | */ 58 | version?: string 59 | } 60 | 61 | /** 62 | * Permissions to ask a root authority. 63 | */ 64 | permissions?: Permissions 65 | 66 | /** 67 | * Configure messages that the ODD SDK sends to users. 68 | * 69 | * `versionMismatch.newer` is shown when the ODD SDK detects 70 | * that the user's filesystem is newer than what this version of the ODD SDK supports. 71 | * `versionMismatch.older` is shown when the ODD SDK detects that the user's 72 | * filesystem is older than what this version of the ODD SDK supports. 73 | */ 74 | userMessages?: UserMessages 75 | } 76 | 77 | 78 | 79 | // PIECES 80 | 81 | 82 | export type UserMessages = { 83 | versionMismatch: { 84 | newer(version: string): Promise 85 | older(version: string): Promise 86 | } 87 | } 88 | 89 | 90 | 91 | // 🛠 92 | 93 | 94 | export function addRootFileSystemPermissions(config: Configuration): Configuration { 95 | return { ...config, permissions: { ...config.permissions, ...ROOT_FILESYSTEM_PERMISSIONS } } 96 | } 97 | 98 | 99 | /** 100 | * Generate a namespace string based on a configuration. 101 | */ 102 | export function namespace(config: Configuration): string { 103 | return isString(config.namespace) ? config.namespace : appId(config.namespace) 104 | } -------------------------------------------------------------------------------- /src/dag/codecs.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | 3 | import * as DagCBOR from "@ipld/dag-cbor" 4 | import * as DagPB from "@ipld/dag-pb" 5 | import * as Raw from "multiformats/codecs/raw" 6 | 7 | import { BlockCodec } from "multiformats/codecs/interface" 8 | import { CID } from "multiformats/cid" 9 | 10 | 11 | // 🧩 12 | 13 | 14 | export type CodecIdentifier = ( 15 | typeof DagCBOR.code | typeof DagCBOR.name | 16 | typeof DagPB.code | typeof DagPB.name | 17 | typeof Raw.code | typeof Raw.name 18 | ) 19 | 20 | 21 | 22 | // 🏔 23 | 24 | 25 | export const BY_NAME: Record> = { 26 | [ DagPB.name ]: DagPB, 27 | [ DagCBOR.name ]: DagCBOR, 28 | [ Raw.name ]: Raw, 29 | } 30 | 31 | export const BY_CODE: Record> = { 32 | [ DagPB.code ]: DagPB, 33 | [ DagCBOR.code ]: DagCBOR, 34 | [ Raw.code ]: Raw, 35 | } 36 | 37 | export function getByCode(code: number): BlockCodec { 38 | const codec = BY_CODE[ code ] 39 | if (!codec) throw new Error(`No codec was registered for the code: ${numberHex(code)}. Is it part of the multicodec table (https://github.com/multiformats/multicodec/blob/master/table.csv)?`) 40 | return codec 41 | } 42 | 43 | export function getByName(name: string): BlockCodec { 44 | const codec = BY_NAME[ name ] 45 | if (!codec) throw new Error(`No codec was registered for the name: ${name}`) 46 | return codec 47 | } 48 | 49 | export function getByIdentifier(id: CodecIdentifier): BlockCodec { 50 | if (typeof id === "string") return getByName(id) 51 | return getByCode(id) 52 | } 53 | 54 | 55 | 56 | // 🛠 57 | 58 | 59 | export function expect(codecId: CodecIdentifier, cid: CID): void { 60 | const codec = getByIdentifier(codecId) 61 | 62 | if (cid.code !== codec.code) { 63 | const cidCodec = getByCode(cid.code) 64 | throw new Error(`Expected a ${codec.name} CID, found a ${cidCodec.name} CID instead.`) 65 | } 66 | } 67 | 68 | 69 | export function isIdentifier(codeOrName: number | string): codeOrName is CodecIdentifier { 70 | return typeof codeOrName === "string" ? !!BY_NAME[ codeOrName ] : !!BY_CODE[ codeOrName ] 71 | } 72 | 73 | 74 | export function numberHex(num: number): string { 75 | const codeUint8Array = new Uint8Array(4) 76 | const numberByteView = new DataView(codeUint8Array.buffer) 77 | numberByteView.setUint32(0, num) 78 | const hex = Uint8arrays.toString(codeUint8Array, "hex") 79 | const trimmed = hex.replace(/^(00)*/, "") 80 | return `0x${trimmed}` 81 | } -------------------------------------------------------------------------------- /src/dag/index.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | 3 | import * as DagCBOR from "@ipld/dag-cbor" 4 | import * as DagPB from "@ipld/dag-pb" 5 | 6 | import * as Codecs from "./codecs.js" 7 | import * as Depot from "../components/depot/implementation.js" 8 | 9 | 10 | // CONSTANTS 11 | 12 | 13 | // These bytes in the "data" field of a DAG-PB node indicate that the node is an IPLD DAG Node 14 | export const PB_IPLD_DATA = new Uint8Array([ 8, 1 ]) 15 | 16 | 17 | 18 | // BYTES 19 | 20 | 21 | export function fromBytes( 22 | storeCodecId: Codecs.CodecIdentifier, 23 | bytes: Uint8Array 24 | ): unknown { 25 | const storeCodec = Codecs.getByIdentifier(storeCodecId) 26 | return storeCodec.decode(bytes) 27 | } 28 | 29 | export function toBytes( 30 | storeCodecId: Codecs.CodecIdentifier, 31 | dagNode: unknown 32 | ): Uint8Array { 33 | const storeCodec = Codecs.getByIdentifier(storeCodecId) 34 | return storeCodec.encode(dagNode) 35 | } 36 | 37 | 38 | 39 | // GET 40 | 41 | 42 | export async function get(depot: Depot.Implementation, cid: CID): Promise { 43 | const codec = Codecs.getByCode(cid.code) 44 | return codec.decode(await depot.getBlock(cid)) 45 | } 46 | 47 | export async function getCBOR(depot: Depot.Implementation, cid: CID): Promise { 48 | Codecs.expect(DagCBOR.code, cid) 49 | return DagCBOR.decode(await depot.getBlock(cid)) 50 | } 51 | 52 | export async function getPB(depot: Depot.Implementation, cid: CID): Promise { 53 | Codecs.expect(DagPB.code, cid) 54 | return DagPB.decode(await depot.getBlock(cid)) 55 | } 56 | 57 | 58 | 59 | // PUT 60 | 61 | 62 | export function putPB(depot: Depot.Implementation, links: DagPB.PBLink[]): Promise { 63 | const node = DagPB.createNode(PB_IPLD_DATA, links) 64 | return depot.putBlock(DagPB.encode(node), DagPB.code) 65 | } -------------------------------------------------------------------------------- /src/did/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./local.js" 2 | export * from "./transformers.js" 3 | -------------------------------------------------------------------------------- /src/did/local.ts: -------------------------------------------------------------------------------- 1 | import * as Crypto from "../components/crypto/implementation.js" 2 | 3 | import { publicKeyToDid } from "./transformers.js" 4 | 5 | 6 | /** 7 | * Create a DID based on the exchange key-pair. 8 | */ 9 | export async function exchange(crypto: Crypto.Implementation): Promise { 10 | const pubKey = await crypto.keystore.publicExchangeKey() 11 | const ksAlg = await crypto.keystore.getAlgorithm() 12 | 13 | return publicKeyToDid( 14 | crypto, 15 | pubKey, 16 | ksAlg 17 | ) 18 | } 19 | 20 | /** 21 | * Create a DID based on the write key-pair. 22 | */ 23 | export async function write(crypto: Crypto.Implementation): Promise { 24 | const pubKey = await crypto.keystore.publicWriteKey() 25 | const ksAlg = await crypto.keystore.getAlgorithm() 26 | 27 | return publicKeyToDid( 28 | crypto, 29 | pubKey, 30 | ksAlg 31 | ) 32 | } 33 | /** 34 | * Alias `exchange` to `sharing` 35 | */ 36 | export { exchange as sharing } 37 | 38 | /** 39 | * Alias `write` to `agent` 40 | */ 41 | export { write as agent } 42 | 43 | /** 44 | * Alias `write` to `ucan` 45 | */ 46 | export { write as ucan } -------------------------------------------------------------------------------- /src/did/transformers.ts: -------------------------------------------------------------------------------- 1 | import * as uint8arrays from "uint8arrays" 2 | import * as Crypto from "../components/crypto/implementation.js" 3 | 4 | import { BASE58_DID_PREFIX, hasPrefix } from "./util.js" 5 | 6 | 7 | /** 8 | * Convert a base64 public key to a DID (did:key). 9 | */ 10 | export function publicKeyToDid( 11 | crypto: Crypto.Implementation, 12 | publicKey: Uint8Array, 13 | keyType: string 14 | ): string { 15 | // Prefix public-write key 16 | const prefix = crypto.did.keyTypes[ keyType ]?.magicBytes 17 | if (prefix === null) { 18 | throw new Error(`Key type '${keyType}' not supported, available types: ${Object.keys(crypto.did.keyTypes).join(", ")}`) 19 | } 20 | 21 | const prefixedBuf = uint8arrays.concat([ prefix, publicKey ]) 22 | 23 | // Encode prefixed 24 | return BASE58_DID_PREFIX + uint8arrays.toString(prefixedBuf, "base58btc") 25 | } 26 | 27 | /** 28 | * Convert a DID (did:key) to a base64 public key. 29 | */ 30 | export function didToPublicKey(crypto: Crypto.Implementation, did: string): { 31 | publicKey: Uint8Array 32 | type: string 33 | } { 34 | if (!did.startsWith(BASE58_DID_PREFIX)) { 35 | throw new Error("Please use a base58-encoded DID formatted `did:key:z...`") 36 | } 37 | 38 | const didWithoutPrefix = did.substr(BASE58_DID_PREFIX.length) 39 | const magicalBuf = uint8arrays.fromString(didWithoutPrefix, "base58btc") 40 | const result = Object.entries(crypto.did.keyTypes).find( 41 | ([ _key, attr ]) => hasPrefix(magicalBuf, attr.magicBytes) 42 | ) 43 | 44 | if (!result) { 45 | throw new Error("Unsupported key algorithm.") 46 | } 47 | 48 | return { 49 | publicKey: magicalBuf.slice(result[ 1 ].magicBytes.length), 50 | type: result[ 0 ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/did/util.ts: -------------------------------------------------------------------------------- 1 | import { arrbufs } from "../common/index.js" 2 | 3 | 4 | export const BASE58_DID_PREFIX = "did:key:z" 5 | 6 | 7 | /** 8 | * Determines if an ArrayBuffer has a given indeterminate length-prefix. 9 | */ 10 | export const hasPrefix = (prefixedKey: ArrayBuffer, prefix: ArrayBuffer): boolean => { 11 | return arrbufs.equal(prefix, prefixedKey.slice(0, prefix.byteLength)) 12 | } 13 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "./common/cid.js" 2 | import { EventEmitter } from "./common/event-emitter.js" 3 | import { DistinctivePath, Partition, Partitioned } from "./path/index.js" 4 | 5 | 6 | export { EventEmitter, EventEmitter as Emitter } 7 | 8 | 9 | /** 10 | * Events interface. 11 | * 12 | * Subscribe to events using `on` and unsubscribe using `off`, 13 | * alternatively you can use `addListener` and `removeListener`. 14 | * 15 | * ```ts 16 | * program.on("fileSystem:local-change", ({ path, root }) => { 17 | * console.log("The file system has changed locally 🔔") 18 | * console.log("Changed path:", path) 19 | * console.log("New data root CID:", root) 20 | * }) 21 | * 22 | * program.off("fileSystem:publish") 23 | * ``` 24 | */ 25 | export type ListenTo = Pick< 26 | EventEmitter, 27 | "addListener" | "removeListener" | "on" | "off" 28 | > 29 | 30 | 31 | export type FileSystem = { 32 | "fileSystem:local-change": { root: CID; path: DistinctivePath> } 33 | "fileSystem:publish": { root: CID } 34 | } 35 | 36 | 37 | export type Session = { 38 | "session:create": { session: S } 39 | "session:destroy": { username: string } 40 | } 41 | 42 | 43 | export type All = FileSystem & Session 44 | 45 | 46 | export function createEmitter(): EventEmitter { 47 | return new EventEmitter() 48 | } 49 | 50 | 51 | export function listenTo(emitter: EventEmitter): ListenTo { 52 | return { 53 | addListener: emitter.addListener.bind(emitter), 54 | removeListener: emitter.removeListener.bind(emitter), 55 | on: emitter.on.bind(emitter), 56 | off: emitter.off.bind(emitter), 57 | } 58 | } 59 | 60 | 61 | export function merge(a: EventEmitter, b: EventEmitter): EventEmitter { 62 | const merged = createEmitter() 63 | const aEmit = a.emit 64 | const bEmit = b.emit 65 | 66 | a.emit = (eventName: K, event: (A & B)[ K ]) => { 67 | aEmit.call(a, eventName, event) 68 | merged.emit(eventName, event) 69 | } 70 | 71 | b.emit = (eventName: K, event: (A & B)[ K ]) => { 72 | bEmit.call(b, eventName, event) 73 | merged.emit(eventName, event) 74 | } 75 | 76 | return merged 77 | } -------------------------------------------------------------------------------- /src/fs/README.md: -------------------------------------------------------------------------------- 1 | # FileSystem 2 | 3 | Below is a brief overview of the different types used for the Fission FileSystem 4 | 5 | ## Interfaces 6 | 7 | ### Tree / File vs HeaderTree / HeaderFile 8 | `Tree` / `File` describe the main interfaces for trees and files. `HeaderTree` / `HeaderFile` inherit from `Tree` / `File` but include extra methods for dealing with header options (metadata, pins, caching, etc). 9 | 10 | ## Classes 11 | 12 | ### BaseTree / BaseFile 13 | These are abstract classes that implement methods that are common to all trees and files: `ls`, `mkdir`, `cat`, `add`, `rm`, `get`. These classes implement the `Tree` & `File` interfaces respectively. 14 | 15 | ### BareTree / BareFile 16 | These are isomorphic to IPFS DAG objects, but contain a wrapper so that they share a common interface with our custom Tree nodes. These are all public, and implement the `Tree` interface. 17 | 18 | ### PublicTree / PublicFile 19 | This is v1.0.0 of the Fission FileSystem. It uses a 2-layer approach where each directory actually points to a "header" directory which incldues all of the metadata and FFS features for folders & files as well as an `userland` link that points to the actual tree or file. Each directory contains a `skeleton` of the directory structure beneath it. This allows you to make one trip to the network and derive the entire skeleton of a file system 20 | 21 | ### PrivateTree / PrivateFile 22 | These inherit from `PublicTree` / `PublicFile`, but each node has a `parentKey` and an `ownKey`. where the folder itself is encrypted with the `parentKey` and `userland` & it's decendents are encrypted with `ownKey` 23 | 24 | ### Inheritance structure 25 | As additional versions of the FS are added, the inheritance structure will look like: 26 | ``` 27 | publicV1.0.0 -> privateV1.0.0 28 | | 29 | v 30 | publicV1.0.1 -> privateV1.0.1 31 | | 32 | v 33 | publicV1.1.0 -> privateV1.1.0 34 | ``` 35 | 36 | ### Header 37 | Most Fission FileSystem information is stored in the Header file. So most upgrades to the version of a FileSystem will include upgrades to the Header. This means we'll likely have a different `Header` type for each version of the FileSystem, and we can expect a simple lineage of inheritance (`HeaderV1.0.0 -> HeaderV1.0.1 -> HeaderV1.1.0`). Each version of the FileSystem will include an implementation of `PublicTree`, `PublicFile`, `PrivateTree`, `PrivateFile`, and a `Header` parser 38 | -------------------------------------------------------------------------------- /src/fs/bare/file.ts: -------------------------------------------------------------------------------- 1 | import type { CID } from "multiformats/cid" 2 | 3 | import * as Depot from "../../components/depot/implementation.js" 4 | import * as Protocol from "../protocol/index.js" 5 | 6 | import { PutResult } from "../../components/depot/implementation.js" 7 | import { isObject, hasProp } from "../../common/index.js" 8 | import BaseFile from "../base/file.js" 9 | 10 | 11 | export class BareFile extends BaseFile { 12 | 13 | depot: Depot.Implementation 14 | 15 | constructor(depot: Depot.Implementation, content: Uint8Array) { 16 | super(content) 17 | this.depot = depot 18 | } 19 | 20 | static create(depot: Depot.Implementation, content: Uint8Array): BareFile { 21 | return new BareFile(depot, content) 22 | } 23 | 24 | static async fromCID(depot: Depot.Implementation, cid: CID): Promise { 25 | const content = await Protocol.basic.getFile(depot, cid) 26 | return new BareFile(depot, content) 27 | } 28 | 29 | static instanceOf(obj: unknown): obj is BareFile { 30 | return isObject(obj) && hasProp(obj, "content") 31 | } 32 | 33 | async put(): Promise { 34 | const { cid } = await this.putDetailed() 35 | return cid 36 | } 37 | 38 | async putDetailed(): Promise { 39 | return Protocol.basic.putFile( 40 | this.depot, 41 | this.content 42 | ) 43 | } 44 | } 45 | 46 | 47 | export default BareFile 48 | -------------------------------------------------------------------------------- /src/fs/base/file.ts: -------------------------------------------------------------------------------- 1 | import type { CID } from "multiformats/cid" 2 | 3 | import { File } from "../types.js" 4 | import { PutResult } from "../../components/depot/implementation.js" 5 | 6 | 7 | export abstract class BaseFile implements File { 8 | 9 | content: Uint8Array 10 | readOnly: boolean 11 | 12 | constructor(content: Uint8Array) { 13 | this.content = content 14 | this.readOnly = false 15 | } 16 | 17 | async put(): Promise { 18 | const { cid } = await this.putDetailed() 19 | return cid 20 | } 21 | 22 | async updateContent(content: Uint8Array): Promise { 23 | if (this.readOnly) throw new Error("File is read-only") 24 | this.content = content 25 | return this 26 | } 27 | 28 | abstract putDetailed(): Promise 29 | } 30 | 31 | 32 | export default BaseFile 33 | -------------------------------------------------------------------------------- /src/fs/data.ts: -------------------------------------------------------------------------------- 1 | import * as Crypto from "../components/crypto/implementation.js" 2 | 3 | import * as DID from "../did/index.js" 4 | import * as FileSystem from "../fs/types.js" 5 | import * as Path from "../path/index.js" 6 | import * as Sharing from "./share.js" 7 | 8 | 9 | /** 10 | * Adds some sample to the file system. 11 | */ 12 | export async function addSampleData(fs: FileSystem.API): Promise { 13 | await fs.mkdir(Path.directory("private", "Apps")) 14 | await fs.mkdir(Path.directory("private", "Audio")) 15 | await fs.mkdir(Path.directory("private", "Documents")) 16 | await fs.mkdir(Path.directory("private", "Photos")) 17 | await fs.mkdir(Path.directory("private", "Video")) 18 | 19 | // Files 20 | await fs.write( 21 | Path.file("private", "Welcome.txt"), 22 | new TextEncoder().encode("Welcome to your personal transportable encrypted file system 👋") 23 | ) 24 | } 25 | 26 | /** 27 | * Stores the public part of the exchange key in the DID format, 28 | * in the `/public/.well-known/exchange/DID_GOES_HERE/` directory. 29 | */ 30 | export async function addPublicExchangeKey( 31 | crypto: Crypto.Implementation, 32 | fs: FileSystem.API 33 | ): Promise { 34 | const publicDid = await DID.exchange(crypto) 35 | 36 | await fs.mkdir( 37 | Path.combine(Sharing.EXCHANGE_PATH, Path.directory(publicDid)) 38 | ) 39 | } 40 | 41 | 42 | /** 43 | * Checks if the public exchange key was added in the well-known location. 44 | * See `addPublicExchangeKey()` for the exact details. 45 | */ 46 | export async function hasPublicExchangeKey( 47 | crypto: Crypto.Implementation, 48 | fs: FileSystem.API 49 | ): Promise { 50 | const publicDid = await DID.exchange(crypto) 51 | 52 | return fs.exists( 53 | Path.combine(Sharing.EXCHANGE_PATH, Path.directory(publicDid)) 54 | ) 55 | } -------------------------------------------------------------------------------- /src/fs/errors.ts: -------------------------------------------------------------------------------- 1 | export class NoPermissionError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = "NoPermissionError" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/fs/index.ts: -------------------------------------------------------------------------------- 1 | import FileSystem from "./filesystem.js" 2 | 3 | 4 | /** 5 | * The FileSystem class. 6 | */ 7 | export default FileSystem 8 | -------------------------------------------------------------------------------- /src/fs/link.ts: -------------------------------------------------------------------------------- 1 | import * as DagPB from "@ipld/dag-pb" 2 | import { CID } from "multiformats/cid" 3 | import { PBLink } from "@ipld/dag-pb" 4 | 5 | import { HardLink, SimpleLink } from "./types.js" 6 | import { decodeCID, encodeCID } from "../common/cid.js" 7 | 8 | 9 | type HasName = { name: string } 10 | 11 | 12 | export const arrToMap = (arr: T[]): { [ name: string ]: T } => { 13 | return arr.reduce((acc, cur) => { 14 | acc[ cur.name ] = cur 15 | return acc 16 | }, {} as { [ name: string ]: T }) 17 | } 18 | 19 | export const fromDAGLink = (link: PBLink): SimpleLink => { 20 | const name = link.Name || "" 21 | const cid = link.Hash 22 | const size = link.Tsize || 0 23 | return { name, cid, size } 24 | } 25 | 26 | export const make = (name: string, cid: CID, isFile: boolean, size: number): HardLink => { 27 | return { 28 | name, 29 | cid: encodeCID(cid), 30 | size, 31 | isFile 32 | } 33 | } 34 | 35 | export const toDAGLink = (link: SimpleLink): PBLink => { 36 | const { name, cid, size } = link 37 | return DagPB.createLink(name, size, decodeCID(cid)) 38 | } 39 | -------------------------------------------------------------------------------- /src/fs/metadata.ts: -------------------------------------------------------------------------------- 1 | import * as versions from "./versions.js" 2 | 3 | 4 | export type UnixFileMode = number 5 | 6 | export enum UnixNodeType { 7 | Raw = "raw", 8 | Directory = "dir", 9 | File = "file", 10 | Metadata = "metadata", 11 | Symlink = "symlink", 12 | HAMTShard = "hamtShard", 13 | } 14 | 15 | export type UnixMeta = { 16 | mtime: number 17 | ctime: number 18 | mode: UnixFileMode 19 | _type: string 20 | } 21 | 22 | export type Metadata = { 23 | unixMeta: UnixMeta 24 | isFile: boolean 25 | version: versions.SemVer 26 | } 27 | 28 | export const emptyUnix = (isFile: boolean): UnixMeta => ({ 29 | mtime: Date.now(), 30 | ctime: Date.now(), 31 | mode: isFile ? 644 : 755, 32 | _type: isFile ? UnixNodeType.File : UnixNodeType.Directory, 33 | }) 34 | 35 | export const empty = (isFile: boolean, version: versions.SemVer): Metadata => ({ 36 | isFile, 37 | version, 38 | unixMeta: emptyUnix(isFile) 39 | }) 40 | 41 | export const updateMtime = (metadata: Metadata): Metadata => ({ 42 | ...metadata, 43 | unixMeta: { 44 | ...metadata.unixMeta, 45 | mtime: Date.now() 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /src/fs/protocol/index.ts: -------------------------------------------------------------------------------- 1 | export * as basic from "./basic.js" 2 | export * as pub from "./public/index.js" 3 | export * as priv from "./private/index.js" 4 | -------------------------------------------------------------------------------- /src/fs/protocol/private/mmpt.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as DagPB from "@ipld/dag-pb" 2 | import { CID } from "multiformats/cid" 3 | import expect from "expect" 4 | import crypto from "crypto" 5 | 6 | import { depot, manners } from "../../../../tests/helpers/components.js" 7 | import MMPT from "./mmpt.js" 8 | 9 | 10 | function sha256Str(str: string): string { 11 | return crypto.createHash("sha256").update(str).digest("hex") 12 | } 13 | 14 | function encode(str: string): Uint8Array { 15 | return (new TextEncoder()).encode(str) 16 | } 17 | 18 | 19 | /* 20 | Generates lots of entries for insertion into the MMPT. 21 | 22 | The MMPT is a glorified key-value store. 23 | 24 | This returns an array of key-values sorted by the key, 25 | so that key collisions are more likely to be tested. 26 | */ 27 | async function generateExampleEntries(amount: number): Promise<{ name: string; cid: CID }[]> { 28 | const entries: { name: string; cid: CID }[] = [] 29 | 30 | for (const i of Array(amount).keys()) { 31 | const hash = sha256Str(`${i}`) 32 | const node = { Data: encode(hash), Links: [] } 33 | const cid = await depot.putBlock(DagPB.encode(node), DagPB.code) 34 | entries.push({ 35 | name: hash, 36 | cid: cid, 37 | }) 38 | } 39 | 40 | return entries.sort((a, b) => a.name.localeCompare(b.name)) 41 | } 42 | 43 | 44 | 45 | describe("the mmpt", function () { 46 | 47 | it("can handle concurrent adds", async function () { 48 | const mmpt = MMPT.create(depot) 49 | 50 | // Generate lots of entries 51 | const amount = 500 52 | const entries = await generateExampleEntries(amount) 53 | 54 | // Concurrently add all those entries to the MMPT 55 | await Promise.all(entries.map(entry => mmpt.add(entry.name, entry.cid))) 56 | 57 | // Check that the MMPT contains all entries we added 58 | const members = await mmpt.members() 59 | const keys = members.map(member => member.name).sort() 60 | const intputKeys = entries.map(entry => entry.name).sort() 61 | 62 | expect(keys).toStrictEqual(intputKeys) 63 | }) 64 | 65 | // This test used to generate even more data races 66 | it("can handle concurrent adds in batches", async function () { 67 | const mmpt = MMPT.create(depot) 68 | 69 | // Generate lots of entries 70 | const amount = 500 71 | const entries = await generateExampleEntries(amount) 72 | 73 | const slice_size = 5 74 | let soFar: { name: string; cid: CID }[] = [] 75 | let missing: { name: string; cid: CID }[] = [] 76 | 77 | for (let i = 0; i < entries.length; i += slice_size) { 78 | const slice = entries.slice(i, i + slice_size) 79 | await Promise.all(slice.map(entry => mmpt.add(entry.name, entry.cid))) 80 | soFar = soFar.concat(slice) 81 | const members = await mmpt.members() 82 | 83 | missing = soFar.filter(({ name }) => !members.some(mem => mem.name === name)) 84 | 85 | if (missing.length > 0) { 86 | break 87 | } 88 | } 89 | 90 | expect(missing.length).toStrictEqual(0) 91 | 92 | const reconstructedMMPT = await MMPT.fromCID(depot, await mmpt.put()) 93 | 94 | const reMembers = await reconstructedMMPT.members() 95 | missing = soFar.filter(({ name }) => !reMembers.some(mem => mem.name === name)) 96 | 97 | expect(missing.length).toStrictEqual(0) 98 | }) 99 | 100 | // reconstructing from CID causes the MMPT to be in a weird 101 | // half-in-memory half-in-ipfs state where not all branches are fetched 102 | // that's worth testing for sure 103 | it("can handle concurrent adds when reconstructed from CID", async function () { 104 | const firstMMPT = MMPT.create(depot) 105 | for (const entry of await generateExampleEntries(500)) { 106 | await firstMMPT.add(entry.name, entry.cid) 107 | } 108 | 109 | // Reconstruct an MMPT from a CID. This causes it to only fetch branches from ipfs on-demand 110 | const reconstructedMMPT = await MMPT.fromCID(depot, await firstMMPT.put()) 111 | 112 | // Test asynchronous adds 113 | const entries = await generateExampleEntries(500) 114 | await Promise.all(entries.map(entry => reconstructedMMPT.add(entry.name, entry.cid))) 115 | 116 | // Check that the MMPT contains all entries we added 117 | const members = await reconstructedMMPT.members() 118 | const keys = members.map(member => member.name).sort() 119 | const intputKeys = entries.map(entry => entry.name).sort() 120 | 121 | expect(keys).toStrictEqual(intputKeys) 122 | }) 123 | 124 | }) 125 | -------------------------------------------------------------------------------- /src/fs/protocol/private/namefilter.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import * as fc from "fast-check" 3 | import { BloomFilter } from "fission-bloom-filters" 4 | import expect from "expect" 5 | 6 | import * as namefilter from "./namefilter.js" 7 | import { crypto } from "../../../../tests/helpers/components.js" 8 | 9 | 10 | describe("hex bloom filter conversion", () => { 11 | 12 | before(() => { 13 | fc.configureGlobal({ numRuns: 10000 }) 14 | }) 15 | 16 | after(() => { 17 | fc.resetConfigureGlobal() 18 | }) 19 | 20 | /** Round trip hex to bloom filter 21 | * The bloom filter implementation likely drops the last digit of odd-length hex strings 22 | * because the last digit would only encode half a byte. We therefore only test even-length 23 | * hex strings here. 24 | */ 25 | it("round trip hex to bloom filter", async () => { 26 | fc.assert( 27 | fc.property(fc.hexaString({ minLength: 2 }).filter(str => str.length % 2 == 0), originalHex => { 28 | const filter = namefilter.fromHex(originalHex) 29 | const returnHex = namefilter.toHex(filter) 30 | expect(returnHex).toBe(originalHex) 31 | }) 32 | ) 33 | }) 34 | }) 35 | 36 | describe("bare filters", () => { 37 | it("a new filter with one entry has 16 bits set", async () => { 38 | fc.assert( 39 | fc.asyncProperty(fc.string({ minLength: 1 }), async key => { 40 | const filter = await namefilter.createBare(crypto, Uint8arrays.fromString(key, "utf8")) 41 | 42 | const bloomFilter = namefilter.fromHex(filter) 43 | const onesCount = countOnes(bloomFilter) 44 | expect(onesCount).toEqual(16) 45 | }) 46 | ) 47 | }) 48 | 49 | it("a filter with two entries has between 16 and 32 bits set", async () => { 50 | fc.assert( 51 | fc.asyncProperty(fc.tuple( 52 | fc.string({ minLength: 1 }), 53 | fc.string({ minLength: 1 }) 54 | ), async ([ first, second ]) => { 55 | let filter = await namefilter.createBare(crypto, Uint8arrays.fromString(first, "utf8")) 56 | filter = await namefilter.addToBare(crypto, filter, Uint8arrays.fromString(second, "utf8")) 57 | 58 | const bloomFilter = namefilter.fromHex(filter) 59 | const onesCount = countOnes(bloomFilter) 60 | expect(onesCount).toBeGreaterThanOrEqual(16) 61 | expect(onesCount).toBeLessThanOrEqual(32) 62 | }) 63 | ) 64 | }) 65 | 66 | it("a filter with n entries has between 16 and n*16 bits set", async () => { 67 | fc.assert( 68 | fc.asyncProperty(fc.array( 69 | fc.string({ minLength: 1 }), { minLength: 1, maxLength: 40 } 70 | ), async keys => { 71 | const n = keys.length 72 | 73 | let filter = await namefilter.createBare(crypto, Uint8arrays.fromString(keys[ 0 ], "utf8")) 74 | keys.slice(1).forEach(async key => { 75 | filter = await namefilter.addToBare(crypto, filter, Uint8arrays.fromString(key, "utf8")) 76 | }) 77 | 78 | const bloomFilter = namefilter.fromHex(filter) 79 | const onesCount = countOnes(bloomFilter) 80 | expect(onesCount).toBeGreaterThanOrEqual(16) 81 | expect(onesCount).toBeLessThanOrEqual(n * 16) 82 | }) 83 | ) 84 | }) 85 | }) 86 | 87 | describe("revision filters", () => { 88 | it("add revision adds one entry", async () => { 89 | fc.assert( 90 | fc.asyncProperty( 91 | fc.string({ minLength: 1 }), 92 | fc.integer({ min: 1 }), 93 | async (key, revision) => { 94 | const filter = await namefilter.createBare(crypto, Uint8arrays.fromString(key, "base64pad")) 95 | const revisionFilter = await namefilter.addRevision(crypto, filter, Uint8arrays.fromString(key, "base64pad"), revision) 96 | 97 | const bloomFilter = namefilter.fromHex(revisionFilter) 98 | const onesCount = countOnes(bloomFilter) 99 | expect(onesCount).toBeGreaterThanOrEqual(16) 100 | expect(onesCount).toBeLessThanOrEqual(32) 101 | }) 102 | ) 103 | }) 104 | }) 105 | 106 | /** Helper functions 107 | * These helper functions MUST match the implementations in namefilter.ts! 108 | */ 109 | 110 | // count the number of 1 bits in a filter 111 | const countOnes = (filter: BloomFilter): number => { 112 | const arr = new Uint32Array(filter.toBytes()) 113 | let count = 0 114 | for (let i = 0; i < arr.length; i++) { 115 | count += bitCount32(arr[ i ]) 116 | } 117 | return count 118 | } 119 | 120 | // counts the number of 1s in a uint32 121 | // from: https://graphics.stanford.edu/~seander/bithacks.html#CountBitsSetParallel 122 | const bitCount32 = (num: number): number => { 123 | const a = num - ((num >> 1) & 0x55555555) 124 | const b = (a & 0x33333333) + ((a >> 2) & 0x33333333) 125 | return ((b + (b >> 4) & 0xF0F0F0F) * 0x1010101) >> 24 126 | } -------------------------------------------------------------------------------- /src/fs/protocol/private/types.ts: -------------------------------------------------------------------------------- 1 | import type { CID } from "multiformats/cid" 2 | 3 | import { BareNameFilter, PrivateName } from "./namefilter.js" 4 | import { BaseLink, SoftLink } from "../../types.js" 5 | import { Metadata } from "../../metadata.js" 6 | import { PutResult } from "../../../components/depot/implementation.js" 7 | 8 | 9 | export type DecryptedNode = PrivateFileInfo | PrivateTreeInfo 10 | 11 | export type PrivateFileInfo = { 12 | content: string 13 | metadata: Metadata 14 | bareNameFilter: BareNameFilter 15 | revision: number 16 | key: string 17 | } 18 | 19 | export type PrivateLink = BaseLink & { 20 | key: string 21 | pointer: PrivateName 22 | } 23 | 24 | export type PrivateLinks = { [ name: string ]: PrivateLink | SoftLink } 25 | 26 | export type PrivateTreeInfo = { 27 | metadata: Metadata 28 | bareNameFilter: BareNameFilter 29 | revision: number 30 | links: PrivateLinks 31 | skeleton: PrivateSkeleton 32 | } 33 | 34 | export type PrivateSkeleton = { [ name: string ]: PrivateSkeletonInfo | SoftLink } 35 | 36 | export type PrivateSkeletonInfo = { 37 | cid: string 38 | key: string 39 | subSkeleton: PrivateSkeleton 40 | } 41 | 42 | export type PrivateAddResult = PutResult & { 43 | name: PrivateName 44 | key: Uint8Array 45 | skeleton: PrivateSkeleton 46 | } 47 | 48 | export type Revision = { 49 | cid: CID | string 50 | name: PrivateName 51 | number: number 52 | } 53 | -------------------------------------------------------------------------------- /src/fs/protocol/private/types/check.ts: -------------------------------------------------------------------------------- 1 | import * as check from "../../../types/check.js" 2 | import { PrivateFileInfo, PrivateTreeInfo, PrivateLink, PrivateLinks, DecryptedNode, PrivateSkeletonInfo, PrivateSkeleton } from "../types.js" 3 | import { isNum, isObject, isString, notNull } from "../../../../common/index.js" 4 | 5 | 6 | export const isDecryptedNode = (obj: any): obj is DecryptedNode => { 7 | return isPrivateTreeInfo(obj) || isPrivateFileInfo(obj) || check.isSoftLink(obj) 8 | } 9 | 10 | export const isPrivateFileInfo = (obj: any): obj is PrivateFileInfo => { 11 | return isObject(obj) 12 | && check.isMetadata(obj.metadata) 13 | && obj.metadata.isFile 14 | && isString(obj.key) 15 | && notNull(obj.content) 16 | } 17 | 18 | export const isPrivateTreeInfo = (obj: any): obj is PrivateTreeInfo => { 19 | return isObject(obj) 20 | && check.isMetadata(obj.metadata) 21 | && obj.metadata.isFile === false 22 | && isNum(obj.revision) 23 | && isPrivateLinks(obj.links) 24 | && isPrivateSkeleton(obj.skeleton) 25 | } 26 | 27 | export const isPrivateLink = (obj: any): obj is PrivateLink => { 28 | return check.isBaseLink(obj) 29 | && isString((obj as any).key) 30 | && isString((obj as any).pointer) 31 | } 32 | 33 | export const isPrivateLinks = (obj: any): obj is PrivateLinks => { 34 | return isObject(obj) 35 | && Object.values(obj).every(a => isPrivateLink(a) || check.isSoftLink(a)) 36 | } 37 | 38 | export const isPrivateSkeleton = (obj: any): obj is PrivateSkeleton => { 39 | return isObject(obj) 40 | && Object.values(obj).every(a => isPrivateSkeletonInfo(a) || check.isSoftLink(a)) 41 | } 42 | 43 | export const isPrivateSkeletonInfo = (obj: any): obj is PrivateSkeletonInfo => { 44 | return isObject(obj) 45 | && notNull(obj.cid) 46 | && isString(obj.key) 47 | && isPrivateSkeleton(obj.subSkeleton) 48 | } 49 | -------------------------------------------------------------------------------- /src/fs/protocol/public/skeleton.ts: -------------------------------------------------------------------------------- 1 | import { Skeleton, SkeletonInfo } from "./types.js" 2 | import { NonEmptyPath, SoftLink } from "../../types.js" 3 | import { isSoftLink } from "../../types/check.js" 4 | 5 | 6 | export const getPath = (skeleton: Skeleton, path: NonEmptyPath): SkeletonInfo | SoftLink | null => { 7 | const head = path[0] 8 | const child = skeleton[head] || null 9 | const nextPath = nextNonEmpty(path) 10 | if (child === null || nextPath === null || isSoftLink(child)) { 11 | return child 12 | } else if (child.subSkeleton) { 13 | return getPath(child.subSkeleton, nextPath) 14 | } else { 15 | return null 16 | } 17 | } 18 | 19 | export function nextNonEmpty(parts: NonEmptyPath): NonEmptyPath | null { 20 | const next = parts.slice(1) 21 | if (next.length < 1) { 22 | return null 23 | } 24 | return next as NonEmptyPath 25 | } 26 | -------------------------------------------------------------------------------- /src/fs/protocol/public/types.ts: -------------------------------------------------------------------------------- 1 | import type { CID } from "multiformats/cid" 2 | 3 | import { Metadata } from "../../metadata.js" 4 | import { PutResult } from "../../../components/depot/implementation.js" 5 | import { SoftLink } from "../../types.js" 6 | 7 | 8 | export type PutDetails = PutResult & { 9 | userland: CID 10 | metadata: CID 11 | isFile: boolean 12 | skeleton: Skeleton 13 | } 14 | 15 | export type SkeletonInfo = { 16 | cid: CID | string 17 | userland: CID | string 18 | metadata: CID | string 19 | subSkeleton: Skeleton 20 | isFile: boolean 21 | } 22 | 23 | export type Skeleton = { [ name: string ]: SkeletonInfo | SoftLink } 24 | 25 | export type TreeHeader = { 26 | metadata: Metadata 27 | previous?: CID | string 28 | skeleton: Skeleton 29 | } 30 | 31 | export type TreeInfo = TreeHeader & { 32 | userland: CID | string 33 | } 34 | 35 | export type FileHeader = { 36 | metadata: Metadata 37 | previous?: CID | string 38 | } 39 | 40 | export type FileInfo = FileHeader & { 41 | userland: CID | string 42 | } 43 | -------------------------------------------------------------------------------- /src/fs/protocol/shared/entry-index.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | 3 | import * as Crypto from "../../../components/crypto/implementation.js" 4 | import * as Namefilters from "../private/namefilter.js" 5 | 6 | import type { BareNameFilter, SaturatedNameFilter } from "../private/namefilter.js" 7 | import type { ShareKey } from "./key.js" 8 | 9 | 10 | export async function namefilter( 11 | crypto: Crypto.Implementation, 12 | { bareFilter, shareKey }: 13 | { bareFilter: BareNameFilter, shareKey: ShareKey } 14 | ): Promise { 15 | const hashedKey = await crypto.hash.sha256( 16 | Uint8arrays.fromString(shareKey, "base64pad") 17 | ) 18 | 19 | return Namefilters.saturate( 20 | crypto, 21 | await Namefilters.addToBare( 22 | crypto, 23 | bareFilter, 24 | Namefilters.legacyEncodingMistake(hashedKey, "hex") 25 | ) 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/fs/protocol/shared/key.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | 3 | import * as Crypto from "../../../components/crypto/implementation.js" 4 | import { Opaque } from "../../../common/types.js" 5 | 6 | 7 | export type ShareKey = Opaque<"ShareKey", string> 8 | 9 | 10 | /** 11 | * Creates a share key. 12 | */ 13 | export async function create( 14 | crypto: Crypto.Implementation, 15 | { counter, recipientExchangeDid, senderRootDid }: 16 | { counter: number, recipientExchangeDid: string, senderRootDid: string } 17 | ): Promise { 18 | const bytes = Uint8arrays.fromString(`${recipientExchangeDid}${senderRootDid}${counter}`, "utf8") 19 | 20 | return Uint8arrays.toString( 21 | await crypto.hash.sha256(bytes), 22 | "base64pad" 23 | ) 24 | } 25 | 26 | 27 | /** 28 | * Creates the payload for a share key. 29 | */ 30 | export function payload( 31 | { entryIndexCid, symmKey, symmKeyAlgo }: 32 | { entryIndexCid: string, symmKey: string | Uint8Array, symmKeyAlgo: string } 33 | ): { algo: string, key: Uint8Array, cid: string } { 34 | const cid = entryIndexCid 35 | 36 | return { 37 | algo: symmKeyAlgo, 38 | key: typeof symmKey === "string" 39 | ? Uint8arrays.fromString(symmKey, "base64pad") 40 | : symmKey, 41 | cid 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/fs/types/check.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | 3 | import { isString, isObject, isNum, isBool } from "../../common/index.js" 4 | import { Tree, File, HardLink, SoftLink, Links, BaseLink, SimpleLink } from "../types.js" 5 | import { Skeleton, SkeletonInfo, TreeInfo, FileInfo, TreeHeader, FileHeader } from "../protocol/public/types.js" 6 | import { SemVer } from "../versions.js" 7 | import { Metadata, UnixMeta } from "../metadata.js" 8 | 9 | 10 | export const isFile = (obj: any): obj is File => { 11 | return isObject(obj) && obj.content !== undefined 12 | } 13 | 14 | export const isTree = (obj: any): obj is Tree => { 15 | return isObject(obj) && obj.ls !== undefined 16 | } 17 | 18 | export const isBaseLink = (obj: any): obj is BaseLink => { 19 | return isObject(obj) 20 | && isString(obj.name) 21 | && isNum(obj.size) 22 | && isBool(obj.isFile) 23 | } 24 | 25 | export const isSimpleLink = (obj: any): obj is SimpleLink => { 26 | return isObject(obj) 27 | && isString(obj.name) 28 | && isNum(obj.size) 29 | && isCID(obj.cid) 30 | } 31 | 32 | export const isSoftLink = (obj: any): obj is SoftLink => { 33 | return isObject(obj) 34 | && isString(obj.name) 35 | && isString(obj.ipns) 36 | } 37 | 38 | export const isSoftLinkDictionary = (obj: any): obj is Record => { 39 | if (isObject(obj)) { 40 | const values = Object.values(obj) 41 | return values.length > 0 && values.every(isSoftLink) 42 | } 43 | 44 | return false 45 | } 46 | 47 | export const isSoftLinkList = (obj: any): obj is Array => { 48 | return Array.isArray(obj) && obj.every(isSoftLink) 49 | } 50 | 51 | export const isHardLink = (obj: any): obj is HardLink => { 52 | return isBaseLink(obj) && isCID((obj as any).cid) 53 | } 54 | 55 | export const isLinks = (obj: any): obj is Links => { 56 | return isObject(obj) 57 | && Object.values(obj).every(a => isHardLink(a) || isSoftLink(a)) 58 | } 59 | 60 | export const isUnixMeta = (obj: any): obj is UnixMeta => { 61 | return isObject(obj) 62 | && isNum(obj.mtime) 63 | && isNum(obj.ctime) 64 | && isNum(obj.mode) 65 | && isString(obj._type) 66 | } 67 | 68 | export const isMetadata = (obj: any): obj is Metadata => { 69 | return isObject(obj) 70 | && isUnixMeta(obj.unixMeta) 71 | && isBool(obj.isFile) 72 | && isSemVer(obj.version) 73 | } 74 | 75 | export const isSkeleton = (obj: any): obj is Skeleton => { 76 | return isObject(obj) && Object.values(obj).every(isSkeletonInfo) 77 | } 78 | 79 | export const isSkeletonInfo = (val: any): val is SkeletonInfo => { 80 | const isNode = isObject(val) 81 | && isCID(val.cid) 82 | && isCID(val.userland) 83 | && isCID(val.metadata) 84 | && isSkeleton(val.subSkeleton) 85 | 86 | return isNode || isSoftLink(val) 87 | } 88 | 89 | export const isTreeHeader = (obj: any): obj is TreeHeader => { 90 | return isObject(obj) 91 | && isSkeleton(obj.skeleton) 92 | && isMetadata(obj.metadata) 93 | && obj.metadata.isFile === false 94 | } 95 | 96 | export const isTreeInfo = (obj: any): obj is TreeInfo => { 97 | return isTreeHeader(obj) 98 | && isCID((obj as any).userland) 99 | } 100 | 101 | export const isFileHeader = (obj: any): obj is FileHeader => { 102 | return isObject(obj) 103 | && isMetadata(obj.metadata) 104 | && obj.metadata.isFile === true 105 | } 106 | 107 | export const isFileInfo = (obj: any): obj is FileInfo => { 108 | return isFileHeader(obj) 109 | && isCID((obj as any).userland) 110 | } 111 | 112 | export const isCID = (obj: any): obj is CID | string => { 113 | const cid = CID.asCID(obj) 114 | return !!cid || isString(obj) || (obj && "code" in obj && "version" in obj && ("multihash" in obj || "hash" in obj)) 115 | } 116 | 117 | export const isSemVer = (obj: any): obj is SemVer => { 118 | if (!isObject(obj)) return false 119 | const { major, minor, patch } = obj 120 | return isNum(major) && isNum(minor) && isNum(patch) 121 | } 122 | -------------------------------------------------------------------------------- /src/fs/types/params.ts: -------------------------------------------------------------------------------- 1 | export type RecoverFileSystemParams = { 2 | newUsername: string 3 | oldUsername: string 4 | readKey: Uint8Array 5 | } 6 | -------------------------------------------------------------------------------- /src/fs/v1/PrivateHistory.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | 3 | import * as Crypto from "../../components/crypto/implementation.js" 4 | import * as Depot from "../../components/depot/implementation.js" 5 | import * as Protocol from "../protocol/index.js" 6 | 7 | import MMPT from "../protocol/private/mmpt.js" 8 | 9 | import { BareNameFilter } from "../protocol/private/namefilter.js" 10 | import { DecryptedNode, Revision } from "../protocol/private/types.js" 11 | import { Maybe, decodeCID } from "../../common/index.js" 12 | import { Metadata } from "../metadata.js" 13 | 14 | 15 | export interface Node { 16 | fromInfo: (mmpt: MMPT, key: Uint8Array, info: DecryptedNode) => Promise 17 | header: { 18 | bareNameFilter: BareNameFilter 19 | metadata: Metadata 20 | revision: number 21 | } 22 | key: Uint8Array 23 | mmpt: MMPT 24 | } 25 | 26 | 27 | export default class PrivateHistory { 28 | 29 | readonly node: Node 30 | 31 | crypto: Crypto.Implementation 32 | depot: Depot.Implementation 33 | 34 | constructor(crypto: Crypto.Implementation, depot: Depot.Implementation, node: Node) { 35 | this.crypto = crypto 36 | this.depot = depot 37 | this.node = node 38 | } 39 | 40 | /** 41 | * Go back one or more versions. 42 | * 43 | * @param delta Optional negative number to specify how far to go back 44 | */ 45 | async back(delta = -1): Promise> { 46 | const n = Math.min(delta, -1) 47 | const revision = this.node.header?.revision 48 | return (revision && await this._getRevision(revision + n)) || null 49 | } 50 | 51 | // async forward(delta: number = 1): Promise> { 52 | // const n = Math.max(delta, 1) 53 | // const revision = this.node.header?.revision 54 | // return (revision && await this._getRevision(revision + n)) || null 55 | // } 56 | 57 | /** 58 | * Get a version before a given timestamp. 59 | * 60 | * @param timestamp Unix timestamp in seconds 61 | */ 62 | async prior(timestamp: number): Promise> { 63 | if (this.node.header.metadata.unixMeta.mtime < timestamp) { 64 | return this.node 65 | } else { 66 | return this._prior(this.node.header.revision - 1, timestamp) 67 | } 68 | } 69 | 70 | /** 71 | * List earlier versions along with the timestamp they were created. 72 | */ 73 | async list(amount = 5): Promise> { 74 | const max = this.node.header.revision 75 | 76 | return Promise.all( 77 | Array.from({ length: amount }, (_, i) => { 78 | const n = i + 1 79 | return this._getRevisionInfoFromNumber(max - n).then(info => ({ 80 | revisionInfo: info, 81 | delta: -n 82 | })) 83 | }) 84 | ).then( 85 | list => list.filter(a => !!a.revisionInfo) as Array<{ revisionInfo: DecryptedNode; delta: number }> 86 | ).then( 87 | list => list.map(a => { 88 | const mtime = a.revisionInfo.metadata.unixMeta.mtime 89 | return { delta: a.delta, timestamp: mtime } 90 | }) 91 | ) 92 | } 93 | 94 | /** 95 | * @internal 96 | */ 97 | async _getRevision(revision: number): Promise> { 98 | const info = await this._getRevisionInfoFromNumber(revision) 99 | return info && await this.node.fromInfo( 100 | this.node.mmpt, 101 | this.node.key, 102 | info 103 | ) 104 | } 105 | 106 | /** 107 | * @internal 108 | */ 109 | _getRevisionInfo(revision: Revision): Promise { 110 | return Protocol.priv.readNode( 111 | this.depot, 112 | this.crypto, 113 | decodeCID(revision.cid), 114 | this.node.key 115 | ) 116 | } 117 | 118 | /** 119 | * @internal 120 | */ 121 | async _getRevisionInfoFromNumber(revision: number): Promise> { 122 | const { mmpt } = this.node 123 | const { bareNameFilter } = this.node.header 124 | const key = this.node.key 125 | 126 | const r = await Protocol.priv.getRevision(this.crypto, mmpt, bareNameFilter, key, revision) 127 | return r && this._getRevisionInfo(r) 128 | } 129 | 130 | /** 131 | * @internal 132 | */ 133 | async _prior(revision: number, timestamp: number): Promise> { 134 | const info = await this._getRevisionInfoFromNumber(revision) 135 | if (!info?.revision) return null 136 | 137 | if (info.metadata.unixMeta.mtime < timestamp) { 138 | return this._getRevision(info.revision) 139 | } else { 140 | return this._prior(info.revision - 1, timestamp) 141 | } 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /src/fs/v1/PublicFile.ts: -------------------------------------------------------------------------------- 1 | import type { CID } from "multiformats/cid" 2 | 3 | import * as Check from "../types/check.js" 4 | import * as Depot from "../../components/depot/implementation.js" 5 | import * as History from "./PublicHistory.js" 6 | import * as Metadata from "../metadata.js" 7 | import * as Protocol from "../protocol/index.js" 8 | import * as Versions from "../versions.js" 9 | 10 | import { FileInfo, FileHeader, PutDetails } from "../protocol/public/types.js" 11 | import { decodeCID, isObject, hasProp, Maybe } from "../../common/index.js" 12 | import BaseFile from "../base/file.js" 13 | import PublicHistory from "./PublicHistory.js" 14 | 15 | 16 | type ConstructorParams = { 17 | depot: Depot.Implementation 18 | 19 | cid: Maybe 20 | content: Uint8Array 21 | header: FileHeader 22 | } 23 | 24 | export class PublicFile extends BaseFile { 25 | 26 | depot: Depot.Implementation 27 | 28 | cid: Maybe 29 | header: FileHeader 30 | history: PublicHistory 31 | 32 | constructor({ depot, content, header, cid }: ConstructorParams) { 33 | super(content) 34 | 35 | this.depot = depot 36 | 37 | this.cid = cid 38 | this.header = header 39 | this.history = new PublicHistory( 40 | toHistoryNode(this) 41 | ) 42 | 43 | function toHistoryNode(file: PublicFile): History.Node { 44 | return { 45 | ...file, 46 | fromCID: async (cid: CID) => toHistoryNode( 47 | await PublicFile.fromCID(depot, cid) 48 | ) 49 | } 50 | } 51 | } 52 | 53 | static instanceOf(obj: unknown): obj is PublicFile { 54 | return isObject(obj) 55 | && hasProp(obj, "content") 56 | && hasProp(obj, "header") 57 | && Check.isFileHeader(obj.header) 58 | } 59 | 60 | static async create(depot: Depot.Implementation, content: Uint8Array): Promise { 61 | return new PublicFile({ 62 | depot, 63 | 64 | content, 65 | header: { metadata: Metadata.empty(true, Versions.latest) }, 66 | cid: null 67 | }) 68 | } 69 | 70 | static async fromCID(depot: Depot.Implementation, cid: CID): Promise { 71 | const info = await Protocol.pub.get(depot, cid) 72 | return PublicFile.fromInfo(depot, info, cid) 73 | } 74 | 75 | static async fromInfo(depot: Depot.Implementation, info: FileInfo, cid: CID): Promise { 76 | const { userland, metadata, previous } = info 77 | const content = await Protocol.basic.getFile(depot, decodeCID(userland)) 78 | return new PublicFile({ 79 | depot, 80 | 81 | content, 82 | header: { metadata, previous }, 83 | cid 84 | }) 85 | } 86 | 87 | async putDetailed(): Promise { 88 | const details = await Protocol.pub.putFile( 89 | this.depot, 90 | this.content, 91 | Metadata.updateMtime(this.header.metadata), 92 | this.cid 93 | ) 94 | this.cid = details.cid 95 | return details 96 | } 97 | 98 | } 99 | 100 | export default PublicFile 101 | -------------------------------------------------------------------------------- /src/fs/v1/PublicHistory.ts: -------------------------------------------------------------------------------- 1 | import type { CID } from "multiformats/cid" 2 | 3 | import { decodeCID, Maybe } from "../../common/index.js" 4 | import { FileHeader } from "../protocol/public/types.js" 5 | 6 | 7 | export type Node = { 8 | fromCID: (cid: CID) => Promise 9 | header: Pick 10 | } 11 | 12 | 13 | export default class PublicHistory { 14 | 15 | constructor(readonly node: Node) { } 16 | 17 | /** 18 | * Go back one or more versions. 19 | * 20 | * @param delta Optional negative number to specify how far to go back 21 | */ 22 | back(delta = -1): Promise> { 23 | const length = Math.abs(Math.min(delta, -1)) 24 | 25 | return Array.from({ length }, (_, i) => i).reduce( 26 | (promise: Promise>) => promise.then( 27 | (n: Maybe) => n ? PublicHistory._getPreviousVersion(n) : null 28 | ), 29 | Promise.resolve(this.node) 30 | ) 31 | } 32 | 33 | // async forward(delta: number = 1): Promise> {} 34 | 35 | /** 36 | * Get a version before a given timestamp. 37 | * 38 | * @param timestamp Unix timestamp in seconds 39 | */ 40 | async prior(timestamp: number): Promise> { 41 | return PublicHistory._prior(this.node, timestamp) 42 | } 43 | 44 | /** 45 | * List earlier versions along with the timestamp they were created. 46 | */ 47 | async list(amount = 5): Promise> { 48 | const { acc } = await Array.from({ length: amount }, (_, i) => i).reduce( 49 | (promise, i) => promise.then(({ node, acc }) => { 50 | if (!node) return Promise.resolve({ node: null, acc }) 51 | 52 | return PublicHistory 53 | ._getPreviousVersion(node) 54 | .then(n => ({ 55 | node: n, 56 | acc: [ 57 | ...acc, 58 | { delta: -(i + 1), timestamp: node.header.metadata.unixMeta.mtime } 59 | ] 60 | })) 61 | }), 62 | PublicHistory 63 | ._getPreviousVersion(this.node) 64 | .then(n => ( 65 | { node: n, acc: [] } as { 66 | node: Maybe 67 | acc: Array<{ delta: number; timestamp: number }> 68 | } 69 | )) 70 | ) 71 | 72 | return acc 73 | } 74 | 75 | /** 76 | * @internal 77 | */ 78 | static async _getPreviousVersion(node: Node): Promise> { 79 | if (!node.header.previous) return Promise.resolve(null) 80 | return node.fromCID( 81 | decodeCID(node.header.previous) 82 | ) 83 | } 84 | 85 | /** 86 | * @internal 87 | */ 88 | static async _prior(node: Node, timestamp: number): Promise> { 89 | if (node.header.metadata.unixMeta.mtime < timestamp) return node 90 | const previous = await PublicHistory._getPreviousVersion(node) 91 | return previous ? PublicHistory._prior(previous, timestamp) : null 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/fs/v3/DepotBlockStore.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | 3 | import * as Codecs from "../../dag/codecs.js" 4 | import * as Depot from "../../components/depot/implementation.js" 5 | 6 | 7 | export interface BlockStore { 8 | putBlock(bytes: Uint8Array, code: number): Promise 9 | getBlock(cid: Uint8Array): Promise 10 | } 11 | 12 | export class DepotBlockStore implements BlockStore { 13 | private depot: Depot.Implementation 14 | 15 | constructor(depot: Depot.Implementation) { 16 | this.depot = depot 17 | } 18 | 19 | /** Retrieves an array of bytes from the block store with given CID. */ 20 | async getBlock(cid: Uint8Array): Promise { 21 | const decodedCid = CID.decode(cid) 22 | return await this.depot.getBlock(decodedCid) 23 | } 24 | 25 | /** Stores an array of bytes in the block store. */ 26 | async putBlock(bytes: Uint8Array, code: number): Promise { 27 | if (!Codecs.isIdentifier(code)) throw new Error(`No codec was registered for the code: ${Codecs.numberHex(code)}`) 28 | 29 | const cid = await this.depot.putBlock(bytes, code) 30 | return cid.bytes 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/fs/versions.ts: -------------------------------------------------------------------------------- 1 | import * as SemVer from "../common/semver.js" 2 | 3 | 4 | export * from "../common/semver.js" 5 | 6 | 7 | 8 | export const isSupported = (fsVersion: SemVer.SemVer): true | "too-high" | "too-low" => { 9 | if (SemVer.isSmallerThan(fsVersion, latest)) { 10 | return "too-low" 11 | } else if (SemVer.isBiggerThan(fsVersion, wnfsWasm)) { 12 | return "too-high" 13 | } else { 14 | return true 15 | } 16 | } 17 | 18 | 19 | // VERSIONS 20 | export const v0 = SemVer.encode(0, 0, 0) 21 | export const v1 = SemVer.encode(1, 0, 0) 22 | export const latest = SemVer.encode(2, 0, 0) 23 | export const wnfsWasm = SemVer.encode(3, 0, 0) 24 | 25 | export const supported: SemVer.SemVer[] = [ latest, wnfsWasm ] 26 | -------------------------------------------------------------------------------- /src/linking/common.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "../common/types.js" 2 | import * as Manners from "../components/manners/implementation.js" 3 | 4 | 5 | export enum LinkingStep { 6 | Broadcast = "BROADCAST", 7 | Negotiation = "NEGOTIATION", 8 | Delegation = "DELEGATION" 9 | } 10 | 11 | export class LinkingError extends Error { 12 | constructor(message: string) { 13 | super(message) 14 | this.name = "LinkingError" 15 | } 16 | } 17 | 18 | export class LinkingWarning extends Error { 19 | constructor(message: string) { 20 | super(message) 21 | this.name = "LinkingWarning" 22 | } 23 | } 24 | 25 | export const handleLinkingError = (manners: Manners.Implementation, error: LinkingError | LinkingWarning): void => { 26 | switch (error.name) { 27 | case "LinkingWarning": 28 | manners.warn(error.message) 29 | break 30 | 31 | case "LinkingError": 32 | throw error 33 | 34 | default: 35 | throw error 36 | } 37 | } 38 | 39 | export const tryParseMessage = ( 40 | data: string, 41 | typeGuard: (message: unknown) => message is T, 42 | context: { participant: string; callSite: string } 43 | ): Result => { 44 | try { 45 | const message = JSON.parse(data) 46 | 47 | if (typeGuard(message)) { 48 | return { 49 | ok: true, 50 | value: message 51 | } 52 | } else { 53 | return { 54 | ok: false, 55 | error: new LinkingWarning(`${context.participant} received an unexpected message in ${context.callSite}: ${data}. Ignoring message.`) 56 | } 57 | } 58 | 59 | } catch { 60 | return { 61 | ok: false, 62 | error: new LinkingWarning(`${context.participant} received a message in ${context.callSite} that it could not parse: ${data}. Ignoring message.`) 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /src/linking/index.ts: -------------------------------------------------------------------------------- 1 | export { AccountLinkingConsumer, createConsumer } from "./consumer.js" 2 | export { AccountLinkingProducer, createProducer } from "./producer.js" -------------------------------------------------------------------------------- /src/permissions.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "./path/index.js" 2 | import { AppInfo } from "./appInfo.js" 3 | import { Distinctive } from "./path/index.js" 4 | import { Potency, Resource } from "./ucan/index.js" 5 | 6 | 7 | // 🏔 8 | 9 | 10 | export const ROOT_FILESYSTEM_PERMISSIONS = { 11 | fs: { 12 | private: [ Path.root() ], 13 | public: [ Path.root() ] 14 | } 15 | } 16 | 17 | 18 | 19 | // 🧩 20 | 21 | 22 | export type Permissions = { 23 | app?: AppInfo 24 | fs?: FileSystemPermissions 25 | platform?: PlatformPermissions 26 | raw?: RawPermissions 27 | sharing?: boolean 28 | } 29 | 30 | export type FileSystemPermissions = { 31 | private?: Array> 32 | public?: Array> 33 | } 34 | 35 | export type PlatformPermissions = { 36 | apps: "*" | Array 37 | } 38 | 39 | export type RawPermissions = Array 40 | 41 | export type RawPermission = { 42 | exp: number 43 | rsc: Resource 44 | ptc: Potency 45 | } 46 | 47 | 48 | 49 | // 🛠 50 | 51 | 52 | /** 53 | * App identifier. 54 | */ 55 | export function appId(app: AppInfo): string { 56 | return `${app.creator}/${app.name}` 57 | } 58 | 59 | 60 | /** 61 | * Lists the filesystems paths for a set of `Permissions`. 62 | * This'll return a list of `DistinctivePath`s. 63 | */ 64 | export function permissionPaths(permissions: Permissions): Distinctive>[] { 65 | let list = [] as Distinctive>[] 66 | 67 | if (permissions.app) list.push(Path.appData(permissions.app)) 68 | if (permissions.fs?.private) list = list.concat( 69 | permissions.fs?.private.map(p => Path.withPartition( 70 | "private", 71 | p 72 | )) 73 | ) 74 | if (permissions.fs?.public) list = list.concat( 75 | permissions.fs?.public.map(p => Path.withPartition( 76 | "public", 77 | p 78 | )) 79 | ) 80 | 81 | return list 82 | } -------------------------------------------------------------------------------- /src/repositories/README.md: -------------------------------------------------------------------------------- 1 | Smaller components, as opposed to the ones in `components/`, that use storage. -------------------------------------------------------------------------------- /src/repositories/cid-log.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as DagCBOR from "@ipld/dag-cbor" 2 | import * as fc from "fast-check" 3 | import * as Raw from "multiformats/codecs/raw" 4 | import { CID } from "multiformats/cid" 5 | import { sha256 } from "multiformats/hashes/sha2" 6 | import expect from "expect" 7 | 8 | import * as CIDLog from "./cid-log.js" 9 | import { storage } from "../../tests/helpers/components.js" 10 | 11 | 12 | async function generateCids(data: Uint8Array[]): Promise { 13 | const promisedCids = data.map(async bytes => { 14 | const encoded = DagCBOR.encode(bytes) 15 | const mhash = await sha256.digest(encoded) 16 | return CID.createV1(DagCBOR.code, mhash) 17 | }) 18 | return Promise.all(promisedCids) 19 | } 20 | 21 | 22 | function isEqualCIDsSet(a: CID[], b: CID[]) { 23 | return expect(a.map(c => c.toString())).toEqual(b.map(c => c.toString())) 24 | } 25 | 26 | 27 | describe("cid-log", () => { 28 | 29 | let cidLog: CIDLog.Repo 30 | 31 | before(async () => { 32 | cidLog = await CIDLog.create({ storage }) 33 | }) 34 | 35 | it("gets an empty log when key is missing", async () => { 36 | const log = await cidLog.getAll() 37 | expect(log).toEqual([]) 38 | }) 39 | 40 | it("adds cids and gets an ordered log", async () => { 41 | await fc.assert( 42 | fc.asyncProperty( 43 | fc.array(fc.uint8Array({ minLength: 1, maxLength: 100 }), { minLength: 1, maxLength: 10 }), async data => { 44 | await cidLog.clear() 45 | 46 | const cids: CID[] = await generateCids(data) 47 | await cidLog.add(cids) 48 | 49 | const log = await cidLog.getAll() 50 | 51 | isEqualCIDsSet(log, cids) 52 | }) 53 | ) 54 | }) 55 | 56 | it("gets index of a cid", async () => { 57 | await fc.assert( 58 | fc.asyncProperty( 59 | fc.array(fc.uint8Array({ minLength: 1, maxLength: 100 }), { minLength: 1, maxLength: 10 }), async data => { 60 | await cidLog.clear() 61 | 62 | const cids: CID[] = await generateCids(data) 63 | await cidLog.add(cids) 64 | 65 | const idx = Math.floor(Math.random() * data.length) 66 | const cid = cids[ idx ] 67 | 68 | // Get the index of test cid after all CIDs have been added 69 | const index = cidLog.indexOf(cid) 70 | 71 | expect(index).toEqual(idx) 72 | }) 73 | ) 74 | }) 75 | 76 | it("gets the newest cid", async () => { 77 | await fc.assert( 78 | fc.asyncProperty( 79 | fc.array(fc.uint8Array({ minLength: 1, maxLength: 100 }), { minLength: 1, maxLength: 10 }), async data => { 80 | await cidLog.clear() 81 | 82 | const cids: CID[] = await generateCids(data) 83 | await cidLog.add(cids) 84 | 85 | const cid = cids[ cids.length - 1 ] 86 | 87 | // Get the newest cid after all CIDs have been added 88 | const newest = cidLog.newest() 89 | 90 | expect(newest.toString()).toEqual(cid.toString()) 91 | }) 92 | ) 93 | }) 94 | 95 | it("clears the cid log", async () => { 96 | await fc.assert( 97 | fc.asyncProperty( 98 | fc.array(fc.uint8Array({ minLength: 1, maxLength: 100 }), { maxLength: 5 }), async data => { 99 | await cidLog.clear() 100 | 101 | const cids: CID[] = await generateCids(data) 102 | await cidLog.add(cids) 103 | 104 | // Clear the log and get it after all CIDs have been added 105 | await cidLog.clear() 106 | const log = await cidLog.getAll() 107 | 108 | isEqualCIDsSet(log, []) 109 | }) 110 | ) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/repositories/cid-log.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | 3 | import * as Storage from "../components/storage/implementation" 4 | import { decodeCID } from "../common/cid.js" 5 | import Repository, { RepositoryOptions } from "../repository.js" 6 | 7 | 8 | export function create({ storage }: { storage: Storage.Implementation }): Promise { 9 | return Repo.create({ 10 | storage, 11 | storageName: storage.KEYS.CID_LOG 12 | }) 13 | } 14 | 15 | 16 | // CLASS 17 | 18 | 19 | export class Repo extends Repository { 20 | 21 | private constructor(options: RepositoryOptions) { 22 | super(options) 23 | } 24 | 25 | fromJSON(a: string): CID { 26 | return decodeCID(a) 27 | } 28 | 29 | toJSON(a: CID): string { 30 | return a.toString() 31 | } 32 | 33 | indexOf(item: CID): number { 34 | return this.memoryCache.map( 35 | c => c.toString() 36 | ).indexOf( 37 | item.toString() 38 | ) 39 | } 40 | 41 | newest(): CID { 42 | return this.memoryCache[ this.memoryCache.length - 1 ] 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/repositories/ucans.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "../path/index.js" 2 | import * as Storage from "../components/storage/implementation" 3 | import * as Ucan from "../ucan/index.js" 4 | 5 | import Repository, { RepositoryOptions } from "../repository.js" 6 | import { DistinctivePath } from "../path/index.js" 7 | import { Resource } from "../ucan/index.js" 8 | 9 | 10 | export function create({ storage }: { storage: Storage.Implementation }): Promise { 11 | return Repo.create({ 12 | storage, 13 | storageName: storage.KEYS.UCANS 14 | }) 15 | } 16 | 17 | 18 | 19 | // CLASS 20 | 21 | 22 | export class Repo extends Repository { 23 | 24 | private constructor(options: RepositoryOptions) { 25 | super(options) 26 | } 27 | 28 | 29 | // ENCODING 30 | 31 | fromJSON(a: string): Ucan.Ucan { return Ucan.decode(a) } 32 | toJSON(a: Ucan.Ucan): string { return Ucan.encode(a) } 33 | 34 | // `${resourceKey}:${resourceValue}` 35 | toDictionary(items: Ucan.Ucan[]) { 36 | return items.reduce( 37 | (acc, ucan) => ({ ...acc, [ resourceLabel(ucan.payload.rsc) ]: ucan }), 38 | {} 39 | ) 40 | } 41 | 42 | 43 | // LOOKUPS 44 | 45 | /** 46 | * Look up a UCAN with a file system path. 47 | */ 48 | async lookupFilesystemUcan( 49 | path: DistinctivePath | "*" 50 | ): Promise { 51 | const god = this.getByKey("*") 52 | if (god) return god 53 | 54 | const all = path === "*" 55 | const isDirectory = all ? false : Path.isDirectory(path) 56 | const pathParts = all ? [ "*" ] : Path.unwrap(path) 57 | 58 | const prefix = filesystemPrefix() 59 | 60 | return pathParts.reduce( 61 | (acc: Ucan.Ucan | null, part: string, idx: number) => { 62 | if (acc) return acc 63 | 64 | const isLastPart = idx === 0 65 | const partsSlice = pathParts.slice(0, pathParts.length - idx) 66 | 67 | const partialPath = Path.toPosix( 68 | isLastPart && !isDirectory 69 | ? Path.file(...partsSlice) 70 | : Path.directory(...partsSlice) 71 | ) 72 | 73 | return this.getByKey(`${prefix}${partialPath}`) || null 74 | }, 75 | null 76 | ) 77 | } 78 | 79 | /** 80 | * Look up a UCAN for a platform app. 81 | */ 82 | async lookupAppUcan( 83 | domain: string 84 | ): Promise { 85 | return this.getByKey("*") || this.getByKey("app:*") || this.getByKey(`app:${domain}`) 86 | } 87 | 88 | } 89 | 90 | 91 | 92 | // CONSTANTS 93 | 94 | 95 | // TODO: Waiting on API change. 96 | // Should be `dnslink` 97 | export const WNFS_PREFIX = "wnfs" 98 | 99 | 100 | 101 | // DICTIONARY 102 | 103 | 104 | /** 105 | * Construct the prefix for a filesystem key. 106 | */ 107 | export function filesystemPrefix(username?: string): string { 108 | // const host = `${username}.${setup.endpoints.user}` 109 | // TODO: Waiting on API change. 110 | // Should be `${WNFS_PREFIX}:${host}/` 111 | return WNFS_PREFIX + ":" 112 | } 113 | 114 | /** 115 | * Creates the label for a given resource. 116 | */ 117 | export function resourceLabel(rsc: Resource): string { 118 | if (typeof rsc !== "object") { 119 | return rsc 120 | } 121 | 122 | const resource = Array.from(Object.entries(rsc))[ 0 ] 123 | return resource[ 0 ] + ":" + ( 124 | resource[ 0 ] === WNFS_PREFIX 125 | ? resource[ 1 ].replace(/^\/+/, "") 126 | : resource[ 1 ] 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /src/repository.ts: -------------------------------------------------------------------------------- 1 | import * as Storage from "./components/storage/implementation" 2 | import * as TypeChecks from "./common/type-checks.js" 3 | 4 | 5 | export type RepositoryOptions = { 6 | storage: Storage.Implementation 7 | storageName: string 8 | } 9 | 10 | 11 | export default abstract class Repository { 12 | 13 | dictionary: Record 14 | memoryCache: T[] 15 | storage: Storage.Implementation 16 | storageName: string 17 | 18 | 19 | constructor({ storage, storageName }: RepositoryOptions) { 20 | this.memoryCache = [] 21 | this.dictionary = {} 22 | this.storage = storage 23 | this.storageName = storageName 24 | } 25 | 26 | static async create(options: RepositoryOptions) { 27 | // @ts-ignore 28 | const repo = new this.prototype.constructor(options) 29 | 30 | repo.memoryCache = await repo.getAll() 31 | repo.dictionary = repo.toDictionary(repo.memoryCache) 32 | 33 | return repo 34 | } 35 | 36 | async add(itemOrItems: T | T[]): Promise { 37 | const items = Array.isArray(itemOrItems) ? itemOrItems : [ itemOrItems ] 38 | 39 | this.memoryCache = [ ...this.memoryCache, ...items ] 40 | this.dictionary = this.toDictionary(this.memoryCache) 41 | 42 | await this.storage.setItem( 43 | this.storageName, 44 | // TODO: JSON.stringify(this.memoryCache.map(this.toJSON)) 45 | this.memoryCache.map(this.toJSON).join("|||") 46 | ) 47 | } 48 | 49 | clear(): Promise { 50 | this.memoryCache = [] 51 | this.dictionary = {} 52 | 53 | return this.storage.removeItem(this.storageName) 54 | } 55 | 56 | find(predicate: (value: T, index: number) => boolean): T | null { 57 | return this.memoryCache.find(predicate) || null 58 | } 59 | 60 | getByIndex(idx: number): T | null { 61 | return this.memoryCache[ idx ] 62 | } 63 | 64 | async getAll(): Promise { 65 | const storage = await this.storage.getItem(this.storageName) 66 | const storedItems = TypeChecks.isString(storage) 67 | // TODO: ? - Need partial JSON decoding for this 68 | ? storage.split("|||").map(this.fromJSON) 69 | : [] 70 | 71 | return storedItems 72 | } 73 | 74 | indexOf(item: T): number { 75 | return this.memoryCache.indexOf(item) 76 | } 77 | 78 | length(): number { 79 | return this.memoryCache.length 80 | } 81 | 82 | 83 | // ENCODING 84 | 85 | fromJSON(a: string): T { 86 | return JSON.parse(a) 87 | } 88 | 89 | toJSON(a: T): string { 90 | return JSON.stringify(a) 91 | } 92 | 93 | 94 | // DICTIONARY 95 | 96 | getByKey(key: string): T | null { 97 | return this.dictionary[ key ] 98 | } 99 | 100 | toDictionary(items: T[]): Record { 101 | return items.reduce( 102 | (acc, value, idx) => ({ ...acc, [ idx.toString() ]: value }), 103 | {} 104 | ) 105 | } 106 | 107 | } -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import * as Crypto from "./components/crypto/implementation.js" 2 | import * as Storage from "./components/storage/implementation.js" 3 | 4 | import * as Events from "./events.js" 5 | import * as TypeChecks from "./common/type-checks.js" 6 | 7 | import { Maybe } from "./common/types.js" 8 | import FileSystem from "./fs/index.js" 9 | 10 | 11 | // ✨ 12 | 13 | 14 | export class Session { 15 | 16 | #crypto: Crypto.Implementation 17 | #storage: Storage.Implementation 18 | 19 | #eventEmitter: Events.Emitter> 20 | 21 | fs?: FileSystem 22 | type: string 23 | username: string 24 | 25 | constructor(props: { 26 | crypto: Crypto.Implementation 27 | storage: Storage.Implementation 28 | 29 | eventEmitter: Events.Emitter> 30 | 31 | fs?: FileSystem 32 | type: string 33 | username: string 34 | }) { 35 | this.#crypto = props.crypto 36 | this.#storage = props.storage 37 | 38 | this.#eventEmitter = props.eventEmitter 39 | 40 | this.fs = props.fs 41 | this.type = props.type 42 | this.username = props.username 43 | 44 | this.#eventEmitter.emit("session:create", { session: this }) 45 | } 46 | 47 | 48 | async destroy() { 49 | this.#eventEmitter.emit("session:destroy", { username: this.username }) 50 | 51 | await this.#storage.removeItem(this.#storage.KEYS.ACCOUNT_UCAN) 52 | await this.#storage.removeItem(this.#storage.KEYS.CID_LOG) 53 | await this.#storage.removeItem(this.#storage.KEYS.SESSION) 54 | await this.#storage.removeItem(this.#storage.KEYS.UCANS) 55 | 56 | await this.#crypto.keystore.clearStore() 57 | 58 | if (this.fs) this.fs.deactivate() 59 | } 60 | 61 | } 62 | 63 | 64 | 65 | // INFO 66 | 67 | 68 | type SessionInfo = { 69 | type: string 70 | username: string 71 | } 72 | 73 | 74 | export function isSessionInfo(a: unknown): a is SessionInfo { 75 | return TypeChecks.isObject(a) 76 | && TypeChecks.hasProp(a, "username") 77 | && TypeChecks.hasProp(a, "type") 78 | } 79 | 80 | /** 81 | * Begin to restore a `Session` by looking up the `SessionInfo` in the storage. 82 | */ 83 | export async function restore(storage: Storage.Implementation): Promise> { 84 | return storage 85 | .getItem(storage.KEYS.SESSION) 86 | .then((a: unknown) => a ? a as string : null) 87 | .then(a => a ? JSON.parse(a) : null) 88 | .then(a => isSessionInfo(a) ? a : null) 89 | } 90 | 91 | /** 92 | * Prepare the system for the creation of a `Session` 93 | * by adding the necessary info to the storage. 94 | */ 95 | export function provide(storage: Storage.Implementation, info: SessionInfo): Promise { 96 | return storage.setItem( 97 | storage.KEYS.SESSION, 98 | JSON.stringify({ type: info.type, username: info.username }) 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/ucan/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types.js" 2 | export * from "./token.js" 3 | -------------------------------------------------------------------------------- /src/ucan/types.ts: -------------------------------------------------------------------------------- 1 | export type SessionKey = { 2 | sessionKey: string 3 | } 4 | 5 | export type Fact = SessionKey | Record 6 | 7 | export type Resource = 8 | "*" | Record 9 | 10 | export type Potency = string | Record | undefined | null 11 | 12 | export type UcanHeader = { 13 | alg: string 14 | typ: string 15 | uav: string 16 | } 17 | 18 | export type UcanPayload = { 19 | aud: string 20 | exp: number 21 | fct: Array 22 | iss: string 23 | nbf: number 24 | prf: string | null 25 | ptc: Potency 26 | rsc: Resource 27 | } 28 | 29 | export type Ucan = { 30 | header: UcanHeader 31 | payload: UcanPayload 32 | signature: string | null 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tests/did/ed25519.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import expect from "expect" 3 | 4 | import * as DID from "../../src/did/index.js" 5 | import * as Ucan from "../../src/ucan/index.js" 6 | import { components } from "../helpers/components.js" 7 | import { didToPublicKey } from "../../src/did/index.js" 8 | 9 | 10 | describe("Ed25519 Signatures", () => { 11 | 12 | it("can verify a UCAN signature", async function () { 13 | const encodedUcan = "eyJ1YXYiOiIxLjAuMCIsImFsZyI6IkVkRFNBIiwiY3R5IjpudWxsLCJ0eXAiOiJKV1QifQ.eyJwdGMiOiJBUFBFTkQiLCJuYmYiOjE2MTg0MjU4NzYsInJzYyI6eyJ3bmZzIjoiLyJ9LCJleHAiOjE2MTg0MjU5MzYsImlzcyI6ImRpZDprZXk6ejZNa3BoTWtYc24ybzVnN2E4M292MndjalBOeXNkZXlNMm9CdEVaUlphRXJqSlU1IiwicHJmIjpudWxsLCJhdWQiOiJkaWQ6a2V5Ono2TWtnWUdGM3RobjhrMUZ2NHA0ZFdYS3RzWENuTEg3cTl5dzRRZ05QVUxEbURLQiIsImZjdCI6W119.DItB729fJHKYhVuhjpXFOyqJeJwSpa8y5cAvbkdzzTbKTUEpKv5YfgKn5FWKzY_cnCeCLjqL_Zw9gto7kPqVCw" 14 | const u = Ucan.decode(encodedUcan) 15 | 16 | const encodedHeader = Ucan.encodeHeader(u.header) 17 | const encodedPayload = Ucan.encodePayload(u.payload) 18 | 19 | const isValid = await components.crypto.did.keyTypes[ "ed25519" ].verify({ 20 | message: Uint8arrays.fromString(`${encodedHeader}.${encodedPayload}`, "utf8"), 21 | publicKey: didToPublicKey(components.crypto, u.payload.iss).publicKey, 22 | signature: Uint8arrays.fromString(u.signature || "", "base64url") 23 | }) 24 | 25 | expect(isValid).toBe(true) 26 | }) 27 | 28 | it("can verify a JWT signature", async function () { 29 | const jwt = "eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc.hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg" 30 | const s = jwt.split(".") 31 | 32 | const isValid = await components.crypto.did.keyTypes[ "ed25519" ].verify({ 33 | message: Uint8arrays.fromString(s.slice(0, 2).join("."), "utf8"), 34 | publicKey: Uint8arrays.fromString("11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo", "base64pad"), 35 | signature: Uint8arrays.fromString(s[ 2 ], "base64url") 36 | }) 37 | 38 | expect(isValid).toBe(true) 39 | }) 40 | 41 | }) 42 | -------------------------------------------------------------------------------- /tests/did/pubkeyToDid.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import expect from "expect" 3 | 4 | import * as DID from "../../src/did/index.js" 5 | import { components } from "../helpers/components.js" 6 | 7 | 8 | describe("publicKeyToDid", () => { 9 | 10 | it("handles RSA Keys", async function () { 11 | const expectedDid = "did:key:z13V3Sog2YaUKhdGCmgx9UZuW1o1ShFJYc6DvGYe7NTt689NoL2RtpVs65Zw899YrTN9WuxdEEDm54YxWuQHQvcKfkZwa8HTgokHxGDPEmNLhvh69zUMEP4zjuARQ3T8bMUumkSLGpxNe1bfQX624ef45GhWb3S9HM3gvAJ7Qftm8iqnDQVcxwKHjmkV4hveKMTix4bTRhieVHi1oqU4QCVy4QPWpAAympuCP9dAoJFxSP6TNBLY9vPKLazsg7XcFov6UuLWsEaxJ5SomCpDx181mEgW2qTug5oQbrJwExbD9CMgXHLVDE2QgLoQMmgsrPevX57dH715NXC2uY6vo2mYCzRY4KuDRUsrkuYCkewL8q2oK1BEDVvi3Sg8pbC9QYQ5mMiHf8uxiHxTAmPedv8" 12 | const pubkey = Uint8arrays.fromString("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnzyis1ZjfNB0bBgKFMSvvkTtwlvBsaJq7S5wA+kzeVOVpVWwkWdVha4s38XM/pa/yr47av7+z3VTmvDRyAHcaT92whREFpLv9cj5lTeJSibyr/Mrm/YtjCZVWgaOYIhwrXwKLqPr/11inWsAkfIytvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47XUu0t8Y0e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWbV6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9MwIDAQAB", "base64pad") 13 | const d = DID.publicKeyToDid(components.crypto, pubkey, "rsa") 14 | 15 | expect(d).toEqual(expectedDid) 16 | }) 17 | 18 | it("handles Ed25519 Keys", async function () { 19 | const expectedDid = "did:key:z6MkgYGF3thn8k1Fv4p4dWXKtsXCnLH7q9yw4QgNPULDmDKB" 20 | const pubkey = Uint8arrays.fromString("Hv+AVRD2WUjUFOsSNbsmrp9fokuwrUnjBcr92f0kxw4=", "base64pad") 21 | const d = DID.publicKeyToDid(components.crypto, pubkey, "ed25519") 22 | 23 | expect(d).toEqual(expectedDid) 24 | }) 25 | 26 | it("handles BLS12-381 Keys", async function () { 27 | const expectedDid = "did:key:z6HpYD1br5P4QVh5rjRGAkBfKMWes44uhKmKdJ6dN2Nm9gHK" 28 | const pubkey = Uint8arrays.fromString("Hv+AVRD2WUjUFOsSNbsmrp9fokuwrUnjBcr92f0kxw4=", "base64pad") 29 | const d = DID.publicKeyToDid(components.crypto, pubkey, "bls12-381") 30 | 31 | expect(d).toEqual(expectedDid) 32 | }) 33 | 34 | }) 35 | -------------------------------------------------------------------------------- /tests/encoding.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | import * as cbor from "@ipld/dag-cbor" 3 | import { SymmAlg } from "keystore-idb/types.js" 4 | 5 | import { components } from "./helpers/components.js" 6 | 7 | 8 | describe("cbor encoding in node", () => { 9 | 10 | // This test is a regression test. There used to be problems due to Buffer vs. Uint8Array differences. 11 | 12 | it("works with encryption in between", async () => { 13 | const key = await components.crypto.aes.genKey(SymmAlg.AES_GCM) 14 | const keyStr = await components.crypto.aes.exportKey(key) 15 | 16 | const message = { 17 | hello: "world!" 18 | } 19 | const encoded = cbor.encode(message) 20 | const cipher = await components.crypto.aes.encrypt(encoded, keyStr, SymmAlg.AES_GCM) 21 | const decipher = await components.crypto.aes.decrypt(cipher, keyStr, SymmAlg.AES_GCM) 22 | const decoded = cbor.decode(decipher) 23 | 24 | expect(decoded).toEqual(message) 25 | }) 26 | 27 | }) 28 | -------------------------------------------------------------------------------- /tests/fixtures/odd-integration-test-v1-0-0.car: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddsdk/ts-odd/f90bde37416d9986d1c0afed406182a95ce7c1d7/tests/fixtures/odd-integration-test-v1-0-0.car -------------------------------------------------------------------------------- /tests/fixtures/odd-integration-test-v2-0-0.car: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddsdk/ts-odd/f90bde37416d9986d1c0afed406182a95ce7c1d7/tests/fixtures/odd-integration-test-v2-0-0.car -------------------------------------------------------------------------------- /tests/fs/concurrency.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import expect from "expect" 3 | 4 | import * as Path from "../../src/path/index.js" 5 | import { emptyFilesystem } from "../helpers/filesystem.js" 6 | 7 | 8 | describe("the filesystem", () => { 9 | 10 | it("performs actions concurrently", async function () { 11 | const fs = await emptyFilesystem() 12 | 13 | const pathA = Path.file("private", "a") 14 | const pathB = Path.file("private", "b") 15 | const pathC = Path.file("private", "c", "foo") 16 | 17 | await Promise.all([ 18 | fs.write(pathA, from_s("x")) 19 | .then(() => fs.write(pathA, from_s("y"))) 20 | .then(() => fs.write(pathA, from_s("z"))), 21 | 22 | fs.write(pathB, from_s("1")) 23 | .then(() => fs.write(pathB, from_s("2"))), 24 | 25 | fs.write(pathC, from_s("bar")), 26 | ]) 27 | 28 | const string = [ 29 | to_s(await fs.read(pathA)), 30 | to_s(await fs.read(pathB)), 31 | to_s(await fs.read(pathC)) 32 | ].join("") 33 | 34 | expect(string).toEqual([ "z", "2", "bar" ].join("")) 35 | }) 36 | 37 | }) 38 | 39 | 40 | function from_s(a: string): Uint8Array { return Uint8arrays.fromString(a) } 41 | function to_s(a: Uint8Array | null): string { return a ? Uint8arrays.toString(a) : "" } 42 | -------------------------------------------------------------------------------- /tests/fs/data.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | 3 | import * as FsData from "../../src/fs/data.js" 4 | import * as Path from "../../src/path/index.js" 5 | import { emptyFilesystem } from "../helpers/filesystem.js" 6 | 7 | 8 | describe("the filesystem", () => { 9 | 10 | it("adds sample data", async function () { 11 | const fs = await emptyFilesystem() 12 | 13 | await FsData.addSampleData(fs) 14 | 15 | expect( 16 | await fs.exists( 17 | Path.file("private", "Welcome.txt") 18 | ) 19 | ).toBe(true) 20 | }) 21 | 22 | }) -------------------------------------------------------------------------------- /tests/fs/exchange.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | import { crypto } from "../helpers/components.js" 3 | import { emptyFilesystem } from "../helpers/filesystem.js" 4 | 5 | import { addPublicExchangeKey, hasPublicExchangeKey } from "../../src/fs/data.js" 6 | 7 | 8 | describe("the filesystem", () => { 9 | 10 | it("adds public exchange key to well-known location", async function () { 11 | const fs = await emptyFilesystem() 12 | await addPublicExchangeKey(crypto, fs) 13 | const exists = await hasPublicExchangeKey(crypto, fs) 14 | 15 | expect(exists).toEqual(true) 16 | }) 17 | 18 | }) 19 | -------------------------------------------------------------------------------- /tests/fs/integration.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import expect from "expect" 3 | 4 | import { loadCARWithRoot } from "../helpers/loadCAR.js" 5 | 6 | import * as Path from "../../src/path/index.js" 7 | import { File } from "../../src/fs/types.js" 8 | import FileSystem from "../../src/fs/filesystem.js" 9 | 10 | import { loadFilesystem } from "../helpers/filesystem.js" 11 | import { isSoftLink } from "../../src/fs/types/check.js" 12 | 13 | 14 | describe("the filesystem", () => { 15 | 16 | it("can load filesystem fixtures", async function () { 17 | const rootCID = await loadCARWithRoot("tests/fixtures/odd-integration-test-v2-0-0.car") 18 | const readKey = Uint8arrays.fromString("pJW/xgBGck9/ZXwQHNPhV3zSuqGlUpXiChxwigwvUws=", "base64pad") 19 | const fs = await loadFilesystem(rootCID, readKey) 20 | 21 | let files = await listFiles(fs, Path.directory("private")) 22 | files = files.concat(await listFiles(fs, Path.directory("private"))) 23 | 24 | expect(files).not.toEqual([]) 25 | }) 26 | }) 27 | 28 | async function listFiles(fs: FileSystem, searchPath: Path.Directory>): Promise { 29 | let files: File[] = [] 30 | for (const [ subName, sub ] of Object.entries(await fs.ls(searchPath))) { 31 | if (isSoftLink(sub)) continue 32 | if (sub.isFile) { 33 | const file = await fs.get(Path.combine(searchPath, Path.file(subName))) as File 34 | files.push(file) 35 | } else { 36 | const subFiles = await listFiles(fs, Path.combine(searchPath, Path.directory(subName))) 37 | files = files.concat(subFiles) 38 | } 39 | } 40 | return files 41 | } 42 | -------------------------------------------------------------------------------- /tests/fs/share.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as CBOR from "@ipld/dag-cbor" 2 | import * as Uint8arrays from "uint8arrays" 3 | import expect from "expect" 4 | 5 | import * as PrivateCheck from "../../src/fs/protocol/private/types/check.js" 6 | import * as DID from "../../src/did/index.js" 7 | import * as Path from "../../src/path/index.js" 8 | import * as Protocol from "../../src/fs/protocol/index.js" 9 | import * as SharingKey from "../../src/fs/protocol/shared/key.js" 10 | 11 | import { SymmAlg } from "../../src/components/crypto/implementation.js" 12 | import { components } from "../helpers/components.js" 13 | import { decodeCID } from "../../src/common/index.js" 14 | import { emptyFilesystem } from "../helpers/filesystem.js" 15 | 16 | import PrivateFile from "../../src/fs/v1/PrivateFile.js" 17 | import PrivateTree from "../../src/fs/v1/PrivateTree.js" 18 | 19 | const wnfsWasmEnabled = process.env.WNFS_WASM != null 20 | const itSkipInWasm = wnfsWasmEnabled ? it.skip : it 21 | 22 | 23 | describe("the filesystem", () => { 24 | 25 | itSkipInWasm("creates shares", async function () { 26 | const fs = await emptyFilesystem() 27 | const counter = 12345678 28 | 29 | await fs.root.setSharedCounter(counter) 30 | 31 | // Test items 32 | const C = Uint8arrays.fromString("🕵️‍♀️", "utf8") 33 | const F = Uint8arrays.fromString("🍻", "utf8") 34 | 35 | await fs.write(Path.file("private", "a", "b", "c.txt"), C) 36 | await fs.write(Path.file("private", "a", "d", "e", "f.txt"), F) 37 | 38 | // Test identifiers 39 | const exchangeKeyPair = await components.crypto.rsa.genKey() 40 | const exchangePubKey = await components.crypto.rsa.exportPublicKey(exchangeKeyPair.publicKey) 41 | const exchangeDID = DID.publicKeyToDid(components.crypto, exchangePubKey, "rsa") 42 | if (!exchangeKeyPair.privateKey) throw new Error("Missing private key in exchange key-pair") 43 | 44 | const senderKeyPair = await components.crypto.rsa.genKey() 45 | const senderPubKey = await components.crypto.rsa.exportPublicKey(senderKeyPair.publicKey) 46 | const senderDID = DID.publicKeyToDid(components.crypto, senderPubKey, "rsa") 47 | 48 | // Create the `/shared` entries 49 | const itemC = await fs.get(Path.file("private", "a", "b", "c.txt")) 50 | const itemE = await fs.get(Path.directory("private", "a", "d", "e")) 51 | 52 | if (!PrivateFile.instanceOf(itemC)) throw new Error("Not a PrivateFile") 53 | if (!PrivateTree.instanceOf(itemE)) throw new Error("Not a PrivateTree") 54 | 55 | await fs.sharePrivate( 56 | [ 57 | Path.file("private", "a", "b", "c.txt"), 58 | Path.directory("private", "a", "d", "e") 59 | ], 60 | { 61 | sharedBy: { rootDid: senderDID, username: "anonymous" }, 62 | shareWith: [ exchangeDID ] 63 | } 64 | ) 65 | 66 | // Test 67 | const shareKey = await SharingKey.create(components.crypto, { 68 | counter: counter, 69 | recipientExchangeDid: exchangeDID, 70 | senderRootDid: senderDID 71 | }) 72 | 73 | const createdLink = fs.root.sharedLinks[ shareKey ] 74 | const sharePayload = await Protocol.basic.getFile(components.depot, decodeCID(createdLink.cid)) 75 | const decryptedPayload: Record = await components.crypto.rsa.decrypt( 76 | sharePayload, 77 | exchangeKeyPair.privateKey 78 | ).then(a => CBOR.decode( 79 | new Uint8Array(a) 80 | )) 81 | 82 | const entryIndexInfo = JSON.parse( 83 | new TextDecoder().decode( 84 | await components.crypto.aes.decrypt( 85 | await Protocol.basic.getFile(components.depot, decodeCID(decryptedPayload.cid)), 86 | decryptedPayload.key, 87 | SymmAlg.AES_GCM 88 | ) 89 | ) 90 | ) 91 | 92 | if (!PrivateCheck.isPrivateTreeInfo(entryIndexInfo)) { 93 | throw new Error("Entry index is not a PrivateTree") 94 | } 95 | 96 | const entryIndex = await PrivateTree.fromInfo( 97 | components.crypto, 98 | components.depot, 99 | components.manners, 100 | components.reference, 101 | 102 | fs.root.mmpt, 103 | decryptedPayload.symmKey, 104 | entryIndexInfo 105 | ) 106 | 107 | const resultC: any = entryIndex.header.links[ "c.txt" ] 108 | const resultE: any = entryIndex.header.links[ "e" ] 109 | 110 | expect(resultC.privateName).toEqual(await itemC.getName()) 111 | expect(resultE.privateName).toEqual(await itemE.getName()) 112 | 113 | // Should increase the counter 114 | expect(fs.root.sharedCounter).toEqual(counter + 1) 115 | }) 116 | 117 | }) 118 | -------------------------------------------------------------------------------- /tests/fs/tree.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import expect from "expect" 3 | 4 | import * as Path from "../../src/path/index.js" 5 | import { emptyFilesystem } from "../helpers/filesystem.js" 6 | 7 | 8 | describe("the filesystem", () => { 9 | 10 | it("creates parent directories automatically", async function () { 11 | const fs = await emptyFilesystem() 12 | const expected = Uint8arrays.fromString("content", "utf8") 13 | 14 | const privatePath = Path.file("private", "a", "b", "c.txt") 15 | const publicPath = Path.file("public", "a", "b", "c.txt") 16 | 17 | await fs.write(privatePath, expected) 18 | await fs.write(publicPath, expected) 19 | 20 | const string = [ 21 | await fs.read(privatePath), 22 | await fs.read(publicPath) 23 | ].map( 24 | a => a ? Uint8arrays.toString(a, "utf8") : "" 25 | ).join("/") 26 | 27 | expect(string).toEqual("content/content") 28 | }) 29 | 30 | }) 31 | -------------------------------------------------------------------------------- /tests/fs/versioning.node.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import expect from "expect" 3 | 4 | import * as Path from "../../src/path/index.js" 5 | import * as Versions from "../../src/fs/versions.js" 6 | import { checkFileSystemVersion } from "../../src/filesystem.js" 7 | 8 | import { configuration, depot } from "../helpers/components.js" 9 | import { emptyFilesystem } from "../helpers/filesystem.js" 10 | 11 | 12 | describe("the filesystem versioning system", () => { 13 | 14 | const content = Uint8arrays.fromString("Hello, World!", "utf8") 15 | 16 | it("throws an error if the version is too high", async function () { 17 | const fs = await emptyFilesystem() 18 | await fs.write(Path.file("public", "some", "file.txt"), content) 19 | await fs.root.setVersion(Versions.encode(Versions.latest.major + 2, 0, 0)) // latest + 2, because wnfsWasm is latest + 1 20 | const changedCID = await fs.root.put() 21 | 22 | await expect(checkFileSystemVersion(depot, configuration, changedCID)).rejects.toBeDefined() 23 | }) 24 | 25 | it("throws an error if the version is too low", async function () { 26 | const fs = await emptyFilesystem() 27 | await fs.write(Path.file("public", "some", "file.txt"), content) 28 | await fs.root.setVersion(Versions.encode(Versions.latest.major - 1, 0, 0)) 29 | const changedCID = await fs.root.put() 30 | 31 | await expect(checkFileSystemVersion(depot, configuration, changedCID)).rejects.toBeDefined() 32 | }) 33 | 34 | }) 35 | -------------------------------------------------------------------------------- /tests/fs/wasm/public.test.ts: -------------------------------------------------------------------------------- 1 | import * as Uint8arrays from "uint8arrays" 2 | import expect from "expect" 3 | 4 | import { PublicFileWasm, PublicRootWasm } from "../../../src/fs/v3/PublicRootWasm.js" 5 | 6 | import { HardLinks } from "../../../src/fs/types.js" 7 | import { CID } from "multiformats" 8 | import { components } from "../../helpers/components.js" 9 | 10 | 11 | 12 | describe("the wasm public root", () => { 13 | 14 | const dependencies = components 15 | 16 | async function simpleExample() { 17 | const root = await PublicRootWasm.empty(dependencies) 18 | await root.mkdir([ "hello", "world" ]) 19 | await root.historyStep() 20 | await root.add([ "hello", "actor", "James" ], Uint8arrays.fromString("Cameron?")) 21 | return root 22 | } 23 | 24 | describe("the simple example", () => { 25 | it("has a hello world directory", async () => { 26 | const root = await simpleExample() 27 | expect(await root.exists([ "hello", "world" ])).toEqual(true) 28 | }) 29 | 30 | it("returns false with exist on non-existing directories", async () => { 31 | const root = await simpleExample() 32 | expect(await root.exists([ "bogus", "path" ])).toEqual(false) 33 | }) 34 | 35 | it("store- and load-roundtrips", async () => { 36 | const cid = await (await simpleExample()).put() 37 | const root = await PublicRootWasm.fromCID(dependencies, cid) 38 | expect(await root.exists([ "hello", "world" ])).toEqual(true) 39 | }) 40 | 41 | it("has a 'James' file", async () => { 42 | const root = await simpleExample() 43 | const result = await root.cat([ "hello", "actor", "James" ]) as Uint8Array 44 | expect(Uint8arrays.toString(result)).toEqual("Cameron?") 45 | }) 46 | 47 | it("can list the 'hello' directory contents correctly", async () => { 48 | const root = await simpleExample() 49 | const lsResult = await root.ls([ "hello" ]) as HardLinks 50 | expect(lsResult[ "actor" ].name).toEqual("actor") 51 | expect(lsResult[ "actor" ].isFile).toEqual(false) 52 | expect(lsResult[ "actor" ].cid).toBeInstanceOf(CID) 53 | expect(lsResult[ "world" ].name).toEqual("world") 54 | expect(lsResult[ "world" ].isFile).toEqual(false) 55 | expect(lsResult[ "world" ].cid).toBeInstanceOf(CID) 56 | }) 57 | 58 | it("can list the 'hello/actor' directory contents and shows a file", async () => { 59 | const root = await simpleExample() 60 | const lsResult = await root.ls([ "hello", "actor" ]) as HardLinks 61 | expect(lsResult[ "James" ].name).toEqual("James") 62 | expect(lsResult[ "James" ].isFile).toEqual(true) 63 | }) 64 | 65 | it("can read the metadata of some file with .get()", async () => { 66 | const root = await simpleExample() 67 | const file = await root.get([ "hello", "actor", "James" ]) 68 | if (!(file instanceof PublicFileWasm)) { 69 | throw new Error(`Expected file to be instance of PublicFileWasm`) 70 | } 71 | expect(file.header.metadata.isFile).toEqual(true) 72 | expect(typeof file.header.metadata.unixMeta.ctime).toBe("number") 73 | expect(typeof file.header.metadata.unixMeta.mtime).toBe("number") 74 | }) 75 | }) 76 | 77 | }) 78 | -------------------------------------------------------------------------------- /tests/helpers/fileContent.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check" 2 | 3 | 4 | /** Public and Private file content */ 5 | 6 | type FileContent = 7 | fc.Arbitrary< 8 | { type: string; val: Uint8Array } 9 | > 10 | 11 | export const fileContent: () => FileContent = () => { 12 | return fc.oneof( 13 | { arbitrary: rawFileContent(), weight: 1 }, 14 | ) 15 | } 16 | 17 | /** File content generators */ 18 | 19 | const rawFileContent = () => { 20 | return fc.record({ type: fc.constant("rawFileContent"), val: fc.uint8Array({ minLength: 1 }) }) 21 | } 22 | -------------------------------------------------------------------------------- /tests/helpers/filesystem.ts: -------------------------------------------------------------------------------- 1 | import { CID } from "multiformats/cid" 2 | 3 | import * as Identifiers from "../../src/common/identifiers.js" 4 | import * as Events from "../../src/events.js" 5 | import * as Path from "../../src/path/index.js" 6 | 7 | import FileSystem from "../../src/fs/filesystem.js" 8 | import { account, components, crypto } from "./components.js" 9 | 10 | 11 | export function emptyFilesystem(version?: string): Promise { 12 | return FileSystem.empty({ 13 | account, 14 | dependencies: components, 15 | eventEmitter: Events.createEmitter(), 16 | localOnly: true, 17 | permissions: { 18 | fs: { 19 | public: [ Path.root() ], 20 | private: [ Path.root() ] 21 | } 22 | }, 23 | version 24 | }) 25 | } 26 | 27 | 28 | export async function loadFilesystem(cid: CID, readKey?: Uint8Array): Promise { 29 | if (readKey != null) { 30 | await crypto.keystore.importSymmKey( 31 | readKey, 32 | await Identifiers.readKey({ 33 | accountDID: account.rootDID, 34 | crypto, 35 | path: Path.directory("private") 36 | }) 37 | ) 38 | } 39 | 40 | const fs = await FileSystem.fromCID(cid, { 41 | account, 42 | dependencies: components, 43 | eventEmitter: Events.createEmitter(), 44 | localOnly: true, 45 | permissions: { 46 | fs: { 47 | public: [ Path.root() ], 48 | private: [ Path.root() ] 49 | } 50 | } 51 | }) 52 | 53 | if (fs == null) { 54 | throw new Error(`Couldn't load filesystem from CID ${cid} (and readKey ${readKey})`) 55 | } 56 | 57 | return fs 58 | } 59 | -------------------------------------------------------------------------------- /tests/helpers/loadCAR.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import { CarBlockIterator } from "@ipld/car" 3 | import { CID } from "multiformats" 4 | import { inMemoryDepot } from "./components.js" 5 | 6 | 7 | /** 8 | * @returns the roots defined in the CAR file 9 | */ 10 | export async function loadCAR(filepath: string): Promise<{ roots: CID[] }> { 11 | const inStream = fs.createReadStream(filepath) 12 | const reader = await CarBlockIterator.fromIterable(inStream) 13 | 14 | try { 15 | const roots = await reader.getRoots() 16 | 17 | for await (const { cid, bytes } of reader) { 18 | inMemoryDepot[ cid.toString() ] = bytes 19 | } 20 | 21 | return { roots } 22 | } finally { 23 | inStream.close() 24 | } 25 | } 26 | 27 | export async function loadCARWithRoot(filepath: string): Promise { 28 | const { roots } = await loadCAR(filepath) 29 | const [ rootCID ] = roots 30 | 31 | if (rootCID == null) { 32 | throw new Error(`CAR file at ${filepath} doesn't have a root specified.`) 33 | } 34 | 35 | return rootCID 36 | } 37 | -------------------------------------------------------------------------------- /tests/helpers/localforage/in-memory-storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * In memory storage. 3 | * This in memory storage implements the methods of localForage. 4 | */ 5 | export class Storage { 6 | storage: { [key: string]: any} 7 | 8 | constructor() { 9 | this.storage = {} 10 | } 11 | 12 | getItem = (key: string): Promise => { 13 | return new Promise(resolve => { 14 | const val = this.storage[key] || null 15 | 16 | // https://localforage.github.io/localForage/#data-api-getitem 17 | // "Even if undefined is saved, null will be returned by getItem(). 18 | // This is due to a limitation in localStorage, and for compatibility 19 | // reasons localForage cannot store the value undefined." 20 | const checkedVal = val === undefined ? null : val 21 | 22 | resolve(checkedVal) 23 | }) 24 | } 25 | 26 | setItem = (key: string, val: T): Promise => { 27 | return new Promise(resolve => { 28 | this.storage[key] = val 29 | resolve(val) 30 | }) 31 | } 32 | 33 | removeItem = (key: string): Promise => { 34 | return new Promise(resolve => { 35 | delete this.storage[key] 36 | resolve() 37 | }) 38 | } 39 | 40 | clear = (): Promise => { 41 | return new Promise(resolve => { 42 | this.storage = {} 43 | resolve() 44 | }) 45 | } 46 | } -------------------------------------------------------------------------------- /tests/helpers/paths.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check" 2 | 3 | /** Path segment. 4 | * Cannot be an empty string or contain '/'. 5 | */ 6 | 7 | export const pathSegment: () => fc.Arbitrary = () => { 8 | return fc.hexaString({ minLength: 1, maxLength: 20 }) 9 | } 10 | 11 | /** Path segment pairs. 12 | * Members should be unique within the pair, but also across test runs. 13 | * The minLength four should generate unique paths in most cases, but 14 | * there may be occassional collisions 15 | */ 16 | 17 | export const pathSegmentPair: () => fc.Arbitrary<{ first: string; second: string }> = () => { 18 | return fc.uniqueArray( 19 | fc.hexaString({ minLength: 4, maxLength: 20 }), 20 | { minLength: 2, maxLength: 2 } 21 | ).map(([ first, second ]) => ({ first, second })) 22 | } -------------------------------------------------------------------------------- /tests/index.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | import * as DID from "../src/did/index.js" 3 | import * as ODD from "../src/index.js" 4 | import { components, configuration, username } from "./helpers/components.js" 5 | 6 | 7 | describe("accountDID shorthand", async () => { 8 | const program = await ODD.assemble(configuration, { ...components }) 9 | 10 | expect(await program.accountDID(username)).toEqual( 11 | await DID.write(components.crypto) 12 | ) 13 | }) 14 | -------------------------------------------------------------------------------- /tests/mocha-hook.ts: -------------------------------------------------------------------------------- 1 | // declare module "mocha" { 2 | // export interface Context {} 3 | // } 4 | 5 | export const mochaHooks = {} 6 | 7 | function errorContext(functionName: string) { 8 | return `Called "${functionName}" without a mocha test context. Make sure to run your tests as "it(..., async function(){}" and to provide "this": "${functionName}(this)"` 9 | } -------------------------------------------------------------------------------- /tests/ucan/ucan.node.test.ts: -------------------------------------------------------------------------------- 1 | import expect from "expect" 2 | import { webcrypto } from "one-webcrypto" 3 | 4 | import * as DID from "../../src/did/index.js" 5 | import * as Ucan from "../../src/ucan/index.js" 6 | import { components, createCryptoComponent } from "../helpers/components.js" 7 | 8 | 9 | describe("UCAN", () => { 10 | 11 | const dependencies = { crypto: components.crypto } 12 | 13 | it("can be built", async function () { 14 | const u = await Ucan.build({ 15 | dependencies, 16 | audience: await randomRsaDid(), 17 | issuer: await DID.ucan(components.crypto) 18 | }) 19 | 20 | expect(await Ucan.isValid(components.crypto, u)).toBe(true) 21 | }) 22 | 23 | it("can validate a UCAN with a valid proof", async function () { 24 | const cryptoOther = await createCryptoComponent() 25 | const cryptoMain = components.crypto 26 | 27 | const issB = await DID.ucan(cryptoMain) 28 | 29 | // Proof 30 | const issA = await DID.ucan(cryptoOther) 31 | const prf = await Ucan.build({ 32 | dependencies: { crypto: cryptoOther }, 33 | audience: issB, 34 | issuer: issA 35 | }) 36 | 37 | // Shell 38 | const u = await Ucan.build({ 39 | dependencies: { crypto: cryptoMain }, 40 | audience: await randomRsaDid(), 41 | issuer: issB, 42 | proof: Ucan.encode(prf) 43 | }) 44 | 45 | expect(await Ucan.isValid(components.crypto, u)).toBe(true) 46 | }) 47 | 48 | it("can validate a UCAN with a sessionKey fact", async function () { 49 | const sessionKey = "RANDOM KEY" 50 | const u = await Ucan.build({ 51 | dependencies, 52 | issuer: await DID.ucan(components.crypto), 53 | audience: await randomRsaDid(), 54 | lifetimeInSeconds: 60 * 5, // 5 minutes 55 | facts: [ { sessionKey } ] 56 | }) 57 | 58 | expect(await Ucan.isValid(components.crypto, u)).toBe(true) 59 | }) 60 | 61 | it("decodes and reencodes UCAN to the same value", async function () { 62 | const u = "eyJ1YXYiOiIxLjAuMCIsImFsZyI6IkVkRFNBIiwiY3R5IjpudWxsLCJ0eXAiOiJKV1QifQ.eyJwdGMiOiJBUFBFTkQiLCJuYmYiOjE2MTg0MjU4NzYsInJzYyI6eyJ3bmZzIjoiLyJ9LCJleHAiOjE2MTg0MjU5MzYsImlzcyI6ImRpZDprZXk6ejZNa3BoTWtYc24ybzVnN2E4M292MndjalBOeXNkZXlNMm9CdEVaUlphRXJqSlU1IiwicHJmIjpudWxsLCJhdWQiOiJkaWQ6a2V5Ono2TWtnWUdGM3RobjhrMUZ2NHA0ZFdYS3RzWENuTEg3cTl5dzRRZ05QVUxEbURLQiIsImZjdCI6W119.DItB729fJHKYhVuhjpXFOyqJeJwSpa8y5cAvbkdzzTbKTUEpKv5YfgKn5FWKzY_cnCeCLjqL_Zw9gto7kPqVCw" 63 | const decoded = Ucan.decode(u) 64 | const reencoded = Ucan.encode(decoded) 65 | 66 | expect(u === reencoded).toBe(true) 67 | }) 68 | 69 | }) 70 | 71 | 72 | async function randomRsaDid(): Promise { 73 | const key = await webcrypto.subtle.generateKey( 74 | { 75 | name: "RSASSA-PKCS1-v1_5", 76 | modulusLength: 2048, 77 | publicExponent: new Uint8Array([ 0x01, 0x00, 0x01 ]), 78 | hash: "SHA-256" 79 | }, 80 | false, 81 | [ "sign", "verify" ] 82 | ) 83 | 84 | const exportedKey = await webcrypto.subtle.exportKey("spki", key.publicKey) 85 | 86 | return DID.publicKeyToDid( 87 | components.crypto, 88 | new Uint8Array(exportedKey), 89 | "rsa" 90 | ) 91 | } -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "exclude": [] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "ES2020", 5 | "module": "ES2020", 6 | "lib": [ 7 | "dom", 8 | "es2021", 9 | "webworker" 10 | ], 11 | "strict": true, 12 | "sourceMap": true, 13 | "declaration": true, 14 | "allowSyntheticDefaultImports": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "skipLibCheck": true, 18 | "declarationDir": "lib", 19 | "outDir": "lib" 20 | }, 21 | "include": [ 22 | "src/**/*" 23 | ], 24 | "exclude": [ 25 | "src/**/*.test.ts", 26 | "tests/**/*", 27 | "src/setup/node.ts" 28 | ], 29 | "ts-node": { 30 | "compilerOptions": { 31 | "target": "es2022", 32 | "module": "es2022" 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "./src/index.ts" 4 | ], 5 | "exclude": [ 6 | "**/*.test.ts" 7 | ], 8 | "excludeExternals": true, 9 | "name": "ODD SDK", 10 | "out": "docs", 11 | "theme": "default" 12 | } --------------------------------------------------------------------------------