├── .github └── workflows │ ├── release.yaml │ └── tests.yaml ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── demos ├── automerge-repo-todos │ ├── LICENSE │ ├── index.html │ ├── package.json │ ├── playwright.config.ts │ ├── postcss.config.cjs │ ├── src │ │ ├── components │ │ │ ├── App.tsx │ │ │ ├── AuthContextProvider.tsx │ │ │ ├── Card.tsx │ │ │ ├── CreateTeam.tsx │ │ │ ├── FirstUseOption.tsx │ │ │ ├── FirstUseSetup.tsx │ │ │ ├── JoinTeam.tsx │ │ │ ├── Layout.tsx │ │ │ ├── RequestUserName.tsx │ │ │ ├── SignOutButton.tsx │ │ │ ├── TeamAdmin.tsx │ │ │ ├── TeamMembers.tsx │ │ │ ├── Todo.tsx │ │ │ └── Todos.tsx │ │ ├── hooks │ │ │ ├── useAuth.tsx │ │ │ ├── useLocalState.ts │ │ │ └── useRootDocument.ts │ │ ├── index.css │ │ ├── main.tsx │ │ ├── types.ts │ │ ├── util │ │ │ ├── createDevice.ts │ │ │ ├── getDeviceNameFromUa.ts │ │ │ ├── getRootDocumentIdFromTeam.ts │ │ │ ├── initializeAuthRepo.ts │ │ │ ├── parseInvitationCode.ts │ │ │ ├── pluralize.ts │ │ │ ├── storeRootDocumentIdOnTeam.ts │ │ │ └── syncServerUrl.ts │ │ └── vite-env.d.ts │ ├── syncserver.js │ ├── tailwind.config.cjs │ ├── test │ │ ├── auth.test.ts │ │ ├── helpers │ │ │ ├── App.ts │ │ │ └── expect.ts │ │ └── todos.test.ts │ ├── tsconfig.json │ └── vite.config.ts └── taco-chat │ ├── .npmrc │ ├── README.md │ ├── cypress.config.ts │ ├── cypress │ ├── e2e │ │ ├── basic.cy.ts │ │ ├── concurrency.cy.ts │ │ ├── connection.cy.ts │ │ ├── invitations.cy.ts │ │ └── membership.cy.ts │ ├── parallel-weights.json │ ├── support │ │ ├── assertions │ │ │ ├── be.admin.ts │ │ │ ├── be.onStartScreen.ts │ │ │ ├── be.online.ts │ │ │ └── have.member.ts │ │ ├── commands │ │ │ ├── addDevice.ts │ │ │ ├── addToTeam.ts │ │ │ ├── adminButton.ts │ │ │ ├── chain.ts │ │ │ ├── demote.ts │ │ │ ├── hide.ts │ │ │ ├── index.ts │ │ │ ├── invite.ts │ │ │ ├── inviteDevice.ts │ │ │ ├── isConnectedTo.ts │ │ │ ├── join.ts │ │ │ ├── peerConnectionStatus.ts │ │ │ ├── promote.ts │ │ │ ├── remove.ts │ │ │ ├── teamMember.ts │ │ │ ├── teamName.ts │ │ │ ├── toggleOnline.ts │ │ │ └── userName.ts │ │ ├── e2e.ts │ │ ├── helpers.ts │ │ └── types.ts │ └── tsconfig.json │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── scripts │ └── start-relay-server.js │ ├── src │ ├── @types │ │ └── friendly-words │ │ │ └── index.d.ts │ ├── ConnectionManager.ts │ ├── DemoConnection.ts │ ├── EventEmitter.ts │ ├── bubbleEvents.ts │ ├── components │ │ ├── Alerts.tsx │ │ ├── App.tsx │ │ ├── Avatar.tsx │ │ ├── Button.tsx │ │ ├── CardLabel.tsx │ │ ├── Catch.tsx │ │ ├── Chooser.tsx │ │ ├── CreateOrJoinTeam.tsx │ │ ├── DeviceChooser.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── GraphDiagram.tsx │ │ ├── HideButton.tsx │ │ ├── Invite.tsx │ │ ├── Label.tsx │ │ ├── Mermaid.tsx │ │ ├── OnlineToggle.tsx │ │ ├── Peer.tsx │ │ ├── StatusIndicator.tsx │ │ ├── Team.tsx │ │ ├── TeamProvider.tsx │ │ └── Toggle.tsx │ ├── hooks │ │ └── useTeam.tsx │ ├── index.css │ ├── main.tsx │ ├── mermaid.theme.ts │ ├── peers.ts │ ├── theme.ts │ ├── types.ts │ └── util │ │ ├── arrayToMap.ts │ │ ├── randomElement.ts │ │ ├── randomTeamName.ts │ │ └── samePeer.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── vite.config.ts ├── docs ├── api.md ├── connection.md ├── context.md ├── img │ ├── key-graph.png │ ├── key-rotation.png │ ├── lf-auth-demo.gif │ ├── lockboxes.png │ ├── sigchain-action.png │ ├── sigchain-med.png │ ├── sigchain-tiny.png │ ├── sigchain.png │ └── sigchallenge.png ├── internals.md ├── lockbox.md └── team.md ├── lerna.json ├── package.json ├── packages ├── auth-provider-automerge-repo │ ├── README.md │ ├── package.json │ ├── src │ │ ├── AbstractConnection.ts │ │ ├── AnonymousConnection.ts │ │ ├── AuthProvider.ts │ │ ├── AuthenticatedNetworkAdapter.ts │ │ ├── CompositeMap.ts │ │ ├── buildServerUrl.ts │ │ ├── getShareId.ts │ │ ├── index.ts │ │ ├── test │ │ │ ├── AuthProvider.test.ts │ │ │ ├── buildServerUrl.test.ts │ │ │ └── helpers │ │ │ │ ├── authenticated.ts │ │ │ │ ├── setup.ts │ │ │ │ └── synced.ts │ │ └── types.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsup.config.ts ├── auth-syncserver │ ├── README.md │ ├── package.json │ ├── src │ │ ├── SyncServer.ts │ │ ├── index.ts │ │ ├── running.html │ │ └── test │ │ │ ├── SyncServer.test.ts │ │ │ └── helpers │ │ │ └── setup.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsup.config.ts ├── auth │ ├── .npmrc │ ├── LICENSE │ ├── README.md │ ├── docs │ │ ├── api.md │ │ ├── img │ │ │ ├── key-graph.png │ │ │ ├── key-rotation.png │ │ │ ├── lockboxes.png │ │ │ ├── sigchain.png │ │ │ └── sigchallenge.png │ │ └── internals.md │ ├── package.json │ ├── src │ │ ├── connection │ │ │ ├── Connection.ts │ │ │ ├── MessageQueue.ts │ │ │ ├── deriveSharedKey.ts │ │ │ ├── errors.ts │ │ │ ├── getDeviceUserFromGraph.ts │ │ │ ├── helpers.ts │ │ │ ├── identity.ts │ │ │ ├── index.ts │ │ │ ├── message.ts │ │ │ ├── test │ │ │ │ ├── MessageQueue.test.ts │ │ │ │ ├── authentication.test.ts │ │ │ │ ├── connection.test.ts │ │ │ │ ├── deriveSharedKey.test.ts │ │ │ │ ├── encryption.test.ts │ │ │ │ ├── identity.test.ts │ │ │ │ └── sync.test.ts │ │ │ └── types.ts │ │ ├── device │ │ │ ├── createDevice.ts │ │ │ ├── index.ts │ │ │ ├── redact.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── invitation │ │ │ ├── create.ts │ │ │ ├── deriveId.ts │ │ │ ├── generateProof.ts │ │ │ ├── generateStarterKeys.ts │ │ │ ├── index.ts │ │ │ ├── normalize.ts │ │ │ ├── randomSeed.ts │ │ │ ├── test │ │ │ │ └── invitation.test.ts │ │ │ ├── types.ts │ │ │ └── validate.ts │ │ ├── lockbox │ │ │ ├── create.ts │ │ │ ├── index.ts │ │ │ ├── open.ts │ │ │ ├── rotate.ts │ │ │ ├── test │ │ │ │ └── lockbox.test.ts │ │ │ └── types.ts │ │ ├── role │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── server │ │ │ ├── castServer.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── team │ │ │ ├── Team.ts │ │ │ ├── bySeniority.ts │ │ │ ├── constants.ts │ │ │ ├── context.ts │ │ │ ├── createTeam.ts │ │ │ ├── decryptTeamGraph.ts │ │ │ ├── getMissingLinks.ts │ │ │ ├── getTeamState.ts │ │ │ ├── index.ts │ │ │ ├── invalidLinkReducer.ts │ │ │ ├── isAdminOnlyAction.ts │ │ │ ├── load.ts │ │ │ ├── membershipResolver.ts │ │ │ ├── redactUser.ts │ │ │ ├── reducer.ts │ │ │ ├── selectors │ │ │ │ ├── device.ts │ │ │ │ ├── deviceWasRemoved.ts │ │ │ │ ├── hasMember.ts │ │ │ │ ├── hasRole.ts │ │ │ │ ├── hasServer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── invitation.ts │ │ │ │ ├── keyMap.ts │ │ │ │ ├── keyring.ts │ │ │ │ ├── keys.ts │ │ │ │ ├── lockboxesInScope.ts │ │ │ │ ├── member.ts │ │ │ │ ├── memberByDeviceId.ts │ │ │ │ ├── memberHasRole.ts │ │ │ │ ├── memberWasRemoved.ts │ │ │ │ ├── membersInRole.ts │ │ │ │ ├── messages.ts │ │ │ │ ├── role.ts │ │ │ │ ├── server.ts │ │ │ │ ├── serverWasRemoved.ts │ │ │ │ ├── teamKeyring.ts │ │ │ │ ├── test │ │ │ │ │ ├── visibleKeys.test.ts │ │ │ │ │ └── visibleScopes.test.ts │ │ │ │ ├── visibleKeys.ts │ │ │ │ └── visibleScopes.ts │ │ │ ├── serialize.ts │ │ │ ├── setHead.ts │ │ │ ├── teamMachine.ts │ │ │ ├── test │ │ │ │ ├── createTeam.test.ts │ │ │ │ ├── crypto.test.ts │ │ │ │ ├── devices.test.ts │ │ │ │ ├── invitations.test.ts │ │ │ │ ├── keys.test.ts │ │ │ │ ├── members.test.ts │ │ │ │ ├── membershipResolver.test.ts │ │ │ │ ├── messages.test.ts │ │ │ │ ├── roles.test.ts │ │ │ │ └── servers.test.ts │ │ │ ├── transforms │ │ │ │ ├── addDevice.ts │ │ │ │ ├── addMember.ts │ │ │ │ ├── addMemberRoles.ts │ │ │ │ ├── addMessage.ts │ │ │ │ ├── addRole.ts │ │ │ │ ├── addServer.ts │ │ │ │ ├── changeMemberKeys.ts │ │ │ │ ├── changeServerKeys.ts │ │ │ │ ├── collectLockboxes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── postInvitation.ts │ │ │ │ ├── removeDevice.ts │ │ │ │ ├── removeMember.ts │ │ │ │ ├── removeMemberRole.ts │ │ │ │ ├── removeRole.ts │ │ │ │ ├── removeServer.ts │ │ │ │ ├── revokeInvitation.ts │ │ │ │ ├── rotateKeys.ts │ │ │ │ ├── setTeamName.ts │ │ │ │ └── useInvitation.ts │ │ │ ├── types.ts │ │ │ └── validate.ts │ │ ├── test │ │ │ └── auth.benchmark.ts │ │ └── util │ │ │ ├── actionFingerprint.ts │ │ │ ├── arrayToMap.ts │ │ │ ├── arraysAreEqual.ts │ │ │ ├── clone.ts │ │ │ ├── composeTransforms.ts │ │ │ ├── constants.ts │ │ │ ├── getScope.ts │ │ │ ├── graphSummary.ts │ │ │ ├── index.ts │ │ │ ├── keysetSummary.ts │ │ │ ├── lockboxSummary.ts │ │ │ ├── scopesMatch.ts │ │ │ ├── testing │ │ │ ├── TestChannel.ts │ │ │ ├── connectionHelpers.ts │ │ │ ├── constants.ts │ │ │ ├── expect │ │ │ │ ├── toBeValid.ts │ │ │ │ ├── toLookLikeKeyset.ts │ │ │ │ └── vitest.d.ts │ │ │ ├── index.ts │ │ │ ├── joinTestChannel.ts │ │ │ ├── messageSummary.ts │ │ │ └── setup.ts │ │ │ ├── types.ts │ │ │ └── unique.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsup.config.ts │ └── xo.config.cjs ├── crdx │ ├── .npmrc │ ├── README.md │ ├── img │ │ ├── crdx-illustration-01.png │ │ ├── crdx-logo.ai │ │ ├── crdx-logo.png │ │ └── crdx-logo.svg │ ├── package.json │ ├── src │ │ ├── constants.ts │ │ ├── graph │ │ │ ├── README.md │ │ │ ├── append.ts │ │ │ ├── children.ts │ │ │ ├── concurrency.ts │ │ │ ├── createGraph.ts │ │ │ ├── decrypt.ts │ │ │ ├── getEncryptedLinks.ts │ │ │ ├── getHashes.ts │ │ │ ├── getHead.ts │ │ │ ├── getLink.ts │ │ │ ├── getParentMap.ts │ │ │ ├── getParents.ts │ │ │ ├── getPredecessors.ts │ │ │ ├── getRoot.ts │ │ │ ├── getSequence.ts │ │ │ ├── getSuccessors.ts │ │ │ ├── hashLink.ts │ │ │ ├── headsAreEqual.ts │ │ │ ├── index.ts │ │ │ ├── isPredecessor.ts │ │ │ ├── isSuccessor.ts │ │ │ ├── merge.ts │ │ │ ├── redactGraph.ts │ │ │ ├── serialize.ts │ │ │ ├── test │ │ │ │ ├── append.test.ts │ │ │ │ ├── create.test.ts │ │ │ │ ├── decrypt.test.ts │ │ │ │ ├── getChildren.test.ts │ │ │ │ ├── getConcurrentLinks.test.ts │ │ │ │ ├── getParentMap.test.ts │ │ │ │ ├── getPredecessors.test.ts │ │ │ │ ├── getSequence.test.ts │ │ │ │ ├── getSuccessors.test.ts │ │ │ │ ├── merge.test.ts │ │ │ │ └── topoSort.test.ts │ │ │ ├── topoSort.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── keyset │ │ │ ├── README.md │ │ │ ├── createKeyring.ts │ │ │ ├── createKeyset.ts │ │ │ ├── getLatestGeneration.ts │ │ │ ├── index.ts │ │ │ ├── redactKeys.ts │ │ │ ├── test │ │ │ │ ├── createKeyset.test.ts │ │ │ │ ├── getLatestGeneration.test.ts │ │ │ │ └── redactKeys.test.ts │ │ │ └── types.ts │ │ ├── store │ │ │ ├── Store.ts │ │ │ ├── StoreOptions.ts │ │ │ ├── compose.ts │ │ │ ├── createStore.ts │ │ │ ├── index.ts │ │ │ ├── makeMachine.ts │ │ │ ├── test │ │ │ │ ├── counter.test.ts │ │ │ │ ├── createStore.test.ts │ │ │ │ ├── scheduler.test.ts │ │ │ │ ├── scrabble.test.ts │ │ │ │ └── shared │ │ │ │ │ └── counterReducer.ts │ │ │ └── types.ts │ │ ├── sync │ │ │ ├── generateMessage.ts │ │ │ ├── getMissingLinks.ts │ │ │ ├── index.ts │ │ │ ├── initSyncState.ts │ │ │ ├── receiveMessage.ts │ │ │ ├── test │ │ │ │ └── sync.test.ts │ │ │ └── types.ts │ │ ├── user │ │ │ ├── README.md │ │ │ ├── createUser.ts │ │ │ ├── index.ts │ │ │ ├── redact.ts │ │ │ ├── test │ │ │ │ └── user.test.ts │ │ │ └── types.ts │ │ ├── util │ │ │ ├── arrayToMap.ts │ │ │ ├── index.ts │ │ │ ├── messageSummary.ts │ │ │ ├── testing │ │ │ │ ├── Network.ts │ │ │ │ ├── arrayToMap.ts │ │ │ │ ├── expect │ │ │ │ │ ├── toBeValid.ts │ │ │ │ │ ├── toLookLikeKeyset.ts │ │ │ │ │ └── vitest.d.ts │ │ │ │ ├── graph.ts │ │ │ │ └── setup.ts │ │ │ └── types.ts │ │ └── validator │ │ │ ├── index.ts │ │ │ ├── test │ │ │ └── validate.test.ts │ │ │ ├── types.ts │ │ │ ├── validate.ts │ │ │ └── validators.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsup.config.ts ├── crypto │ ├── .npmrc │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── asymmetric.ts │ │ ├── hash.ts │ │ ├── index.ts │ │ ├── randomKey.ts │ │ ├── signatures.ts │ │ ├── stretch.ts │ │ ├── symmetric.ts │ │ ├── test │ │ │ ├── asymmetric.test.ts │ │ │ ├── hash.test.ts │ │ │ ├── randomKey.test.ts │ │ │ ├── signatures.test.ts │ │ │ ├── stretch.test.ts │ │ │ └── symmetric.test.ts │ │ ├── types.ts │ │ └── util │ │ │ ├── base58.ts │ │ │ ├── index.ts │ │ │ ├── keyToBytes.ts │ │ │ ├── keypairToBase58.ts │ │ │ └── util.test.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsup.config.ts └── shared │ ├── package.json │ ├── src │ ├── assert.ts │ ├── debug.ts │ ├── eventPromise.ts │ ├── index.ts │ ├── memoize.ts │ ├── pause.ts │ └── truncateHashes.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts ├── clean-log.js ├── flaky.js └── link-local.js ├── tsconfig.json ├── vitest.config.ts ├── vitest.workspace.js ├── wallaby.conf.cjs └── xo.config.cjs /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize, reopened, ready_for_review, review_requested] 8 | branches: 9 | - main 10 | jobs: 11 | run-tests: 12 | name: Run tests 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: '20.x' 18 | registry-url: 'https://registry.npmjs.org' 19 | - uses: pnpm/action-setup@v4 20 | with: 21 | version: 8 22 | run_install: false 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | ref: ${{ github.ref }} 27 | - name: Install and build 28 | run: | 29 | pnpm install 30 | pnpm build 31 | - name: Run tests 32 | run: pnpm test 33 | - name: Run linter 34 | run: pnpm lint 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .dev-sync-server-data 3 | .DS_Store 4 | .flaky 5 | .logs 6 | .nx 7 | .pnp 8 | .pnp.js 9 | .profiles 10 | .vercel 11 | .vscode/settings.json 12 | .yalc 13 | *.cpuprofile 14 | *.local 15 | *.log 16 | *.tsbuildinfo 17 | automerge-repo-data 18 | build 19 | coverage 20 | cypress/screenshots 21 | cypress/videos 22 | dist 23 | logs 24 | node_modules 25 | package-lock.json 26 | pnpm-works 27 | runner-results 28 | vite.config.ts.timestamp* 29 | yalc.lock 30 | yarn.lock -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true 2 | access=public -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "endOfLine": "auto", 5 | "semi": false, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "es5", 9 | "useTabs": false, 10 | "printWidth": 100, 11 | "overrides": [ 12 | { 13 | "files": "*.json5", 14 | "options": { 15 | "singleQuote": false, 16 | "quoteProps": "preserve", 17 | "trailingComma": "none", 18 | "parser": "json5" 19 | } 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "chrome", 5 | "request": "attach", 6 | "name": "Attach to Cypress", 7 | "port": 9222, 8 | "urlFilter": "http://localhost*", 9 | "webRoot": "${workspaceFolder}", 10 | "sourceMaps": true, 11 | "skipFiles": ["cypress_runner.js"] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /demos/automerge-repo-todos/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-2023, Ink & Switch LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Automerge Repo Todo Demo 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from './Card' 2 | import { Layout } from './Layout' 3 | import { TeamAdmin } from './TeamAdmin.js' 4 | import { Todos } from './Todos' 5 | 6 | export const App = () => { 7 | return ( 8 |
9 |
10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | export const Card = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
{children}
4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/components/FirstUseOption.tsx: -------------------------------------------------------------------------------- 1 | export const FirstUseOption = ({ icon, label, buttonText, onSelect, autoFocus }: Props) => { 2 | return ( 3 |
4 |
5 | {icon} 6 |

{label}

7 |

8 | 15 |

16 |
17 |
18 | ) 19 | } 20 | type Props = { 21 | icon: string 22 | label: React.ReactNode 23 | buttonText: string 24 | autoFocus?: boolean 25 | onSelect: () => void 26 | className?: string 27 | } 28 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | export const Layout = ({ children }: Props) => { 2 | return ( 3 |
4 |
{children}
5 |
6 | ) 7 | } 8 | 9 | type Props = { 10 | children: React.ReactNode 11 | } 12 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/components/SignOutButton.tsx: -------------------------------------------------------------------------------- 1 | import { useLocalState } from '../hooks/useLocalState' 2 | 3 | export const SignOutButton = () => { 4 | const { signOut } = useLocalState() 5 | return ( 6 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { AuthContext } from '../components/AuthContextProvider' 3 | 4 | // Convenience wrapper around our authContext for accessing the auth data and provider 5 | export const useAuth = () => { 6 | const context = useContext(AuthContext) 7 | if (!context) throw new Error('useAuth must be used within an AuthContextProvider') 8 | return context 9 | } 10 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/hooks/useLocalState.ts: -------------------------------------------------------------------------------- 1 | import * as Auth from '@localfirst/auth' 2 | import { useLocalStorage } from '@uidotdev/usehooks' 3 | import { LocalState } from '../types' 4 | import { ShareId } from '@localfirst/auth-provider-automerge-repo' 5 | import { DocumentId } from '@automerge/automerge-repo' 6 | 7 | export const useLocalState = () => { 8 | const initialState: LocalState = {} 9 | const [state, setState] = useLocalStorage('automerge-repo-todos-state', initialState) 10 | 11 | const { userName, user, device, shareId, rootDocumentId } = state 12 | const updateLocalState = (s: Partial) => setState({ ...state, ...s }) 13 | const signOut = () => setState(initialState) 14 | 15 | return { 16 | userName, 17 | user, 18 | device, 19 | shareId, 20 | rootDocumentId, 21 | updateLocalState, 22 | signOut, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/hooks/useRootDocument.ts: -------------------------------------------------------------------------------- 1 | import { stringifyAutomergeUrl, type AutomergeUrl } from '@automerge/automerge-repo' 2 | import { useDocument } from '@automerge/automerge-repo-react-hooks' 3 | import { assert } from '@localfirst/shared' 4 | import { type SharedState } from '../types' 5 | import { useLocalState } from './useLocalState' 6 | 7 | export const useRootDocument = () => { 8 | const { rootDocumentId } = useLocalState() 9 | assert(rootDocumentId) 10 | const rootDocumentUrl: AutomergeUrl = stringifyAutomergeUrl({ 11 | documentId: rootDocumentId, 12 | }) 13 | return useDocument(rootDocumentUrl) 14 | } 15 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@ibm/plex/css/ibm-plex.css' 2 | import ReactDOM from 'react-dom/client' 3 | import { App } from './components/App.js' 4 | import { AuthContextProvider } from './components/AuthContextProvider.js' 5 | import './index.css' 6 | 7 | ReactDOM.createRoot(document.querySelector('#root')!).render( 8 | 9 | 10 | 11 | ) 12 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/util/createDevice.ts: -------------------------------------------------------------------------------- 1 | import * as Auth from '@localfirst/auth' 2 | import { getDeviceNameFromUa } from './getDeviceNameFromUa' 3 | 4 | export const createDevice = (userId: string) => { 5 | const deviceName = getDeviceNameFromUa() 6 | const device = Auth.createDevice({ userId, deviceName }) 7 | return device 8 | } 9 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/util/getDeviceNameFromUa.ts: -------------------------------------------------------------------------------- 1 | import { UAParser } from 'ua-parser-js' 2 | 3 | export const getDeviceNameFromUa = () => { 4 | const { browser, os, device } = UAParser(navigator.userAgent) 5 | return `${device.model ?? os.name} (${browser.name})` 6 | } 7 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/util/getRootDocumentIdFromTeam.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentId } from '@automerge/automerge-repo' 2 | import type * as Auth from '@localfirst/auth' 3 | 4 | export const getRootDocumentIdFromTeam = (team: Auth.Team) => { 5 | // find the last message of type ROOT_DOCUMENT_ID 6 | const rootDocumentId = team 7 | .messages() 8 | .filter(message => message.type === 'ROOT_DOCUMENT_ID') 9 | .pop()?.payload 10 | if (!rootDocumentId) throw new Error('No root document ID found on team') 11 | return rootDocumentId 12 | } 13 | 14 | export type TeamMessage = { 15 | type: 'ROOT_DOCUMENT_ID' 16 | payload: DocumentId 17 | } 18 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/util/parseInvitationCode.ts: -------------------------------------------------------------------------------- 1 | import * as Auth from '@localfirst/auth' 2 | import type { ShareId } from '@localfirst/auth-provider-automerge-repo' 3 | 4 | export const parseInvitationCode = (invitationCode: string) => { 5 | const shareId = invitationCode.slice(0, 12) as ShareId // because a ShareId is 12 characters long - see getShareId 6 | const invitationSeed = invitationCode.slice(12) as Auth.Base58 // the rest of the code is the invitation seed 7 | return { shareId, invitationSeed } 8 | } 9 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/util/pluralize.ts: -------------------------------------------------------------------------------- 1 | export const pluralize = (count: number, word: string) => { 2 | return count === 1 ? word : word + 's' 3 | } 4 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/util/storeRootDocumentIdOnTeam.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentId } from "@automerge/automerge-repo" 2 | import type * as Auth from "@localfirst/auth" 3 | 4 | export const storeRootDocumentIdOnTeam = (team: Auth.Team, id: DocumentId) => { 5 | team.addMessage({ type: "ROOT_DOCUMENT_ID", payload: id }) 6 | } 7 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/util/syncServerUrl.ts: -------------------------------------------------------------------------------- 1 | const protocol = window.location.protocol 2 | const wsProtocol = protocol.replace('http', 'ws') 3 | const isProduction = process.env.NODE_ENV === 'production' 4 | 5 | export const host = isProduction ? process.env.SYNC_SERVER_URL : 'localhost:3030' 6 | if (!host) throw new Error('SYNC_SERVER_URL must be set') 7 | 8 | export const [domain, port] = host.split(':') 9 | 10 | export const url = `${protocol}//${host}` 11 | export const wsUrl = `${wsProtocol}//${host}` 12 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/syncserver.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { LocalFirstAuthSyncServer } from '@localfirst/auth-syncserver' 3 | 4 | const storageDir = '.dev-sync-server-data' 5 | // in development, clear stored data on startup 6 | if (process.env.NODE_ENV === 'development') fs.rmSync(storageDir, { force: true, recursive: true }) 7 | 8 | const DEFAULT_PORT = 3030 9 | const port = Number(process.env.PORT) || DEFAULT_PORT 10 | const host = process.env.HOST || 'localhost' 11 | 12 | const server = new LocalFirstAuthSyncServer(host) 13 | 14 | server.listen({ port, storageDir }) 15 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const colors = require("tailwindcss/colors") 3 | 4 | const emoji = "Segoe UI Emoji" 5 | const mono = "IBM Plex Mono" 6 | const sans = "IBM Plex Sans" 7 | const condensed = "IBM Plex Sans Condensed" 8 | const serif = "IBM Plex Serif" 9 | 10 | module.exports = { 11 | content: ["./**/*.{html,tsx}"], 12 | theme: { 13 | extend: { 14 | fontFamily: { 15 | mono: [mono, emoji, "monospace"], 16 | sans: [sans, emoji, "sans-serif"], 17 | condensed: [condensed, emoji, "sans-serif"], 18 | serif: [serif, emoji, "serif"], 19 | }, 20 | zIndex: {}, 21 | colors: { 22 | primary: colors.blue, 23 | secondary: colors.teal, 24 | neutral: colors.gray, 25 | success: colors.green, 26 | warning: colors.orange, 27 | danger: colors.red, 28 | }, 29 | fontWeight: { 30 | thin: 200, 31 | normal: 500, 32 | bold: 600, 33 | extrabold: 800, 34 | }, 35 | }, 36 | }, 37 | variants: { 38 | opacity: ({ after }) => after(["group-hover", "group-focus", "disabled"]), 39 | textColor: ({ after }) => after(["group-hover", "group-focus"]), 40 | boxShadow: ({ after }) => after(["group-hover", "group-focus"]), 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/test/helpers/expect.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-inferrable-types */ 2 | import { pause } from '@localfirst/shared' 3 | import { expect as _expect, type Page } from '@playwright/test' 4 | import { App } from './App' 5 | 6 | export const expect = (app: App) => { 7 | const toSeeMember = async (page: Page, name: string) => { 8 | const members = app.members() 9 | try { 10 | await _expect(members).toContainText(name) 11 | return { 12 | message: () => 'user is visible', 13 | pass: true, 14 | } 15 | } catch (e: any) { 16 | return { 17 | message: () => `user is not visible`, 18 | pass: false, 19 | } 20 | } 21 | } 22 | return _expect.extend({ 23 | toSeeMember, 24 | toBeLoggedIn: toSeeMember, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /demos/automerge-repo-todos/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "jsx": "react-jsx", 9 | "lib": [ 10 | "DOM", 11 | "DOM.Iterable", 12 | "ESNext" 13 | ], 14 | "module": "ESNext", 15 | "moduleResolution": "Bundler", 16 | "noEmit": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "ESNext", 21 | "useDefineForClassFields": true 22 | }, 23 | "include": [ 24 | "src", 25 | ] 26 | } -------------------------------------------------------------------------------- /demos/automerge-repo-todos/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import wasm from 'vite-plugin-wasm' 4 | import topLevelAwait from 'vite-plugin-top-level-await' 5 | 6 | export default defineConfig({ 7 | plugins: [wasm(), topLevelAwait(), react()], 8 | 9 | worker: { 10 | format: 'es', 11 | plugins: () => [wasm(), topLevelAwait()], 12 | }, 13 | 14 | optimizeDeps: { 15 | // This is necessary because otherwise `vite dev` includes two separate 16 | // versions of the JS wrapper. This causes problems because the JS 17 | // wrapper has a module level variable to track JS side heap 18 | // allocations, and initializing this twice causes horrible breakage 19 | exclude: ['@automerge/automerge-wasm/bundler/bindgen_bg.wasm', '@syntect/wasm'], 20 | }, 21 | 22 | server: { 23 | fs: { 24 | strict: false, 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /demos/taco-chat/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | -------------------------------------------------------------------------------- /demos/taco-chat/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/demos/taco-chat/README.md -------------------------------------------------------------------------------- /demos/taco-chat/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | import createBundler from '@bahmutov/cypress-esbuild-preprocessor' 3 | 4 | export default defineConfig({ 5 | e2e: { 6 | projectId: 'taco', 7 | baseUrl: 'http://localhost:3000', 8 | 9 | fixturesFolder: false, 10 | video: false, 11 | viewportWidth: 1600, 12 | viewportHeight: 1200, 13 | defaultCommandTimeout: 10000, 14 | 15 | // experimentalRunAllSpecs: true, 16 | 17 | setupNodeEvents(on) { 18 | on('file:preprocessor', createBundler()) 19 | on('before:browser:launch', (browser: any = {}, launchOptions: any) => { 20 | if (browser.family === 'chromium' && browser.name !== 'electron' && browser.isHeaded) { 21 | // auto open devtools 22 | launchOptions.args.push('--auto-open-devtools-for-tabs') 23 | 24 | // remove "Chrome is being controlled..." infobar 25 | launchOptions.args = launchOptions.args.filter((a: string) => a !== '--enable-automation') 26 | 27 | // allow debugging in vs code 28 | launchOptions.args.push('--remote-debugging-port=9222') 29 | 30 | return launchOptions 31 | } 32 | 33 | // whatever you return here becomes the launchOptions 34 | return launchOptions 35 | }) 36 | }, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/e2e/membership.cy.ts: -------------------------------------------------------------------------------- 1 | import { alice, bob, bobToAlice, bobToBob, show } from '../support/helpers.js' 2 | 3 | it(`Alice promotes Bob after he joins`, () => { 4 | show('Bob:laptop') 5 | alice().addToTeam('Bob').promote('Bob') 6 | 7 | // Alice and Bob see that Bob is admin 8 | bobToAlice().should('be.admin') 9 | bobToBob().should('be.admin') 10 | }) 11 | 12 | it(`Alice demotes Bob`, () => { 13 | show('Bob:laptop') 14 | alice().addToTeam('Bob') 15 | alice().promote('Bob') 16 | 17 | // Alice and Bob see that Bob is admin 18 | bobToAlice().should('be.admin') 19 | bobToBob().should('be.admin') 20 | 21 | // Alice demotes Bob 22 | alice().demote('Bob') 23 | 24 | // neither one sees Bob as admin 25 | bobToAlice().should('not.be.admin') 26 | bobToBob().should('not.be.admin') 27 | }) 28 | 29 | it('Alice removes Bob from the team', () => { 30 | show('Bob:laptop') 31 | bob().should('be.onStartScreen') 32 | 33 | alice().addToTeam('Bob') 34 | bob().should('not.be.onStartScreen') 35 | 36 | // Alice removes Bob 37 | alice().remove('Bob') 38 | 39 | // Bob is no longer on the team - he is returned to the start screen 40 | bob().should('be.onStartScreen') 41 | }) 42 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/parallel-weights.json: -------------------------------------------------------------------------------- 1 | {"cypress/e2e/basic.cy.ts":{"time":2396,"weight":1},"cypress/e2e/concurrency.cy.ts":{"time":31556,"weight":21},"cypress/e2e/connection.cy.ts":{"time":11432,"weight":7},"cypress/e2e/invitations.cy.ts":{"time":22013,"weight":15},"cypress/e2e/membership.cy.ts":{"time":5934,"weight":4}} -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/assertions/be.admin.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands' 2 | 3 | chai.Assertion.addMethod('admin', function () { 4 | const $element = this._obj 5 | new chai.Assertion($element).to.be 6 | 7 | // admins see a button 8 | const $adminButton = $element.find('button:contains("👑")') 9 | // non-admins just see text 10 | const $adminSpan = $element.find('span:contains("👑")') 11 | 12 | const isAdmin = 13 | $adminButton.length > 0 14 | ? // if the button is not faded out, this member is an admin 15 | $adminButton.css('opacity') === '1' 16 | : // if the span exists, this member is an admin 17 | $adminSpan.length 18 | this.assert(isAdmin, 'expected to be admin', 'expected not to be admin', true, false) 19 | }) 20 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/assertions/be.onStartScreen.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands' 2 | 3 | chai.Assertion.addMethod('onStartScreen', function () { 4 | const $element = this._obj 5 | new chai.Assertion($element).to.be 6 | const isOnStartScreen = $element.find('.CreateOrJoinTeam').length === 1 7 | 8 | this.assert( 9 | isOnStartScreen, 10 | 'expected to be on start screen', 11 | 'expected not to be on start screen', 12 | true, 13 | false 14 | ) 15 | }) 16 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/assertions/be.online.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands' 2 | 3 | chai.Assertion.addMethod('online', function () { 4 | const $element = this._obj 5 | new chai.Assertion($element).to.be 6 | const $onlineToggle = $element.find('.OnlineToggle') 7 | const isOnline = $onlineToggle.attr('title') === 'online' 8 | this.assert(isOnline, 'expected to be online', 'expected not to be online', true, false) 9 | }) 10 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/assertions/have.member.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands' 2 | 3 | chai.Assertion.addMethod('member', function (userName: string) { 4 | const $element = this._obj 5 | new chai.Assertion($element).to.have 6 | 7 | const memberRow = $element.find(`.MemberTable tr:contains('${userName}')`) 8 | 9 | // admins see a button 10 | const memberExists = memberRow.length === 1 11 | 12 | this.assert( 13 | memberExists, 14 | `expected to have member ${userName}`, 15 | `expected not to have member ${userName}`, 16 | true, 17 | false 18 | ) 19 | }) 20 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/addDevice.ts: -------------------------------------------------------------------------------- 1 | import { peer, wrap } from '../helpers.js' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const addDevice: CommandFn = (subject, deviceName: string) => { 5 | const s = () => wrap(subject) 6 | let userName: string 7 | s() 8 | .userName() 9 | .then(name => (userName = name)) 10 | return s() 11 | .inviteDevice() 12 | .then(code => { 13 | peer(userName, deviceName).join(code) 14 | }) 15 | .then(() => 16 | s() 17 | .teamName() 18 | .then(teamName => peer(userName, deviceName).teamName().should('equal', teamName)) 19 | ) 20 | .then(() => s()) 21 | } 22 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/addToTeam.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, peer, wrap } from '../helpers.js' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const addToTeam: CommandFn = (subject, userName: string) => { 5 | const s = () => wrap(subject) 6 | return s() 7 | .invite() 8 | .then(code => { 9 | peer(userName).join(code) 10 | }) 11 | .then(() => { 12 | s() 13 | .teamName() 14 | .then(teamName => peer(userName).teamName().should('equal', teamName)) 15 | s().peerConnectionStatus(userName).should('equal', 'connected') 16 | }) 17 | .then(() => s()) 18 | } 19 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/adminButton.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const adminButton: CommandFn = (subject, userName: string) => 5 | wrap(subject).teamMember(userName).findByText('👑', NOLOG) 6 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/chain.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const chain: CommandFn = subject => { 5 | return wrap(subject).find('.ChainDiagram svg g.nodes g.node', NOLOG) 6 | } 7 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/demote.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const demote: CommandFn = (subject, userName: string) => 5 | wrap(subject).teamMember(userName).findByTitle('Team admin (click to remove)', NOLOG).click(NOLOG) 6 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/hide.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const hide: CommandFn = subject => { 5 | const s = () => wrap(subject) 6 | return s().find('.HideButton button', NOLOG).click(NOLOG) 7 | } 8 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/index.ts: -------------------------------------------------------------------------------- 1 | export { addDevice } from './addDevice.js' 2 | export { addToTeam } from './addToTeam.js' 3 | export { adminButton } from './adminButton.js' 4 | export { chain } from './chain.js' 5 | export { demote } from './demote.js' 6 | export { hide } from './hide.js' 7 | export { invite } from './invite.js' 8 | export { inviteDevice } from './inviteDevice.js' 9 | export { isConnectedTo } from './isConnectedTo.js' 10 | export { join } from './join.js' 11 | export { peerConnectionStatus } from './peerConnectionStatus.js' 12 | export { promote } from './promote.js' 13 | export { remove } from './remove.js' 14 | export { teamMember } from './teamMember.js' 15 | export { teamName } from './teamName.js' 16 | export { toggleOnline } from './toggleOnline.js' 17 | export { userName } from './userName.js' 18 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/invite.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const SECOND = 1 5 | export const MINUTE = 60 * SECOND 6 | export const HOUR = 60 * MINUTE 7 | export const DAY = 24 * HOUR 8 | export const WEEK = 7 * DAY 9 | 10 | export type InviteOptions = { 11 | maxUses?: 1 | 5 | 10 | 0 12 | expiration?: typeof SECOND | typeof MINUTE 13 | } 14 | 15 | export const invite: CommandFn = (subject, options: InviteOptions = {}) => { 16 | const { maxUses = 1, expiration = MINUTE } = options 17 | const s = () => wrap(subject) 18 | // click invite button 19 | s().findByText('Invite members', NOLOG).click(NOLOG) 20 | 21 | // set max uses 22 | s() 23 | .findByLabelText('How many people can use this invitation code?', NOLOG) 24 | .select(maxUses.toString(), NOLOG) 25 | 26 | // set expiration 27 | s() 28 | .findByLabelText('When does this invitation code expire?', NOLOG) 29 | .select(expiration.toString(), NOLOG) 30 | 31 | // press invite button 32 | s().findByText('Invite', NOLOG).click(NOLOG) 33 | 34 | // capture invitation code 35 | return s() 36 | .get('pre.InvitationCode', NOLOG) 37 | .then(pre => { 38 | s().findByText('Copy', NOLOG).click(NOLOG) 39 | const code = wrap(pre).invoke(NOLOG, 'text') 40 | return code 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/inviteDevice.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const inviteDevice: CommandFn = subject => { 5 | const s = () => wrap(subject) 6 | // click invite button 7 | s().findByText('Add a device', NOLOG).click(NOLOG) 8 | 9 | // capture invitation code 10 | return s() 11 | .get('pre.InvitationCode') 12 | .then(pre => { 13 | s().findByText('Copy', NOLOG).click(NOLOG) 14 | const code = wrap(pre).invoke(NOLOG, 'text') 15 | return code 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/isConnectedTo.ts: -------------------------------------------------------------------------------- 1 | import { wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const isConnectedTo: CommandFn = (subject, userName: string) => 5 | wrap(subject).peerConnectionStatus(userName).should('equal', 'connected') 6 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/join.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const join: CommandFn = (subject, code: string, options = { expectToFail: false }) => { 5 | const { expectToFail } = options 6 | const s = () => wrap(subject) 7 | s().wait(100).findByText('Join team', NOLOG).click(NOLOG) 8 | s().findByLabelText('Invitation code', NOLOG).type(code) 9 | s().findByText('Join').click() 10 | return s() 11 | .userName() 12 | .then(userName => 13 | s() 14 | .get('.MemberTable', NOLOG) 15 | .should(expectToFail ? 'not.contain' : 'contain', userName) 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/peerConnectionStatus.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const devices = { 5 | laptop: { name: 'laptop', emoji: '💻' }, 6 | phone: { name: 'phone', emoji: '📱' }, 7 | } as Record 8 | 9 | export const peerConnectionStatus: CommandFn = ( 10 | subject, 11 | userName: string, 12 | deviceName = 'laptop' 13 | ) => { 14 | const { emoji } = devices[deviceName] 15 | const connCell = wrap(subject) 16 | .teamMember(userName) 17 | .findByText(emoji, NOLOG) 18 | .parents('div', NOLOG) 19 | .first(NOLOG) 20 | return connCell.invoke(NOLOG, 'attr', 'title') 21 | } 22 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/promote.ts: -------------------------------------------------------------------------------- 1 | import { type CommandFn } from '../e2e.js' 2 | import { NOLOG, wrap } from '../helpers' 3 | 4 | export const promote: CommandFn = (subject, userName: string) => 5 | wrap(subject).teamMember(userName).findByTitle('Click to make team admin', NOLOG).click(NOLOG) 6 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/remove.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const remove: CommandFn = (subject, userName: string) => { 5 | const s = () => wrap(subject) 6 | return s().teamMember(userName).findByTitle('Remove member from team', NOLOG).click(NOLOG) 7 | } 8 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/teamMember.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const teamMember: CommandFn = (subject, userName: string) => { 5 | return wrap(subject).find('.MemberTable', NOLOG).findByText(userName, NOLOG).parents('tr', NOLOG) 6 | } 7 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/teamName.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const teamName: CommandFn = subject => { 5 | const s = () => wrap(subject) 6 | return s().find('.TeamName', NOLOG).invoke(NOLOG, 'text') 7 | } 8 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/toggleOnline.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const toggleOnline: CommandFn = subject => { 5 | const s = () => wrap(subject) 6 | return s() 7 | .find('.OnlineToggle', NOLOG) 8 | .invoke(NOLOG, 'attr', 'title') 9 | .then(prevState => { 10 | s() 11 | .find('.OnlineToggle', NOLOG) 12 | .click(NOLOG) 13 | .invoke(NOLOG, 'attr', 'title') 14 | .should('not.equal', prevState) 15 | }) 16 | .then(() => subject) 17 | } 18 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/commands/userName.ts: -------------------------------------------------------------------------------- 1 | import { NOLOG, wrap } from '../helpers' 2 | import { type CommandFn } from '../types.js' 3 | 4 | export const userName: CommandFn = subject => { 5 | const s = () => wrap(subject) 6 | return s().find('h1', NOLOG).invoke(NOLOG, 'text') 7 | } 8 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // register commands 2 | 3 | import '@testing-library/cypress/add-commands' 4 | import * as _commands from './commands/index.js' 5 | 6 | // register assertions 7 | 8 | import './assertions/be.admin.js' 9 | import './assertions/be.online.js' 10 | import './assertions/have.member.js' 11 | import './assertions/be.onStartScreen.js' 12 | 13 | import { type CommandFn } from './types.js' 14 | import { wrapCommand } from './helpers' 15 | export { type CommandFn } from './types.js' 16 | 17 | const commands = _commands as Record 18 | 19 | for (const key in commands) { 20 | const command = commands[key] 21 | Cypress.Commands.add(key, { prevSubject: true }, wrapCommand(key, command)) 22 | } 23 | 24 | declare global { 25 | namespace Cypress { 26 | interface Chainable extends CustomCommands {} 27 | } 28 | } 29 | 30 | export type CustomCommands = typeof commands 31 | 32 | declare global { 33 | namespace Cypress { 34 | interface Chainer { 35 | (chainer: 'be.admin'): Chainable 36 | (chainer: 'not.be.admin'): Chainable 37 | (chainer: 'be.online'): Chainable 38 | (chainer: 'not.be.online'): Chainable 39 | } 40 | } 41 | } 42 | 43 | beforeEach(() => { 44 | cy.visit('/') 45 | localStorage.setItem('debug', 'lf:*') 46 | }) 47 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/support/types.ts: -------------------------------------------------------------------------------- 1 | export type CommandKey = keyof Cypress.Chainable 2 | export type CommandFn = (...args: any[]) => Cypress.Chainable 3 | -------------------------------------------------------------------------------- /demos/taco-chat/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "es5", 6 | "dom" 7 | ], 8 | "types": [ 9 | "cypress", 10 | "node" 11 | ] 12 | }, 13 | "include": [ 14 | "**/*.ts" 15 | ] 16 | } -------------------------------------------------------------------------------- /demos/taco-chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 🌮 taco chat 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /demos/taco-chat/postcss.config.js: -------------------------------------------------------------------------------- 1 | import tailwind from 'tailwindcss' 2 | import autoprefixer from 'autoprefixer' 3 | import tailwindConfig from './tailwind.config.js' 4 | 5 | export default { 6 | plugins: [tailwind(tailwindConfig), autoprefixer], 7 | } 8 | -------------------------------------------------------------------------------- /demos/taco-chat/scripts/start-relay-server.js: -------------------------------------------------------------------------------- 1 | import { Server } from '@localfirst/relay/Server.js' 2 | 3 | const DEFAULT_PORT = 8080 4 | const port = Number(process.env.PORT) || DEFAULT_PORT 5 | 6 | const server = new Server({ port }) 7 | 8 | server.listen() 9 | -------------------------------------------------------------------------------- /demos/taco-chat/src/@types/friendly-words/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'friendly-words' 2 | 3 | declare const friendlyWords: { 4 | predicates: string[] 5 | objects: string[] 6 | } 7 | -------------------------------------------------------------------------------- /demos/taco-chat/src/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | import { EventArgs, EventMap, EventEmitter as _EventEmitter } from '@herbcaudill/eventemitter42' 3 | 4 | /** EventEmitter with built-in logging */ 5 | export class EventEmitter extends _EventEmitter { 6 | /** The `log` method is meant to be overridden, e.g. 7 | * ```ts 8 | * this.log = debug(`lf:auth:demo:conn:${context.user.userName}`) 9 | * ``` 10 | */ 11 | log: debug.Debugger = debug(`EventEmitter`) 12 | 13 | public emit(event: K, ...args: EventArgs) { 14 | this.log(`emit ${String(event)} %o`, ...args) 15 | return super.emit(event, ...args) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demos/taco-chat/src/bubbleEvents.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '@herbcaudill/eventemitter42' 2 | 3 | export const bubbleEvents = ( 4 | source: EventEmitter, 5 | target: EventEmitter, 6 | events: string[] 7 | ) => { 8 | for (const event of events) source.on(event, payload => target.emit(event, payload)) 9 | } 10 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Alerts.tsx: -------------------------------------------------------------------------------- 1 | import { useTeam } from 'hooks/useTeam.js' 2 | import React from 'react' 3 | 4 | export const Alerts = () => { 5 | const { alerts, clearAlert } = useTeam() 6 | return alerts.length > 0 ? ( 7 |
8 | {alerts.map(a => ( 9 |
10 |
24 | ))} 25 |
26 | ) : null 27 | } 28 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import React, { forwardRef, type HTMLAttributes } from 'react' 3 | 4 | export const Avatar = forwardRef( 5 | function Avatar(props, ref) { 6 | const { size = 'md', className, children } = props 7 | const sizeStyles = { 8 | lg: 'w-12 h-12 text-2xl', 9 | md: 'w-10 h-10 text-lg', 10 | sm: 'w-8 h-8 text-md', 11 | } 12 | const baseStyle = 13 | 'Avatar rounded-full border border-white bg-white bg-opacity-50 mr-3 flex items-center' 14 | const cls = classNames(baseStyle, sizeStyles[size], className) 15 | return ( 16 |
17 |
{children}
18 |
19 | ) 20 | } 21 | ) 22 | 23 | type AvatarProps = { 24 | size?: 'lg' | 'md' | 'sm' 25 | } & HTMLAttributes 26 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import cx from 'classnames' 2 | import { createElement, forwardRef } from 'react' 3 | 4 | export const Button = forwardRef( 5 | ( 6 | { 7 | children, 8 | size = 'sm', 9 | color = 'white', 10 | className = '', 11 | tag = 'button' as T, 12 | ...remainingProps 13 | }: Props, 14 | ref: RefType 15 | ) => { 16 | const props = { 17 | // pass ref to underlying element 18 | ref, 19 | 20 | // add button styles to any classes provided 21 | className: cx(className, `button button-${size} button-${color}`), 22 | 23 | // for buttons, default to type="button" (can be overridden) 24 | ...(tag === 'button' ? { type: 'button' } : {}), 25 | 26 | // pass on any other button or anchor props provided 27 | ...remainingProps, 28 | } 29 | return createElement(tag, props, children) 30 | } 31 | ) 32 | 33 | type ButtonTag = 'button' | 'a' 34 | 35 | type Props = 36 | // expose all native button or anchor props for maximum flexibility 37 | JSX.IntrinsicElements[T] & { 38 | size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' 39 | color?: 'primary' | 'secondary' | 'white' | 'neutral' | 'danger' | 'warning' 40 | tag?: T 41 | } 42 | 43 | type ElementType = // 44 | T extends 'button' ? HTMLButtonElement : HTMLAnchorElement 45 | 46 | type RefType = React.Ref> 47 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/CardLabel.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | 3 | export const CardLabel = ({ children }: CardLabelProps) => ( 4 |

5 | {children} 6 |

7 | ) 8 | type CardLabelProps = { 9 | children: ReactNode 10 | } 11 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Catch.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import { type ComponentType, type ErrorInfo, type ReactNode } from 'react' 3 | import { Component } from 'react' 4 | 5 | type ErrorHandler = (error: Error, info: React.ErrorInfo) => void 6 | type ErrorHandlingComponent = (props: Props, error?: Error) => ReactNode 7 | 8 | type ErrorState = { error?: Error } 9 | 10 | // from http://gist.github.com/andywer/800f3f25ce3698e8f8b5f1e79fed5c9c 11 | 12 | export default function Catch>( 13 | component: ErrorHandlingComponent, 14 | errorHandler?: ErrorHandler 15 | ): ComponentType { 16 | return class extends Component { 17 | state: ErrorState = { 18 | error: undefined, 19 | } 20 | 21 | static getDerivedStateFromError(error: Error) { 22 | return { error } 23 | } 24 | 25 | componentDidCatch(error: Error, info: ErrorInfo) { 26 | if (errorHandler) { 27 | errorHandler(error, info) 28 | } 29 | } 30 | 31 | render() { 32 | return component(this.props, this.state.error) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Chooser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import { type PeerMap } from '../peers.js' 3 | 4 | export const Chooser = ({ onAdd, peers }: ChooserProps) => { 5 | const peerSelect = useRef() as React.MutableRefObject 6 | 7 | // TODO use headlessui component 8 | return ( 9 |
10 | 32 |
33 | ) 34 | } 35 | 36 | type ChooserProps = { 37 | onAdd: (id: string) => void 38 | peers: PeerMap 39 | } 40 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/DeviceChooser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import { type PeerMap } from '../peers.js' 3 | 4 | export const DeviceChooser = ({ onAdd, peers }: ChooserProps) => { 5 | const peerSelect = useRef() as React.MutableRefObject 6 | 7 | return ( 8 |
9 | 31 |
32 | ) 33 | } 34 | 35 | type ChooserProps = { 36 | onAdd: (id: string) => void 37 | peers: PeerMap 38 | } 39 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from 'react' 2 | import Catch from './Catch.js' 3 | 4 | type Props = { 5 | children: ReactNode 6 | } 7 | 8 | // eslint-disable-next-line new-cap 9 | export const ErrorBoundary = Catch(function (props: Props, error?: Error) { 10 | if (error) { 11 | return ( 12 |
13 |

An error has occured

14 |

{error.message}

15 |
16 | ) 17 | } 18 | 19 | return <>{props.children} 20 | }) 21 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/HideButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const HideButton = ({ onClick }: HideButtonProps) => ( 4 |
5 | 16 |
17 | ) 18 | type HideButtonProps = { 19 | onClick: () => void 20 | } 21 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const Label = ({ 4 | children, 5 | ...props 6 | }: { 7 | children: React.ReactNode 8 | }) => ( 9 | 12 | ) 13 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Mermaid.tsx: -------------------------------------------------------------------------------- 1 | import mermaid from 'mermaid' 2 | import { useEffect, useState } from 'react' 3 | 4 | export type MermaidProps = { 5 | id: string 6 | chart: string 7 | config: any 8 | } 9 | 10 | export const Mermaid = ({ id, chart, config = {} }: MermaidProps) => { 11 | const [svg, setSvg] = useState('') 12 | mermaid.mermaidAPI.initialize(config) 13 | 14 | useEffect(() => { 15 | if (chart === '') return 16 | mermaid.render(id, chart).then(({ svg }) => setSvg(svg)) 17 | }, [chart]) 18 | 19 | return ( 20 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/OnlineToggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Toggle } from './Toggle.js' 3 | 4 | export const OnlineToggle = ({ 5 | isOnline = false, 6 | disabled = false, 7 | onChange = () => {}, 8 | }: OnlineToggleProps) => ( 9 | <> 10 | onChange(!isOnline)} 16 | /> 17 | 26 | 27 | 28 | 29 | ) 30 | 31 | type OnlineToggleProps = { 32 | isOnline?: boolean 33 | disabled?: boolean 34 | onChange?: (value: boolean) => void 35 | } 36 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/StatusIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const StatusIndicator: React.FC<{ status: string }> = ({ 4 | status = '', 5 | }) => { 6 | const statusIndicatorCx = () => { 7 | status = status.split(':')[0] 8 | switch (status) { 9 | case 'connecting': { 10 | return 'border-t border-r border-b border-gray-300 animate-spin-fast' 11 | } 12 | 13 | case 'connected': { 14 | return 'bg-green-500' 15 | } 16 | 17 | case 'synchronizing': { 18 | return 'border-t border-r border-b border-green-500 animate-spin-fast' 19 | } 20 | 21 | case 'idle': 22 | case 'disconnected': 23 | default: { 24 | return 'bg-gray-300' 25 | } 26 | } 27 | } 28 | 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/TeamProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | type PeerState, 4 | type StoredPeerState, 5 | type TeamContextPayload, 6 | } from '../types.js' 7 | 8 | export const TeamProvider = ({ 9 | initialState, 10 | onUpdate, 11 | children, 12 | }: TeamProviderProps) => { 13 | const [peerState, setPeerState] = React.useState(initialState) 14 | 15 | React.useEffect(() => { 16 | // store state whenever it changes 17 | const { team } = peerState 18 | const teamGraph = team?.save() 19 | onUpdate({ ...peerState, teamGraph }) 20 | }, [peerState]) 21 | 22 | return ( 23 | 27 | ) 28 | } 29 | 30 | export const teamContext = React.createContext(undefined) 31 | 32 | // TYPES 33 | 34 | type TeamProviderProps = { 35 | initialState: PeerState 36 | onUpdate: (s: StoredPeerState) => void 37 | children: React.ReactNode 38 | } 39 | -------------------------------------------------------------------------------- /demos/taco-chat/src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const noOp = () => {} 4 | 5 | export const Toggle = ({ 6 | title, 7 | on, 8 | disabled = false, 9 | onClick = noOp, 10 | className = '', 11 | }: ToggleProps) => { 12 | return ( 13 | 25 | {/* Slot */} 26 | 32 | 33 | {/* Knob */} 34 | 42 | 43 | ) 44 | } 45 | 46 | type ToggleProps = { 47 | title: string 48 | on: boolean 49 | disabled?: boolean 50 | onClick?: () => void 51 | className?: string 52 | } 53 | -------------------------------------------------------------------------------- /demos/taco-chat/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { App } from './components/App.js' 4 | import '@ibm/plex/css/ibm-plex.css' 5 | import './index.css' 6 | 7 | ReactDOM.createRoot(document.querySelector('#root')!).render( 8 | 9 | 10 | 11 | ) 12 | -------------------------------------------------------------------------------- /demos/taco-chat/src/mermaid.theme.ts: -------------------------------------------------------------------------------- 1 | export const theme = { 2 | theme: 'base', 3 | securityLevel: 'loose', 4 | flowchart: { 5 | diagramPadding: 25, 6 | }, 7 | themeVariables: { 8 | background: '#ffffff', 9 | primaryColor: '#ffffff', 10 | primaryBorderColor: '#000000', 11 | fontFamily: '"IBM Plex Mono"', 12 | fontSize: '12px', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /demos/taco-chat/src/peers.ts: -------------------------------------------------------------------------------- 1 | import { arrayToMap } from './util/arrayToMap.js' 2 | 3 | export const devices = { 4 | laptop: { name: 'laptop', emoji: '💻' }, 5 | phone: { name: 'phone', emoji: '📱' }, 6 | } as Record 7 | 8 | export const users = { 9 | Alice: { userName: 'Alice', userId: 'alice-111', emoji: '👩🏾' }, 10 | Bob: { userName: 'Bob', userId: 'bob-222', emoji: '👨🏻‍🦲' }, 11 | Charlie: { userName: 'Charlie', userId: 'charlie-333', emoji: '👳🏽‍♂️' }, 12 | Dwight: { userName: 'Dwight', userId: 'dwight-444', emoji: '👴' }, 13 | Eve: { userName: 'Eve', userId: 'eve-555', emoji: '🦹‍♀️' }, 14 | } as Record 15 | 16 | const peerArray = Object.values(users).flatMap(user => 17 | Object.values(devices).map( 18 | device => 19 | ({ 20 | user, 21 | device, 22 | id: `${user.userName}:${device.name}`, 23 | show: false, 24 | }) as PeerInfo 25 | ) 26 | ) 27 | 28 | export const peers = peerArray.reduce(arrayToMap('id'), {}) as PeerMap 29 | 30 | export type PeerInfo = { 31 | id: string 32 | user: UserInfo 33 | device: DeviceInfo 34 | show: boolean 35 | } 36 | 37 | export type PeerMap = Record 38 | 39 | export type DeviceInfo = { 40 | name: string 41 | emoji: string 42 | } 43 | 44 | export type UserInfo = { 45 | userId: string 46 | userName: string 47 | emoji: string 48 | } 49 | -------------------------------------------------------------------------------- /demos/taco-chat/src/types.ts: -------------------------------------------------------------------------------- 1 | import type * as auth from '@localfirst/auth' 2 | import { ConnectionManager } from 'ConnectionManager.js' 3 | import * as React from 'react' 4 | 5 | export type UserName = string 6 | export type ConnectionStatus = string 7 | 8 | export type PeerState = { 9 | userName: UserName 10 | userId: string 11 | user?: auth.UserWithSecrets 12 | device: auth.DeviceWithSecrets 13 | team?: auth.Team 14 | teamState?: auth.TeamState 15 | connectionManager?: ConnectionManager 16 | online: boolean 17 | connectionStatus: Record 18 | alerts: AlertInfo[] 19 | } 20 | 21 | export type StoredPeerState = { 22 | userName: UserName 23 | userId: string 24 | user?: auth.UserWithSecrets 25 | device: auth.DeviceWithSecrets 26 | teamGraph?: string 27 | teamKeys?: auth.KeysetWithSecrets 28 | } 29 | 30 | export type Storage = Record 31 | 32 | export type TeamContextPayload = 33 | | [PeerState, React.Dispatch>] 34 | | undefined 35 | 36 | export type AlertInfo = { 37 | id: string 38 | message: string 39 | type: 'error' | 'warning' | 'info' 40 | } 41 | -------------------------------------------------------------------------------- /demos/taco-chat/src/util/arrayToMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reducer for converting an array of objects to a map containing the same objects, where the key is 3 | * taken from a field in each object (e.g. `id`, `hash`, etc.). 4 | * 5 | * ```ts 6 | * const arr = [ 7 | * {id: 1, ...}, 8 | * {id: 2, ...}, 9 | * {id: 3, ...} 10 | * ] 11 | * const mapped = arr.reduce(arrayToMap('id'), {}) 12 | * // mapped = { 13 | * // 1: {id: 1, ...}, 14 | * // 2: {id: 2, ...}, 15 | * // 3: {id: 3, ...}, 16 | * // } 17 | * ``` 18 | * @param keyField 19 | */ 20 | export const arrayToMap = 21 | (keyField: string) => 22 | >( 23 | result: Record, 24 | current: T 25 | ): Record => ({ 26 | ...result, 27 | [current[keyField]]: current, 28 | }) 29 | -------------------------------------------------------------------------------- /demos/taco-chat/src/util/randomElement.ts: -------------------------------------------------------------------------------- 1 | export const randomElement = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)] 2 | -------------------------------------------------------------------------------- /demos/taco-chat/src/util/randomTeamName.ts: -------------------------------------------------------------------------------- 1 | import { objects, predicates } from 'friendly-words' 2 | import { randomElement } from './randomElement.js' 3 | 4 | export const randomTeamName = (): string => [predicates, objects].map(randomElement).join('-') 5 | -------------------------------------------------------------------------------- /demos/taco-chat/src/util/samePeer.tsx: -------------------------------------------------------------------------------- 1 | import { type PeerInfo } from '../peers.js' 2 | 3 | export const samePeer = (a: PeerInfo, b: PeerInfo) => 4 | a.user.userName === b.user.userName && a.device.name === b.device.name 5 | -------------------------------------------------------------------------------- /demos/taco-chat/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | }, 8 | "include": [ 9 | "src", 10 | "@types" 11 | ] 12 | } -------------------------------------------------------------------------------- /demos/taco-chat/vite.config.ts: -------------------------------------------------------------------------------- 1 | import viteReact from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import tsconfigPaths from 'vite-tsconfig-paths' 4 | import postcss from './postcss.config' 5 | import topLevelAwait from 'vite-plugin-top-level-await' 6 | import { NodeGlobalsPolyfillPlugin as nodeGlobals } from '@esbuild-plugins/node-globals-polyfill' 7 | export default defineConfig({ 8 | plugins: [viteReact(), tsconfigPaths(), topLevelAwait()], 9 | build: { 10 | target: 'esnext', 11 | }, 12 | optimizeDeps: { 13 | esbuildOptions: { 14 | define: { global: 'globalThis' }, 15 | plugins: [nodeGlobals({ buffer: true })], 16 | }, 17 | }, 18 | css: { postcss }, 19 | }) 20 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | ## 💻 Context 2 | 3 | The context object is passed in when instantiating a team to identify the runtime environment; it identifies the current local user, the device we're running on, and client application. 4 | 5 | ```js 6 | const context = { user, device, client } 7 | ``` 8 | 9 | ### Device 10 | 11 | The name of the device needs to be unique among this user's devices. 12 | 13 | ```js 14 | const device = { 15 | userName: 'bob', 16 | deviceName: 'iPhone 11', 17 | } 18 | ``` 19 | 20 | ### Client 21 | 22 | Optionally, you can identify the client application. 23 | 24 | ```js 25 | const client = { 26 | name: 'MyAmazingTeamApp', 27 | version: '1.2.3', 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/img/key-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/key-graph.png -------------------------------------------------------------------------------- /docs/img/key-rotation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/key-rotation.png -------------------------------------------------------------------------------- /docs/img/lf-auth-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/lf-auth-demo.gif -------------------------------------------------------------------------------- /docs/img/lockboxes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/lockboxes.png -------------------------------------------------------------------------------- /docs/img/sigchain-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/sigchain-action.png -------------------------------------------------------------------------------- /docs/img/sigchain-med.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/sigchain-med.png -------------------------------------------------------------------------------- /docs/img/sigchain-tiny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/sigchain-tiny.png -------------------------------------------------------------------------------- /docs/img/sigchain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/sigchain.png -------------------------------------------------------------------------------- /docs/img/sigchallenge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/docs/img/sigchallenge.png -------------------------------------------------------------------------------- /docs/lockbox.md: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6.0.0", 3 | "registry": "https://registry.npmjs.org/", 4 | "npmClient": "pnpm", 5 | "packages": ["packages/*", "demos/*"], 6 | "useNx": true, 7 | "publish": { 8 | "noPrivate": true, 9 | "forcePublish": true, 10 | "publishConfig": { 11 | "access": "public" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@localfirst/auth-provider-automerge-repo", 3 | "version": "6.0.0", 4 | "description": "Authentication provider for automerge-repo using localfirst/auth", 5 | "repository": "https://github.com/local-first-web/auth/packages/auth-provider-automerge-repo", 6 | "license": "MIT", 7 | "private": false, 8 | "type": "module", 9 | "exports": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "engines": { 12 | "node": ">=18" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsup", 19 | "postbuild": "tsc -p tsconfig.build.json --emitDeclarationOnly", 20 | "test": "vitest", 21 | "test:log": "cross-env DEBUG='localfirst*' DEBUG_COLORS=1 vitest --reporter basic" 22 | }, 23 | "dependencies": { 24 | "@automerge/automerge-repo": "^1.2.1", 25 | "@herbcaudill/eventemitter42": "^0.3.1", 26 | "@localfirst/auth": "workspace:*", 27 | "@localfirst/crypto": "workspace:*", 28 | "@localfirst/shared": "workspace:*", 29 | "msgpackr": "^1.10.0" 30 | }, 31 | "devDependencies": { 32 | "@automerge/automerge-repo-network-messagechannel": "^1.2.1", 33 | "@automerge/automerge-repo-storage-nodefs": "^1.2.1", 34 | "rimraf": "^5.0.5", 35 | "typescript": "^5.3.2" 36 | }, 37 | "gitHead": "9a7b871e9e34268b32cc6e574189ec2350787b81" 38 | } 39 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/AbstractConnection.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '@automerge/automerge-repo' 2 | import type { ConnectionEvents } from '@localfirst/auth' 3 | import { EventEmitter } from '@herbcaudill/eventemitter42' 4 | 5 | export abstract class AbstractConnection extends EventEmitter { 6 | abstract start(): AbstractConnection 7 | 8 | abstract stop(): AbstractConnection 9 | 10 | abstract deliver(serializedMessage: Uint8Array): void 11 | 12 | abstract send(message: Message): void 13 | 14 | abstract get state(): StateValue 15 | } 16 | 17 | // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style 18 | export type StateValueMap = { 19 | [key: string]: StateValue 20 | } 21 | 22 | export declare type StateValue = string | StateValueMap 23 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/AuthenticatedNetworkAdapter.ts: -------------------------------------------------------------------------------- 1 | import { NetworkAdapter, type Message } from '@automerge/automerge-repo' 2 | import { type ShareId } from 'types.js' 3 | 4 | /** 5 | * An AuthenticatedNetworkAdapter is a NetworkAdapter that wraps another NetworkAdapter and 6 | * transforms outbound messages. 7 | */ 8 | export class AuthenticatedNetworkAdapter // 9 | extends NetworkAdapter 10 | { 11 | connect: typeof NetworkAdapter.prototype.connect 12 | disconnect: typeof NetworkAdapter.prototype.disconnect 13 | 14 | isReady = false 15 | 16 | constructor( 17 | public baseAdapter: T, 18 | private readonly sendFn: (msg: Message) => void, 19 | private readonly shareIds: ShareId[] = [] 20 | ) { 21 | super() 22 | 23 | // pass through the base adapter's connect & disconnect methods 24 | this.connect = this.baseAdapter.connect.bind(this.baseAdapter) 25 | this.disconnect = this.baseAdapter.disconnect.bind(this.baseAdapter) 26 | 27 | baseAdapter.on('ready', () => { 28 | this.isReady = true 29 | this.emit('ready', { network: this }) 30 | }) 31 | } 32 | 33 | send(msg: Message) { 34 | if (!this.isReady) { 35 | // wait for base adapter to be ready 36 | this.baseAdapter.on('ready', () => this.sendFn(msg)) 37 | } else { 38 | // send immediately 39 | this.sendFn(msg) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/CompositeMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A map that supports tuples as keys 3 | */ 4 | export class CompositeMap { 5 | private readonly map = new Map() 6 | private readonly toCompositeKey = (key: K) => key.join(',') 7 | private readonly fromCompositeKey = (key: string) => key.split(',') as K 8 | 9 | clear() { 10 | this.map.clear() 11 | } 12 | 13 | delete(key: K) { 14 | const compositeKey = this.toCompositeKey(key) 15 | return this.map.delete(compositeKey) 16 | } 17 | 18 | get(key: K) { 19 | const compositeKey = this.toCompositeKey(key) 20 | return this.map.get(compositeKey) 21 | } 22 | 23 | set(key: K, value: V) { 24 | const compositeKey = this.toCompositeKey(key) 25 | this.map.set(compositeKey, value) 26 | } 27 | 28 | has(key: K) { 29 | const compositeKey = this.toCompositeKey(key) 30 | return this.map.has(compositeKey) 31 | } 32 | 33 | keys() { 34 | const keys = [...this.map.keys()] 35 | return keys.map(key => this.fromCompositeKey(key)) 36 | } 37 | 38 | get size() { 39 | return this.map.size 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/buildServerUrl.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Prepends the given host with protocol if it's missing, and returns a URL object. 3 | */ 4 | export const buildServerUrl = (host: string) => { 5 | // assume http if no protocol provided (for backwards compatibility) 6 | if (!host.includes('//')) { 7 | host = `http://${host}` 8 | } 9 | return new URL(host) 10 | } 11 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/getShareId.ts: -------------------------------------------------------------------------------- 1 | import type { Team } from '@localfirst/auth' 2 | import type { ShareId } from './types.js' 3 | 4 | /** 5 | * We use the first few characters of the team ID as the share ID. The team ID is a hash of the team's 6 | * root node (in base58 form), so it doesn't change. 7 | * 8 | * Truncating the team ID makes composite invitation keys (shareId + invitation seed) smaller and 9 | * thus more human-friendly. The tradeoff is the increased chance of collisions; 12 base58-encoded characters 10 | * give us 70 bits of entropy; according to [this calculator](https://kevingal.com/apps/collision.html), 11 | * you'd need to have 100 trillion shareIds on a single device before you'd have a 1% chance of a collision. 12 | */ 13 | export const getShareId = (team: Team): ShareId => { 14 | const teamId = team.id 15 | return teamId.slice(0, 12) as ShareId 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthProvider.js' 2 | export * from './getShareId.js' 3 | 4 | export type * from './types.js' 5 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/test/buildServerUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { buildServerUrl } from '../buildServerUrl.js' 3 | 4 | describe('buildServerUrl', () => { 5 | it('should prepend http:// when no protocol is provided', () => { 6 | const { protocol, hostname } = buildServerUrl('example.com') 7 | expect(protocol).toBe('http:') 8 | expect(hostname).toBe('example.com') 9 | }) 10 | 11 | it('should not prepend http:// when https protocol is provided', () => { 12 | const { protocol, hostname } = buildServerUrl('https://example.com') 13 | expect(protocol).toBe('https:') 14 | expect(hostname).toBe('example.com') 15 | }) 16 | 17 | it('should not prepend http:// when http protocol is provided', () => { 18 | const { protocol, hostname } = buildServerUrl('http://example.com') 19 | expect(protocol).toBe('http:') 20 | expect(hostname).toBe('example.com') 21 | }) 22 | 23 | it('should handle hosts with ports', () => { 24 | const { protocol, hostname, port } = buildServerUrl('example.com:8080') 25 | expect(protocol).toBe('http:') 26 | expect(hostname).toBe('example.com') 27 | expect(port).toBe('8080') 28 | }) 29 | 30 | it('should handle localhost', () => { 31 | const { protocol, hostname, port } = buildServerUrl('localhost:3000') 32 | expect(protocol).toBe('http:') 33 | expect(hostname).toBe('localhost') 34 | expect(port).toBe('3000') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/test/helpers/authenticated.ts: -------------------------------------------------------------------------------- 1 | import { eventPromise, pause } from '@localfirst/shared' 2 | import { type UserStuff } from './setup.js' 3 | 4 | export const authenticatedInTime = async (a: UserStuff, b: UserStuff, timeout = 500) => { 5 | const authWorked = authenticated(a, b).then(() => true) 6 | const authTimedOut = pause(timeout).then(() => false) 7 | 8 | return Promise.race([authWorked, authTimedOut]) 9 | } 10 | 11 | export const authenticated = async (a: UserStuff, b: UserStuff) => { 12 | return Promise.all([ 13 | eventPromise(a.repo.networkSubsystem, 'peer'), 14 | eventPromise(b.repo.networkSubsystem, 'peer'), 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/src/test/helpers/synced.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest' 2 | import { eventPromise } from '@localfirst/shared' 3 | import { type UserStuff, type TestDoc } from './setup.js' 4 | 5 | export const synced = async (a: UserStuff, b: UserStuff) => { 6 | // a makes a document 7 | const aHandle = a.repo.create() 8 | aHandle.change(d => { 9 | d.foo = 'bar' 10 | }) 11 | 12 | // b receives a's document 13 | const bHandle = b.repo.find(aHandle.documentId) 14 | const bDoc = await bHandle.doc() 15 | 16 | expect(bDoc?.foo).toBe('bar') 17 | 18 | // b makes a change 19 | bHandle.change(d => { 20 | d.foo = 'baz' 21 | }) 22 | 23 | // a receives the change 24 | await eventPromise(aHandle, 'change') 25 | const aDoc = await aHandle.doc() 26 | expect(aDoc?.foo).toBe('baz') 27 | 28 | return true 29 | } 30 | -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "**/test/*" 5 | ] 6 | } -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | }, 8 | "include": [ 9 | "src", 10 | "@types" 11 | ] 12 | } -------------------------------------------------------------------------------- /packages/auth-provider-automerge-repo/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: 'esm', 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | }) 10 | -------------------------------------------------------------------------------- /packages/auth-syncserver/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@localfirst/auth-syncserver", 3 | "version": "6.0.0", 4 | "description": "Sync server for automerge-repo using localfirst/auth", 5 | "repository": "https://github.com/local-first-web/auth/packages/auth-syncserver", 6 | "license": "MIT", 7 | "private": false, 8 | "type": "module", 9 | "exports": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "engines": { 12 | "node": ">=18" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsup", 19 | "postbuild": "tsc -p tsconfig.build.json --emitDeclarationOnly && shx cp src/*.html dist", 20 | "test": "vitest", 21 | "test:log": "cross-env DEBUG='localfirst*' DEBUG_COLORS=1 vitest --reporter basic" 22 | }, 23 | "dependencies": { 24 | "@automerge/automerge-repo": "^1.2.1", 25 | "@automerge/automerge-repo-network-websocket": "^1.2.1", 26 | "@automerge/automerge-repo-storage-nodefs": "^1.2.1", 27 | "@localfirst/auth": "workspace:*", 28 | "@localfirst/auth-provider-automerge-repo": "workspace:*", 29 | "@localfirst/shared": "workspace:*", 30 | "body-parser": "^1.20.2", 31 | "chalk": "^5.3.0", 32 | "cors": "^2.8.5", 33 | "express": "^4.18.2", 34 | "ws": "^8.14.2" 35 | }, 36 | "devDependencies": { 37 | "@localfirst/crypto": "workspace:*", 38 | "portfinder": "^1.0.32", 39 | "rimraf": "^5.0.5" 40 | }, 41 | "gitHead": "9a7b871e9e34268b32cc6e574189ec2350787b81" 42 | } 43 | -------------------------------------------------------------------------------- /packages/auth-syncserver/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SyncServer.js' 2 | -------------------------------------------------------------------------------- /packages/auth-syncserver/src/running.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 🤖 Sync Server 4 | 5 | 6 | 7 |
8 |
9 |
10 | 11 |
12 | 13 |
14 | 18 |
19 |
20 | 21 |
34 |
🤖
35 |
36 | Sync server for 37 | Automerge Repo + 38 | @localfirst/auth 39 |
40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/auth-syncserver/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "**/test/*", 5 | ] 6 | } -------------------------------------------------------------------------------- /packages/auth-syncserver/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | }, 8 | "include": [ 9 | "src", 10 | "@types" 11 | ] 12 | } -------------------------------------------------------------------------------- /packages/auth-syncserver/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: 'esm', 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | }) 10 | -------------------------------------------------------------------------------- /packages/auth/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | -------------------------------------------------------------------------------- /packages/auth/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Herb Caudill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/auth/docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | -------------------------------------------------------------------------------- /packages/auth/docs/img/key-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/auth/docs/img/key-graph.png -------------------------------------------------------------------------------- /packages/auth/docs/img/key-rotation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/auth/docs/img/key-rotation.png -------------------------------------------------------------------------------- /packages/auth/docs/img/lockboxes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/auth/docs/img/lockboxes.png -------------------------------------------------------------------------------- /packages/auth/docs/img/sigchain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/auth/docs/img/sigchain.png -------------------------------------------------------------------------------- /packages/auth/docs/img/sigchallenge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/auth/docs/img/sigchallenge.png -------------------------------------------------------------------------------- /packages/auth/docs/internals.md: -------------------------------------------------------------------------------- 1 | # Internals 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
✍🔗 Signature chain
🔐📦 Lockbox
🔑🗝 Keyset
👩🦱 User
💻Context
👵👨🏻‍🦲👳🏽‍♂️👩🏾 Team
11 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@localfirst/auth", 3 | "version": "6.0.0", 4 | "private": false, 5 | "author": { 6 | "name": "Herb Caudill", 7 | "email": "herb@devresults.com" 8 | }, 9 | "description": "Decentralized authentication and authorization for team collaboration", 10 | "repository": "http://github.com/local-first-web/auth", 11 | "license": "MIT", 12 | "type": "module", 13 | "exports": "./dist/index.js", 14 | "types": "./dist/index.d.ts", 15 | "engines": { 16 | "node": ">=18" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "scripts": { 22 | "build": "tsup", 23 | "postbuild": "tsc -p tsconfig.build.json --emitDeclarationOnly", 24 | "preinstall": "npx only-allow pnpm", 25 | "test": "vitest", 26 | "test:log": "cross-env DEBUG='localfirst*' DEBUG_COLORS=1 vitest --reporter basic" 27 | }, 28 | "dependencies": { 29 | "@herbcaudill/eventemitter42": "^0.3.1", 30 | "@localfirst/crdx": "workspace:*", 31 | "@localfirst/crypto": "workspace:*", 32 | "@localfirst/shared": "workspace:*", 33 | "@paralleldrive/cuid2": "^2.2.2", 34 | "lodash-es": "^4.17.21", 35 | "msgpackr": "^1.10.0", 36 | "xstate": "^5.9.1" 37 | }, 38 | "publishConfig": { 39 | "access": "public" 40 | }, 41 | "gitHead": "9a7b871e9e34268b32cc6e574189ec2350787b81" 42 | } 43 | -------------------------------------------------------------------------------- /packages/auth/src/connection/deriveSharedKey.ts: -------------------------------------------------------------------------------- 1 | import { hash, hashBytes, type Base58, base58 } from '@localfirst/crypto' 2 | import { HashPurpose } from 'util/index.js' 3 | 4 | /** 5 | * Takes two seeds (in this case, provided by each of two peers that are connecting) and 6 | * deterministically derives a shared key. 7 | */ 8 | export const deriveSharedKey = (a: T, b: T): T => { 9 | const aBytes: Uint8Array = typeof a === 'string' ? base58.decode(a) : a 10 | const bBytes: Uint8Array = typeof b === 'string' ? base58.decode(b) : b 11 | const concatenatedSeeds = [aBytes, bBytes] 12 | .sort(byteArraySortComparator) // Ensure that the seeds are in a predictable order 13 | .reduce((result, seed) => new Uint8Array([...result, ...seed]), new Uint8Array()) // Concatenate 14 | 15 | const hashFn = typeof a === 'string' ? hash : hashBytes 16 | return hashFn(HashPurpose.SHARED_KEY, concatenatedSeeds) as T 17 | } 18 | 19 | const byteArraySortComparator = (a: Uint8Array, b: Uint8Array) => { 20 | const aString = a.toString() 21 | const bString = b.toString() 22 | if (aString < bString) return -1 23 | if (aString > bString) return 1 24 | return 0 25 | } 26 | -------------------------------------------------------------------------------- /packages/auth/src/connection/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Connection.js' 2 | export * from './types.js' 3 | export * from './message.js' 4 | export * from './errors.js' 5 | -------------------------------------------------------------------------------- /packages/auth/src/connection/test/deriveSharedKey.test.ts: -------------------------------------------------------------------------------- 1 | import { type Base58 } from '@localfirst/crdx' 2 | import { describe, expect, it } from 'vitest' 3 | import { deriveSharedKey } from '../deriveSharedKey.js' 4 | import { base58 } from '@localfirst/crypto' 5 | 6 | describe('deriveSharedKey', () => { 7 | it('result should be the same regardless of order of parameters', () => { 8 | const aliceSecret = 'EfLuJLNo6hQPNaqvLHQq9Xc3KQNXJE2erWF4FQbuCZ8' as Base58 9 | const bobSecret = '5gby4eLBSChNvb6UxNVbUuJMKBNfTeqpi5xE27MDWibM' as Base58 10 | 11 | const aliceKey = deriveSharedKey(aliceSecret, bobSecret) 12 | const bobKey = deriveSharedKey(bobSecret, aliceSecret) 13 | 14 | expect(aliceKey).toEqual(bobKey) 15 | }) 16 | 17 | it('works with byte arrays', () => { 18 | const aliceSecret = base58.decode('EfLuJLNo6hQPNaqvLHQq9Xc3KQNXJE2erWF4FQbuCZ8') 19 | const bobSecret = base58.decode('5gby4eLBSChNvb6UxNVbUuJMKBNfTeqpi5xE27MDWibM') 20 | 21 | const aliceKey = deriveSharedKey(aliceSecret, bobSecret) 22 | const bobKey = deriveSharedKey(bobSecret, aliceSecret) 23 | 24 | expect(aliceKey).toEqual(bobKey) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /packages/auth/src/device/createDevice.ts: -------------------------------------------------------------------------------- 1 | import { createKeyset, type UnixTimestamp } from '@localfirst/crdx' 2 | import { createId } from '@paralleldrive/cuid2' 3 | import { randomKey } from '@localfirst/crypto' 4 | import type { DeviceWithSecrets } from './types.js' 5 | import { KeyType } from 'util/index.js' 6 | 7 | export const createDevice = ({ 8 | userId, 9 | deviceName, 10 | deviceInfo = {}, 11 | created = Date.now() as UnixTimestamp, 12 | seed = randomKey(), 13 | }: Params): DeviceWithSecrets => { 14 | const deviceId = createId() 15 | const keys = createKeyset({ type: KeyType.DEVICE, name: deviceId }, seed) 16 | return { userId, deviceId, deviceName, keys, created, deviceInfo } 17 | } 18 | 19 | type Params = { 20 | userId: string 21 | deviceName: string 22 | deviceInfo?: any 23 | created?: UnixTimestamp 24 | seed?: string 25 | } 26 | -------------------------------------------------------------------------------- /packages/auth/src/device/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createDevice.js' 2 | export * from './redact.js' 3 | export * from './types.js' 4 | -------------------------------------------------------------------------------- /packages/auth/src/device/redact.ts: -------------------------------------------------------------------------------- 1 | import { redactKeys } from '@localfirst/crdx' 2 | import { type DeviceWithSecrets, type Device } from 'device/types.js' 3 | 4 | export const redactDevice = (device: DeviceWithSecrets): Device => ({ 5 | ...device, 6 | keys: redactKeys(device.keys), 7 | }) 8 | -------------------------------------------------------------------------------- /packages/auth/src/device/types.ts: -------------------------------------------------------------------------------- 1 | import type { Keyset, KeysetWithSecrets, UnixTimestamp } from '@localfirst/crdx' 2 | 3 | export type DeviceInfo = { 4 | userId: string 5 | deviceId: string 6 | deviceName: string 7 | deviceInfo?: any 8 | created?: UnixTimestamp 9 | } 10 | 11 | export type DeviceWithSecrets = { 12 | keys: KeysetWithSecrets 13 | } & DeviceInfo 14 | 15 | export type Device = { 16 | keys: Keyset 17 | } & DeviceInfo 18 | 19 | export type FirstUseDeviceWithSecrets = Omit 20 | export type FirstUseDevice = Omit 21 | -------------------------------------------------------------------------------- /packages/auth/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Connection } from './connection/index.js' 2 | export { createDevice, redactDevice, type Device } from './device/index.js' 3 | export { generateProof } from './invitation/index.js' 4 | export { Team, createTeam, load as loadTeam } from './team/index.js' 5 | 6 | export * as connection from './connection/index.js' 7 | export * as device from './device/index.js' 8 | export * as invitation from './invitation/index.js' 9 | export * as role from './role/index.js' 10 | export * from './role/constants.js' 11 | export * from './team/constants.js' 12 | export * from './util/constants.js' 13 | export * from './server/castServer.js' 14 | 15 | export type * from './connection/errors.js' 16 | export type * from './connection/message.js' 17 | export type * from './connection/types.js' 18 | export type * from './team/context.js' 19 | export type * from './device/types.js' 20 | export type * from './invitation/types.js' 21 | export type * from './role/types.js' 22 | export type * from './server/types.js' 23 | export type * from './team/types.js' 24 | 25 | export { graphSummary } from './util/graphSummary.js' 26 | 27 | export { 28 | createKeyset, 29 | createUser, 30 | redactKeys, 31 | redactUser, 32 | type Base58, 33 | type Hash, 34 | type Keyring, 35 | type Keyset, 36 | type KeysetWithSecrets, 37 | type LinkBody, 38 | type UnixTimestamp, 39 | type User, 40 | type UserWithSecrets, 41 | } from '@localfirst/crdx' 42 | 43 | export { asymmetric, signatures, symmetric } from '@localfirst/crypto' 44 | -------------------------------------------------------------------------------- /packages/auth/src/invitation/deriveId.ts: -------------------------------------------------------------------------------- 1 | import { type Hash, hash, stretch } from '@localfirst/crypto' 2 | import { HashPurpose } from 'util/index.js' 3 | 4 | export function deriveId(seed: string) { 5 | // ## Step 1b 6 | // The iKey is stretched using `scrypt` to discourage brute-force attacks (docs refer to this as 7 | // the `siKey`) 8 | const stretchedKey = stretch(seed) 9 | 10 | // ## Step 1c 11 | // The invitation id is derived from the stretched iKey, so Bob can generate it independently. 12 | // This will be visible in the signature chain and serves to uniquely identify the invitation. 13 | // (Keybase docs: `inviteID`) 14 | return hash(HashPurpose.INVITATION, stretchedKey).slice(0, 15) as Hash 15 | } 16 | -------------------------------------------------------------------------------- /packages/auth/src/invitation/generateProof.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from '@localfirst/shared' 2 | import { signatures } from '@localfirst/crypto' 3 | import { deriveId } from 'invitation/deriveId.js' 4 | import { type ProofOfInvitation } from 'invitation/types.js' 5 | import { generateStarterKeys } from './generateStarterKeys.js' 6 | import { normalize } from './normalize.js' 7 | 8 | export const generateProof = memoize((seed: string): ProofOfInvitation => { 9 | seed = normalize(seed) 10 | 11 | // Bob independently derives the invitation id and the ephemeral keys 12 | const id = deriveId(seed) 13 | const ephemeralKeys = generateStarterKeys(seed) 14 | 15 | // Bob uses the ephemeral keys to sign a message consisting of the invitation id 16 | const payload = { id } 17 | const signature = signatures.sign(payload, ephemeralKeys.signature.secretKey) 18 | 19 | // This signature will be shown to an existing team admin as proof that Bob knows the secret 20 | // invitation key. 21 | return { id, signature } 22 | }) 23 | -------------------------------------------------------------------------------- /packages/auth/src/invitation/generateStarterKeys.ts: -------------------------------------------------------------------------------- 1 | import { createKeyset, EPHEMERAL_SCOPE } from '@localfirst/crdx' 2 | import { normalize } from './normalize.js' 3 | import { memoize } from '@localfirst/shared' 4 | 5 | /** 6 | * This will be Bob's first-use keyset; as soon as he's admitted, he'll provide keys of his own 7 | * choosing (with private keys that nobody else knows, including the person who invited him). From 8 | * this keyset, we'll include the public signature key in the invitation, so other team members can 9 | * verify Bob's proof of invitation. 10 | * 11 | * Since this keyset is derived from the secret invitation seed, Bob can generate it independently. 12 | * Besides using it to generate his proof, he'll also need it to open lockboxes when he first joins. 13 | */ 14 | export const generateStarterKeys = memoize((seed: string) => { 15 | seed = normalize(seed) 16 | return createKeyset(EPHEMERAL_SCOPE, seed) 17 | }) 18 | -------------------------------------------------------------------------------- /packages/auth/src/invitation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create.js' 2 | export * from './deriveId.js' 3 | export * from './randomSeed.js' 4 | export * from './generateProof.js' 5 | export * from './generateStarterKeys.js' 6 | export * from './validate.js' 7 | export * from './types.js' 8 | -------------------------------------------------------------------------------- /packages/auth/src/invitation/normalize.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Removes all non-alphanumeric keys from the invitation. Normalizing the key this way allows us to 3 | * show it to the user split into blocks (e.g. `4kgd 5mwq 5z4f mfwq`) and/or make it URL-safe (e.g. 4 | * `4kgd+5mwq+5z4f+mfwq`). 5 | */ 6 | export const normalize = (secretKey: string) => secretKey.replaceAll(/[^a-z\d]/gi, '') // Only keep alphanumeric chars 7 | -------------------------------------------------------------------------------- /packages/auth/src/lockbox/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'lockbox/create.js' 2 | export * from 'lockbox/open.js' 3 | export * from 'lockbox/rotate.js' 4 | export * from 'lockbox/types.js' 5 | -------------------------------------------------------------------------------- /packages/auth/src/lockbox/open.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from '@localfirst/shared' 2 | import { type KeysetWithSecrets } from '@localfirst/crdx' 3 | import { asymmetric } from '@localfirst/crypto' 4 | import { type Lockbox } from 'lockbox/types.js' 5 | 6 | export const open = memoize( 7 | (lockbox: Lockbox, decryptionKeys: KeysetWithSecrets): KeysetWithSecrets => { 8 | const { encryptionKey, encryptedPayload } = lockbox 9 | 10 | const decrypted = asymmetric.decryptBytes({ 11 | cipher: encryptedPayload, 12 | senderPublicKey: encryptionKey.publicKey, 13 | recipientSecretKey: decryptionKeys.encryption.secretKey, 14 | }) 15 | const keys = decrypted as unknown as KeysetWithSecrets 16 | 17 | return keys 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /packages/auth/src/lockbox/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Base58, 3 | type KeyMetadata, 4 | type Keyset, 5 | type KeysetWithSecrets, 6 | } from '@localfirst/crdx' 7 | 8 | export type KeyManifest = KeyMetadata & { 9 | publicKey: Base58 10 | } 11 | 12 | // Type guard 13 | export const isKeyManifest = ( 14 | keys: Keyset | KeysetWithSecrets | KeyManifest 15 | ): keys is KeyManifest => keys.hasOwnProperty('publicKey') 16 | 17 | export type Lockbox = { 18 | /** The public key of the keypair used to encrypt this lockbox */ 19 | encryptionKey: { 20 | type: 'EPHEMERAL' 21 | publicKey: Base58 22 | } 23 | 24 | /** Manifest for the keyset that can open this lockbox (the lockbox recipient's keys) */ 25 | recipient: KeyManifest 26 | 27 | /** Manifest for the keyset that is in this lockbox (the lockbox contents) */ 28 | contents: KeyManifest 29 | 30 | /** The encrypted keyset */ 31 | encryptedPayload: Uint8Array 32 | } 33 | -------------------------------------------------------------------------------- /packages/auth/src/role/constants.ts: -------------------------------------------------------------------------------- 1 | export const ADMIN = 'admin' 2 | -------------------------------------------------------------------------------- /packages/auth/src/role/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'role/constants.js' 2 | export * from 'role/types.js' 3 | -------------------------------------------------------------------------------- /packages/auth/src/role/types.ts: -------------------------------------------------------------------------------- 1 | export type PermissionsMap = Record 2 | 3 | export type Role = { 4 | roleName: string 5 | permissions?: PermissionsMap 6 | } 7 | -------------------------------------------------------------------------------- /packages/auth/src/server/castServer.ts: -------------------------------------------------------------------------------- 1 | import type { User, UserWithSecrets } from '@localfirst/crdx' 2 | import type { Server, ServerWithSecrets } from './types.js' 3 | import type { Device, DeviceWithSecrets } from 'device/index.js' 4 | import type { Member } from 'team/index.js' 5 | 6 | const toMember = (server: Server): Member => ({ 7 | userId: server.host, 8 | userName: server.host, 9 | keys: server.keys, 10 | roles: [], 11 | }) 12 | 13 | const toUser = (server: T) => 14 | ({ 15 | userId: server.host, 16 | userName: server.host, 17 | keys: server.keys, 18 | }) as T extends Server ? User : UserWithSecrets 19 | 20 | const toDevice = (server: T) => 21 | ({ 22 | userId: server.host, 23 | deviceName: server.host, 24 | deviceId: server.host, 25 | keys: server.keys, 26 | }) as T extends Server ? Device : DeviceWithSecrets 27 | 28 | export const castServer = { toMember, toUser, toDevice } 29 | -------------------------------------------------------------------------------- /packages/auth/src/server/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types.js' 2 | export * from './castServer.js' 3 | -------------------------------------------------------------------------------- /packages/auth/src/server/types.ts: -------------------------------------------------------------------------------- 1 | import { type Keyset, type KeysetWithSecrets } from '@localfirst/crdx' 2 | 3 | export type ServerWithSecrets = { 4 | host: Host 5 | keys: KeysetWithSecrets 6 | } 7 | 8 | export type Server = { 9 | host: Host 10 | keys: Keyset 11 | } 12 | 13 | /** The hostname, possibly including a port number; e.g. `example.com`, `localhost:8080`, `188.26.221.135` */ 14 | export type Host = string 15 | -------------------------------------------------------------------------------- /packages/auth/src/team/bySeniority.ts: -------------------------------------------------------------------------------- 1 | import { isPredecessor } from '@localfirst/crdx' 2 | import { type TeamGraph, type TeamLink } from 'team/types.js' 3 | import { assert } from '@localfirst/shared' 4 | 5 | export const bySeniority = (chain: TeamGraph) => (a: string, b: string) => { 6 | // If one of these created the chain, they win 7 | if (isFounder(chain, a)) return -1 8 | if (isFounder(chain, b)) return 1 9 | 10 | const linkThatAddedMember = (userId: string) => { 11 | const addedMember = (link: TeamLink) => 12 | link.body.type === 'ADD_MEMBER' && link.body.payload.member.userId === userId 13 | const result = Object.values(chain.links).find(addedMember) 14 | assert(result, `Could not find link that added member ${userId}`) 15 | return result 16 | } 17 | 18 | const [addedA, addedB] = [a, b].map(linkThatAddedMember) 19 | 20 | // if A was added first, A comes first in the sort 21 | // ignore coverage 22 | return isPredecessor(chain, addedA, addedB) ? -1 : 1 23 | } 24 | 25 | const isFounder = (chain: TeamGraph, userId: string) => { 26 | const rootLink = chain.links[chain.root] 27 | return rootLink.body.userId === userId 28 | } 29 | -------------------------------------------------------------------------------- /packages/auth/src/team/constants.ts: -------------------------------------------------------------------------------- 1 | import { type KeyScope } from '@localfirst/crdx' 2 | import { type TeamState } from './types.js' 3 | import { ADMIN } from 'role/index.js' 4 | import { KeyType } from 'util/index.js' 5 | 6 | export const ALL = 'ALL' 7 | 8 | export const initialState: TeamState = { 9 | head: [], 10 | teamName: '', 11 | members: [], 12 | servers: [], 13 | roles: [], 14 | lockboxes: [], 15 | invitations: {}, 16 | messages: [], 17 | removedMembers: [], 18 | removedDevices: [], 19 | removedServers: [], 20 | pendingKeyRotations: [], 21 | } 22 | 23 | export const TEAM_SCOPE = { type: KeyType.TEAM, name: KeyType.TEAM } as KeyScope 24 | export const ADMIN_SCOPE = { type: KeyType.ROLE, name: ADMIN } as KeyScope 25 | export const EPHEMERAL_SCOPE = { 26 | type: KeyType.EPHEMERAL, 27 | name: KeyType.EPHEMERAL, 28 | } as KeyScope 29 | -------------------------------------------------------------------------------- /packages/auth/src/team/context.ts: -------------------------------------------------------------------------------- 1 | import { type UserWithSecrets } from '@localfirst/crdx' 2 | import { type DeviceWithSecrets } from 'device/index.js' 3 | import { type ServerWithSecrets } from 'server/index.js' 4 | 5 | export type LocalContext = LocalUserContext | LocalServerContext 6 | 7 | export type LocalUserContext = { 8 | user: UserWithSecrets 9 | device: DeviceWithSecrets 10 | client?: Client 11 | } 12 | 13 | export type LocalServerContext = { 14 | server: ServerWithSecrets 15 | client?: Client 16 | } 17 | 18 | export type Client = { 19 | name: string 20 | version: string 21 | } 22 | -------------------------------------------------------------------------------- /packages/auth/src/team/createTeam.ts: -------------------------------------------------------------------------------- 1 | import { createKeyset } from '@localfirst/crdx' 2 | import { TEAM_SCOPE } from './constants.js' 3 | import { type LocalContext } from 'team/context.js' 4 | import { Team } from 'team/Team.js' 5 | 6 | export function createTeam(teamName: string, context: LocalContext, seed?: string) { 7 | const teamKeys = createKeyset(TEAM_SCOPE, seed) 8 | 9 | return new Team({ teamName, context, teamKeys }) 10 | } 11 | -------------------------------------------------------------------------------- /packages/auth/src/team/getMissingLinks.ts: -------------------------------------------------------------------------------- 1 | import { type Action, type Graph } from '@localfirst/crdx' 2 | 3 | export function getMissingLinks(chain: Graph) { 4 | const parentHashes = Object.values(chain.links) // 5 | .flatMap(link => link.body.prev) as string[] 6 | return [...parentHashes, chain.root, ...chain.head].filter(hash => !(hash in chain.links)) 7 | } 8 | -------------------------------------------------------------------------------- /packages/auth/src/team/getTeamState.ts: -------------------------------------------------------------------------------- 1 | import type { Keyring } from '@localfirst/crdx' 2 | import { deserializeTeamGraph } from './serialize.js' 3 | import { teamMachine } from './teamMachine.js' 4 | 5 | export const getTeamState = (serializedGraph: Uint8Array, keyring: Keyring) => { 6 | const graph = deserializeTeamGraph(serializedGraph, keyring) 7 | return teamMachine(graph) 8 | } 9 | -------------------------------------------------------------------------------- /packages/auth/src/team/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Team.js' 2 | export * from './constants.js' 3 | export * from './context.js' 4 | export * from './createTeam.js' 5 | export * from './decryptTeamGraph.js' 6 | export * from '../connection/getDeviceUserFromGraph.js' 7 | export * from './isAdminOnlyAction.js' 8 | export * from './load.js' 9 | export * from './membershipResolver.js' 10 | export * from './redactUser.js' 11 | export * from './types.js' 12 | -------------------------------------------------------------------------------- /packages/auth/src/team/isAdminOnlyAction.ts: -------------------------------------------------------------------------------- 1 | import { type TeamAction, type TeamLinkBody } from './types.js' 2 | 3 | export const isAdminOnlyAction = (action: TeamLinkBody) => { 4 | // Any team member can do these things 5 | const nonAdminActions: Array = [ 6 | 'INVITE_DEVICE', 7 | 'ADD_DEVICE', 8 | 'REMOVE_DEVICE', 9 | 'CHANGE_MEMBER_KEYS', 10 | 'CHANGE_SERVER_KEYS', 11 | 'ADMIT_MEMBER', 12 | 'ADMIT_DEVICE', 13 | ] 14 | 15 | return !nonAdminActions.includes(action.type) 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/load.ts: -------------------------------------------------------------------------------- 1 | import { type Keyring, type KeysetWithSecrets, createKeyring } from '@localfirst/crdx' 2 | import { type TeamGraph } from './types.js' 3 | import { type LocalContext } from 'team/context.js' 4 | import { Team } from 'team/Team.js' 5 | 6 | export const load = ( 7 | source: Uint8Array | TeamGraph, 8 | context: LocalContext, 9 | teamKeys: KeysetWithSecrets | Keyring 10 | ) => { 11 | const teamKeyring = createKeyring(teamKeys) 12 | return new Team({ source, context, teamKeyring }) 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/src/team/redactUser.ts: -------------------------------------------------------------------------------- 1 | import { redactUser as _redactUser, type UserWithSecrets } from '@localfirst/crdx' 2 | import { type Member } from './types.js' 3 | 4 | export const redactUser = (user: UserWithSecrets): Member => ({ 5 | ..._redactUser(user), 6 | roles: [], 7 | }) 8 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/device.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | import { assert } from '@localfirst/shared' 3 | import { server } from './server.js' 4 | import { hasServer } from './hasServer.js' 5 | import { castServer } from 'server/castServer.js' 6 | 7 | export const hasDevice = ( 8 | state: TeamState, 9 | deviceId: string, 10 | options = { includeRemoved: false } 11 | ) => { 12 | return getDevice(state, deviceId, options) !== undefined 13 | } 14 | 15 | export const device = (state: TeamState, deviceId: string, options = { includeRemoved: false }) => { 16 | const device = getDevice(state, deviceId, options) 17 | assert(device, `Device ${deviceId} not found`) 18 | return device 19 | } 20 | 21 | const getDevice = (state: TeamState, deviceId: string, options = { includeRemoved: false }) => { 22 | if (hasServer(state, deviceId)) { 23 | return castServer.toDevice(server(state, deviceId)) 24 | } 25 | const members = state.members.concat(options.includeRemoved ? state.removedMembers : []) 26 | const allDevices = members.flatMap(m => m.devices ?? []) 27 | return ( 28 | allDevices.find(d => d.deviceId === deviceId) ?? 29 | (options.includeRemoved ? state.removedDevices.find(d => d.deviceId === deviceId) : undefined) 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/deviceWasRemoved.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | import { hasDevice } from './device.js' 3 | 4 | export const deviceWasRemoved = (state: TeamState, deviceId: string) => { 5 | if (!hasDevice(state, deviceId, { includeRemoved: true })) return false 6 | // throw new Error(`Device ${deviceId} does not exist`) 7 | return state.removedDevices.some(d => d.keys.name === deviceId) 8 | } 9 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/hasMember.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | 3 | export const hasMember = (state: TeamState, userId: string) => 4 | state.members.find(m => m.userId === userId) !== undefined 5 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/hasRole.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | 3 | export const hasRole = (state: TeamState, roleName: string) => 4 | state.roles.find(r => r.roleName === roleName) !== undefined 5 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/hasServer.ts: -------------------------------------------------------------------------------- 1 | import { type Host } from 'server/index.js' 2 | import { type TeamState } from 'team/types.js' 3 | 4 | export const hasServer = (state: TeamState, host: Host) => 5 | state.servers.find(s => s.host === host) !== undefined 6 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './device.js' 2 | export * from './deviceWasRemoved.js' 3 | export * from './hasMember.js' 4 | export * from './hasRole.js' 5 | export * from './hasServer.js' 6 | export * from './invitation.js' 7 | export * from './keyMap.js' 8 | export * from './keyring.js' 9 | export * from './keys.js' 10 | export * from './lockboxesInScope.js' 11 | export * from './member.js' 12 | export * from './memberByDeviceId.js' 13 | export * from './memberHasRole.js' 14 | export * from './memberWasRemoved.js' 15 | export * from './membersInRole.js' 16 | export * from './messages.js' 17 | export * from './role.js' 18 | export * from './server.js' 19 | export * from './serverWasRemoved.js' 20 | export * from './teamKeyring.js' 21 | export * from './visibleKeys.js' 22 | export * from './visibleScopes.js' 23 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/invitation.ts: -------------------------------------------------------------------------------- 1 | import { type Base58 } from '@localfirst/crdx' 2 | import { type TeamState } from 'team/types.js' 3 | import { assert } from '@localfirst/shared' 4 | 5 | export function hasInvitation(state: TeamState, id: Base58): boolean { 6 | return id in state.invitations 7 | } 8 | 9 | export function getInvitation(state: TeamState, id: Base58) { 10 | assert(hasInvitation(state, id), `No invitation with id '${id}' found.`) 11 | const invitation = state.invitations[id] 12 | return invitation 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/keyMap.ts: -------------------------------------------------------------------------------- 1 | import { type KeysetWithSecrets } from '@localfirst/crdx' 2 | import { visibleKeys } from './visibleKeys.js' 3 | import { type TeamState } from 'team/types.js' 4 | 5 | /** Returns all keysets from the current device's lockboxes in a structure that looks like this: 6 | * ```js 7 | * { 8 | * TEAM: { 9 | * TEAM: [ gen0, gen1, gen2, ... ], // <- all keys starting with generation 0 10 | * ROLE: { 11 | * admin: [ gen0, ... ] 12 | * managers: [ gen0, ...] 13 | * }, 14 | * USER: { 15 | * alice: [ gen0, ... ] 16 | * } 17 | * } 18 | * ``` 19 | */ 20 | export const keyMap = (state: TeamState, deviceKeys: KeysetWithSecrets): KeyMap => { 21 | // Get all the keys those keys can access 22 | const allVisibleKeys = visibleKeys(state, deviceKeys) 23 | 24 | // Structure these keys as described above 25 | return allVisibleKeys.reduce(organizeKeysIntoMap, {}) 26 | } 27 | 28 | const organizeKeysIntoMap = (result: KeyMap, keys: KeysetWithSecrets) => { 29 | const { type, name, generation } = keys 30 | const keysetsForScope = result[type] ?? {} 31 | const keysetHistory = keysetsForScope[name] ?? [] 32 | keysetHistory[generation] = keys 33 | return { 34 | ...result, 35 | [type]: { 36 | ...keysetsForScope, 37 | [name]: keysetHistory, 38 | }, 39 | } as KeyMap 40 | } 41 | 42 | type KeyMap = Record> 43 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/keyring.ts: -------------------------------------------------------------------------------- 1 | import { type KeysetWithSecrets, createKeyring, type KeyScope } from '@localfirst/crdx' 2 | import { type TeamState } from 'team/types.js' 3 | import { keyMap } from './keyMap.js' 4 | 5 | /** 6 | * Returns a keyring containing all generations of keys for the given scope. 7 | */ 8 | 9 | export const keyring = (state: TeamState, scope: KeyScope, keys: KeysetWithSecrets) => { 10 | const foo = keyMap(state, keys) 11 | const allKeys = foo[scope.type]?.[scope.name] 12 | return createKeyring(allKeys) 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/keys.ts: -------------------------------------------------------------------------------- 1 | import { type KeyMetadata, type KeyScope, type KeysetWithSecrets } from '@localfirst/crdx' 2 | import { keyMap } from './keyMap.js' 3 | import { type TeamState } from 'team/types.js' 4 | import { assert } from '@localfirst/shared' 5 | import { lockboxSummary } from 'util/lockboxSummary.js' 6 | 7 | /** Returns the keys for the given scope, if they are in a lockbox that the current device has access to */ 8 | export const keys = ( 9 | state: TeamState, 10 | deviceKeys: KeysetWithSecrets, 11 | scope: KeyScope | KeyMetadata 12 | ) => { 13 | const { type, name } = scope 14 | 15 | const keysFromLockboxes = keyMap(state, deviceKeys) 16 | const keys = keysFromLockboxes[type] ? keysFromLockboxes[type][name] : undefined 17 | 18 | assert( 19 | keys, 20 | `Couldn't find keys: ${JSON.stringify(scope)} 21 | Device: ${deviceKeys.name} 22 | Available lockboxes: \n- ${state.lockboxes.map(lockboxSummary).join('\n- ')} 23 | Keymap: ${JSON.stringify(keysFromLockboxes, null, 2)}` 24 | ) 25 | 26 | const generation = 27 | 'generation' in scope && scope.generation !== undefined 28 | ? // Return specific generation if requested 29 | scope.generation 30 | : // Use latest generation by default 31 | keys.length - 1 32 | 33 | return keys[generation] 34 | } 35 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/lockboxesInScope.ts: -------------------------------------------------------------------------------- 1 | import { type KeyScope } from '@localfirst/crdx' 2 | import { type Lockbox } from 'lockbox/index.js' 3 | import { type TeamState } from 'team/types.js' 4 | 5 | /** Returns all lockboxes *containing* keys for the given scope */ 6 | export const lockboxesInScope = (state: TeamState, scope: KeyScope): Lockbox[] => { 7 | const lockboxes = state.lockboxes.filter( 8 | ({ contents }) => contents.type === scope.type && contents.name === scope.name 9 | ) 10 | const latestGeneration = lockboxes.reduce(maxGeneration, 0) 11 | const latestLockboxes = lockboxes.filter( 12 | ({ contents }) => contents.generation === latestGeneration 13 | ) 14 | return latestLockboxes 15 | } 16 | 17 | const maxGeneration = (max: number, lockbox: Lockbox) => { 18 | const { generation } = lockbox.contents 19 | if (generation > max) { 20 | return generation 21 | } 22 | 23 | return max 24 | } 25 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/member.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | 3 | export const member = (state: TeamState, userId: string, options = { includeRemoved: false }) => { 4 | const membersToSearch = [ 5 | ...state.members, 6 | ...(options.includeRemoved ? state.removedMembers : []), 7 | ] 8 | const member = membersToSearch.find(m => m.userId === userId) 9 | 10 | if (member === undefined) { 11 | throw new Error(`A member named '${userId}' was not found`) 12 | } 13 | 14 | return member 15 | } 16 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/memberByDeviceId.ts: -------------------------------------------------------------------------------- 1 | import { castServer } from 'server/castServer.js' 2 | import type { TeamState } from '../index.js' 3 | import { member, device, server, hasServer } from './index.js' 4 | 5 | export const memberByDeviceId = ( 6 | state: TeamState, 7 | deviceId: string, 8 | options = { includeRemoved: false } 9 | ) => { 10 | if (hasServer(state, deviceId)) return castServer.toMember(server(state, deviceId)) 11 | const { userId } = device(state, deviceId, options) 12 | return member(state, userId, options) 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/memberHasRole.ts: -------------------------------------------------------------------------------- 1 | import { ADMIN } from 'role/index.js' 2 | import * as select from 'team/selectors/index.js' 3 | import { type TeamState } from 'team/types.js' 4 | 5 | export const memberHasRole = (state: TeamState, userId: string, role: string) => { 6 | if (!select.hasMember(state, userId)) { 7 | return false 8 | } 9 | 10 | const member = select.member(state, userId) 11 | const { roles = [] } = member 12 | return roles.includes(role) 13 | } 14 | 15 | export const memberIsAdmin = (state: TeamState, userId: string) => 16 | memberHasRole(state, userId, ADMIN) 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/memberWasRemoved.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | 3 | export const memberWasRemoved = (state: TeamState, userId: string) => { 4 | return state.removedMembers.some(m => m.userId === userId) 5 | } 6 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/membersInRole.ts: -------------------------------------------------------------------------------- 1 | import { ADMIN } from 'role/index.js' 2 | import { type TeamState } from 'team/types.js' 3 | 4 | export const membersInRole = (state: TeamState, roleName: string) => 5 | state.members.filter(member => member.roles?.includes(roleName)) 6 | 7 | export const admins = (state: TeamState) => membersInRole(state, ADMIN) 8 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/messages.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | 3 | export const messages = (state: TeamState) => state.messages 4 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/role.ts: -------------------------------------------------------------------------------- 1 | import { type TeamState } from 'team/types.js' 2 | 3 | export const role = (state: TeamState, roleName: string) => { 4 | const role = state.roles.find(r => r.roleName === roleName) 5 | if (!role) throw new Error(`A role called '${roleName}' was not found`) 6 | 7 | return role 8 | } 9 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/server.ts: -------------------------------------------------------------------------------- 1 | import { type Host } from 'server/index.js' 2 | import { type TeamState } from 'team/types.js' 3 | 4 | export const server = (state: TeamState, host: Host, options = { includeRemoved: false }) => { 5 | const serversToSearch = [ 6 | ...state.servers, 7 | ...(options.includeRemoved ? state.removedServers : []), 8 | ] 9 | const server = serversToSearch.find(s => s.host === host) 10 | 11 | if (server === undefined) { 12 | throw new Error(`A server with host '${host}' was not found`) 13 | } 14 | 15 | return server 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/serverWasRemoved.ts: -------------------------------------------------------------------------------- 1 | import { type Host } from 'server/index.js' 2 | import { type TeamState } from 'team/types.js' 3 | 4 | export const serverWasRemoved = (state: TeamState, host: Host) => 5 | state.removedServers.some(s => s.host === host) 6 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/teamKeyring.ts: -------------------------------------------------------------------------------- 1 | import { type KeysetWithSecrets } from '@localfirst/crdx' 2 | import { type TeamState } from 'team/types.js' 3 | import { KeyType } from 'util/types.js' 4 | import { keyring } from './keyring.js' 5 | 6 | const { TEAM } = KeyType 7 | 8 | export const teamKeyring = (state: TeamState, keys: KeysetWithSecrets) => 9 | keyring(state, { type: TEAM, name: TEAM }, keys) 10 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/visibleKeys.ts: -------------------------------------------------------------------------------- 1 | import { type KeysetWithSecrets } from '@localfirst/crdx' 2 | import { open } from 'lockbox/index.js' 3 | import { type TeamState } from 'team/types.js' 4 | 5 | /** 6 | * Returns all keys that can be accessed directly or indirectly (via lockboxes) by the given keyset 7 | * @param state 8 | * @param keyset 9 | */ 10 | export const visibleKeys = (state: TeamState, keyset: KeysetWithSecrets): KeysetWithSecrets[] => { 11 | const { lockboxes } = state 12 | const { publicKey } = keyset.encryption 13 | 14 | // What lockboxes can I open with these keys? 15 | const lockboxesICanOpen = lockboxes.filter(({ recipient }) => recipient.publicKey === publicKey) 16 | 17 | // Collect all the keys from those lockboxes 18 | const keysets = lockboxesICanOpen.map(lockbox => open(lockbox, keyset)) 19 | 20 | // Recursively get all the keys *those* keys can access 21 | const keys = keysets.flatMap(keyset => visibleKeys(state, keyset)) 22 | 23 | return [...keysets, ...keys] 24 | } 25 | -------------------------------------------------------------------------------- /packages/auth/src/team/selectors/visibleScopes.ts: -------------------------------------------------------------------------------- 1 | import { type KeyScope } from '@localfirst/crdx' 2 | import { type TeamState } from 'team/types.js' 3 | import { unique } from 'util/unique.js' 4 | 5 | export const visibleScopes = (state: TeamState, { type, name }: KeyScope): KeyScope[] => { 6 | // Find the keys that the given key can see 7 | const scopes = state.lockboxes 8 | .filter(({ recipient }) => recipient.type === type && recipient.name === name) 9 | .map(({ contents: { type, name } }) => ({ type, name }) as KeyScope) 10 | 11 | // Recursively find all the keys that _those_ keys can see 12 | const derivedScopes = scopes.flatMap(scope => visibleScopes(state, scope)) 13 | 14 | const allScopes = [...scopes, ...derivedScopes] 15 | return unique(allScopes, s => s.name + s.type) 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/serialize.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decryptGraph, 3 | getChildMap, 4 | type EncryptedGraph, 5 | type Keyring, 6 | type LinkMap, 7 | } from '@localfirst/crdx' 8 | import { type TeamGraph } from './types.js' 9 | import { pack, unpack } from 'msgpackr' 10 | 11 | export const EMPTY: LinkMap = {} 12 | 13 | export const serializeTeamGraph = (graph: TeamGraph) => { 14 | const childMap = getChildMap(graph) 15 | 16 | // Leave out the unencrypted `links` element 17 | const { encryptedLinks, head, root } = graph 18 | const encryptedGraph = { encryptedLinks, childMap, head, root } 19 | 20 | const serialized = pack(encryptedGraph) 21 | return toUint8Array(serialized) 22 | } 23 | 24 | export const deserializeTeamGraph = (serialized: Uint8Array, keys: Keyring): TeamGraph => { 25 | const encryptedGraph = unpack(serialized) as EncryptedGraph 26 | return decryptGraph({ encryptedGraph, keys }) 27 | } 28 | 29 | export const maybeDeserialize = ( 30 | source: Uint8Array | TeamGraph, 31 | teamKeyring: Keyring 32 | ): TeamGraph => (isGraph(source) ? source : deserializeTeamGraph(source, teamKeyring)) 33 | 34 | const isGraph = (source: Uint8Array | TeamGraph): source is TeamGraph => 35 | source?.hasOwnProperty('root') 36 | 37 | // buffer to uint8array 38 | const toUint8Array = (buf: globalThis.Buffer) => 39 | new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) 40 | -------------------------------------------------------------------------------- /packages/auth/src/team/setHead.ts: -------------------------------------------------------------------------------- 1 | import { type TeamLink, type Transform } from './types.js' 2 | 3 | export const setHead = 4 | (link: TeamLink): Transform => 5 | state => ({ ...state, head: [link.hash] }) 6 | -------------------------------------------------------------------------------- /packages/auth/src/team/teamMachine.ts: -------------------------------------------------------------------------------- 1 | import { makeMachine } from '@localfirst/crdx' 2 | import { initialState } from './constants.js' 3 | import { reducer } from './reducer.js' 4 | import { membershipResolver as resolver } from './membershipResolver.js' 5 | 6 | export const teamMachine = makeMachine({ initialState, reducer, resolver }) 7 | -------------------------------------------------------------------------------- /packages/auth/src/team/test/messages.test.ts: -------------------------------------------------------------------------------- 1 | import { setup } from 'util/testing/index.js' 2 | import { describe, expect, it } from 'vitest' 3 | 4 | describe('Team', () => { 5 | describe('messages', () => { 6 | it('an admin member can add and retrieve a message', () => { 7 | const { alice } = setup('alice') 8 | 9 | // Alice posts a message to the team graph 10 | const message = { 11 | type: 'SUPER_IMPORTANT_MESSAGE', 12 | payload: { 13 | foo: 'pizza', 14 | }, 15 | } 16 | alice.team.addMessage(message) 17 | 18 | // Alice retrieves all the messages 19 | const messages = alice.team.messages() 20 | 21 | // There's only one 22 | expect(messages.length).toBe(1) 23 | 24 | // It's the one Alice posted 25 | expect(messages[0]).toEqual(message) 26 | }) 27 | 28 | // assuming for now this is the behavior we want 29 | it('a non-admin member cannot add messages', () => { 30 | const { bob } = setup(['alice', { user: 'bob', admin: false }]) 31 | 32 | // Bob tries to post a message to the team graph 33 | const message = { 34 | type: 'SUPER_IMPORTANT_MESSAGE', 35 | payload: { 36 | foo: 'pizza', 37 | }, 38 | } 39 | 40 | expect(() => bob.team.addMessage(message)).toThrow() 41 | 42 | expect(bob.team.messages().length).toBe(0) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/addDevice.ts: -------------------------------------------------------------------------------- 1 | import { type Device } from 'device/index.js' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const addDevice = 5 | (device: Device): Transform => 6 | state => { 7 | const { userId } = device 8 | return { 9 | ...state, 10 | 11 | // Add device to the member's list of devices 12 | members: state.members.map(member => { 13 | if (member.userId === userId) { 14 | const { devices = [] } = member 15 | 16 | // Don't add the device if it's already in the list 17 | if (devices.find(d => d.deviceId === device.deviceId)) { 18 | return member 19 | } else 20 | return { 21 | ...member, 22 | devices: [...devices, device], 23 | } 24 | } 25 | 26 | return member 27 | }), 28 | 29 | // Remove device ID from list of removed devices (e.g. if it was removed at one point and is being re-added) 30 | removedDevices: state.removedDevices.filter(d => d.keys.name === device.deviceId), 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/addMember.ts: -------------------------------------------------------------------------------- 1 | import { type Member } from 'team/index.js' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const addMember = 5 | (newMember: Member): Transform => 6 | state => ({ 7 | ...state, 8 | 9 | // Add member to the team's list of members 10 | members: [ 11 | ...state.members, 12 | { 13 | ...newMember, 14 | roles: [], 15 | }, 16 | ], 17 | 18 | // Remove member's name from list of removed members (e.g. if member was removed and is now being re-added) 19 | removedMembers: state.removedMembers.filter(m => m.userId === newMember.userId), 20 | }) 21 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/addMemberRoles.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | 3 | export const addMemberRoles = (userId: string, roles: string[] = []): Transform[] => 4 | roles.map(roleName => state => ({ 5 | ...state, 6 | members: state.members.map(member => ({ 7 | ...member, 8 | roles: 9 | member.userId !== userId || member.roles.includes(roleName) 10 | ? member.roles 11 | : [...member.roles, roleName], 12 | })), 13 | })) 14 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/addMessage.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | 3 | export const addMessage = 4 | (message: unknown): Transform => 5 | state => ({ 6 | ...state, 7 | messages: [...state.messages, message], 8 | }) 9 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/addRole.ts: -------------------------------------------------------------------------------- 1 | import { type Role } from 'role/index.js' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const addRole = 5 | (newRole: Role): Transform => 6 | state => ({ 7 | ...state, 8 | roles: [...state.roles, newRole], 9 | }) 10 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/addServer.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'server/index.js' 2 | import type { TeamState, Transform } from 'team/types.js' 3 | import { unique } from 'util/unique.js' 4 | 5 | export const addServer = 6 | (newServer: Server): Transform => 7 | state => { 8 | const newState: TeamState = { 9 | ...state, 10 | 11 | // Add server to the team's list of servers 12 | servers: unique([...state.servers, newServer]), 13 | 14 | // Remove server's url from list of removed servers (e.g. if server was removed and is now being re-added) 15 | removedServers: state.removedServers.filter(m => m.host !== newServer.host), 16 | } 17 | return newState 18 | } 19 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/changeMemberKeys.ts: -------------------------------------------------------------------------------- 1 | import { type Keyset } from '@localfirst/crdx' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const changeMemberKeys = 5 | (keys: Keyset): Transform => 6 | state => ({ 7 | ...state, 8 | members: state.members.map(member => 9 | member.userId === keys.name 10 | ? { 11 | ...member, 12 | keys, // 🡐 replace keys with new ones 13 | } 14 | : member 15 | ), 16 | }) 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/changeServerKeys.ts: -------------------------------------------------------------------------------- 1 | import { type Keyset } from '@localfirst/crdx' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const changeServerKeys = 5 | (keys: Keyset): Transform => 6 | state => ({ 7 | ...state, 8 | servers: state.servers.map(server => 9 | server.host === keys.name 10 | ? { 11 | ...server, 12 | keys, // 🡐 replace keys with new ones 13 | } 14 | : server 15 | ), 16 | }) 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/collectLockboxes.ts: -------------------------------------------------------------------------------- 1 | import { type Lockbox } from 'lockbox/index.js' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const collectLockboxes = 5 | (newLockboxes?: Lockbox[]): Transform => 6 | state => { 7 | const { lockboxes } = state 8 | return newLockboxes ? { ...state, lockboxes: lockboxes.concat(newLockboxes) } : state 9 | } 10 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './addDevice.js' 2 | export * from './addMember.js' 3 | export * from './addMemberRoles.js' 4 | export * from './addMessage.js' 5 | export * from './addRole.js' 6 | export * from './addServer.js' 7 | export * from './changeMemberKeys.js' 8 | export * from './changeServerKeys.js' 9 | export * from './collectLockboxes.js' 10 | export * from './postInvitation.js' 11 | export * from './removeDevice.js' 12 | export * from './removeMember.js' 13 | export * from './removeMemberRole.js' 14 | export * from './removeRole.js' 15 | export * from './removeServer.js' 16 | export * from './revokeInvitation.js' 17 | export * from './rotateKeys.js' 18 | export * from './setTeamName.js' 19 | export * from './useInvitation.js' 20 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/postInvitation.ts: -------------------------------------------------------------------------------- 1 | import { type Invitation } from 'invitation/index.js' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const postInvitation = 5 | (invitation: Invitation): Transform => 6 | state => { 7 | const invitationState = { 8 | ...invitation, 9 | uses: 0, 10 | revoked: false, 11 | } 12 | 13 | return { 14 | ...state, 15 | invitations: { 16 | ...state.invitations, 17 | [invitation.id]: invitationState, 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/removeMember.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | 3 | export const removeMember = 4 | (userId: string): Transform => 5 | state => { 6 | const remainingMembers = state.members.filter(m => m.userId !== userId) 7 | const removedMember = state.members.find(m => m.userId === userId) // The member that was removed 8 | 9 | const removedMembers = [...state.removedMembers] 10 | if (removedMember) removedMembers.push(removedMember) 11 | 12 | return { 13 | ...state, 14 | members: remainingMembers, 15 | removedMembers, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/removeMemberRole.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | import { KeyType } from 'util/index.js' 3 | 4 | export const removeMemberRole = 5 | (userId: string, roleName: string): Transform => 6 | state => ({ 7 | ...state, 8 | 9 | // Remove this role from this member's list of roles 10 | members: state.members.map(member => ({ 11 | ...member, 12 | roles: 13 | member.userId === userId // 14 | ? member.roles.filter(r => r !== roleName) // Leave other members' roles alone 15 | : member.roles, 16 | })), 17 | 18 | // Remove any lockboxes this member has for this role 19 | lockboxes: state.lockboxes.filter( 20 | lockbox => 21 | !( 22 | lockbox.recipient.name === userId && 23 | lockbox.contents.type === KeyType.ROLE && 24 | lockbox.contents.name === roleName 25 | ) 26 | ), 27 | }) 28 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/removeRole.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | import { KeyType } from 'util/index.js' 3 | 4 | export const removeRole = 5 | (roleName: string): Transform => 6 | state => ({ 7 | ...state, 8 | 9 | // Remove this role 10 | roles: state.roles.filter(role => role.roleName !== roleName), 11 | 12 | // Remove any lockboxes for this role 13 | lockboxes: state.lockboxes.filter( 14 | lockbox => !(lockbox.contents.type === KeyType.ROLE && lockbox.contents.name === roleName) 15 | ), 16 | }) 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/removeServer.ts: -------------------------------------------------------------------------------- 1 | import { type Host } from 'server/index.js' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const removeServer = 5 | (host: Host): Transform => 6 | state => { 7 | const remainingServers = state.servers.filter(m => m.host !== host) 8 | const removedServer = state.servers.find(m => m.host === host) // The server that was removed 9 | 10 | const removedServers = [...state.removedServers] 11 | if (removedServer) { 12 | removedServers.push(removedServer) 13 | } 14 | 15 | return { 16 | ...state, 17 | servers: remainingServers, 18 | removedServers, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/revokeInvitation.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | 3 | export const revokeInvitation = 4 | (id: string): Transform => 5 | state => { 6 | const invitations = { ...state.invitations } 7 | const revokedInvitation = { ...invitations[id], revoked: true } 8 | 9 | return { 10 | ...state, 11 | invitations: { 12 | ...invitations, 13 | [id]: revokedInvitation, 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/rotateKeys.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | 3 | export const rotateKeys = 4 | (userId: string): Transform => 5 | state => { 6 | // Remove this user name from the list of pending key rotations 7 | const pendingKeyRotations = state.pendingKeyRotations.filter(u => u !== userId) 8 | 9 | return { 10 | ...state, 11 | pendingKeyRotations, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/setTeamName.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | 3 | export const setTeamName = 4 | (teamName: string): Transform => 5 | state => ({ 6 | ...state, 7 | teamName, 8 | }) 9 | -------------------------------------------------------------------------------- /packages/auth/src/team/transforms/useInvitation.ts: -------------------------------------------------------------------------------- 1 | import { type InvitationState } from 'invitation/index.js' 2 | import { type Transform } from 'team/types.js' 3 | 4 | export const useInvitation = 5 | (id: string): Transform => 6 | state => { 7 | const invitations = { ...state.invitations } 8 | const invitationState: InvitationState = invitations[id] 9 | 10 | const uses = invitationState.uses + 1 11 | 12 | return { 13 | ...state, 14 | invitations: { 15 | ...invitations, 16 | [id]: { 17 | ...invitationState, 18 | uses, 19 | }, 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/auth/src/util/arrayToMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reducer for converting an array of objects to a map containing the same objects, where the key is 3 | * taken from a field in each object (e.g. `id`, `hash`, etc.). 4 | * 5 | * ```ts 6 | * const arr = [ 7 | * {id: 1, ...}, 8 | * {id: 2, ...}, 9 | * {id: 3, ...} 10 | * ] 11 | * const mapped = arr.reduce(arrayToMap('id'), {}) 12 | * // mapped = { 13 | * // 1: {id: 1, ...}, 14 | * // 2: {id: 2, ...}, 15 | * // 3: {id: 3, ...}, 16 | * // } 17 | * ``` 18 | * @param keyField 19 | */ 20 | export const arrayToMap = 21 | (keyField: string) => 22 | >( 23 | result: Record, 24 | current: T 25 | ): Record => ({ 26 | ...result, 27 | [current[keyField]]: current, 28 | }) 29 | -------------------------------------------------------------------------------- /packages/auth/src/util/arraysAreEqual.ts: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | export const arraysAreEqual = (a: string[] | undefined, b: string[] | undefined) => { 3 | if (!a || !b) return false 4 | 5 | const normalize = (array: string[]) => array.sort().join(',') 6 | return normalize(a) === normalize(b) 7 | } 8 | -------------------------------------------------------------------------------- /packages/auth/src/util/clone.ts: -------------------------------------------------------------------------------- 1 | export { clone } from 'lodash-es' 2 | -------------------------------------------------------------------------------- /packages/auth/src/util/composeTransforms.ts: -------------------------------------------------------------------------------- 1 | import { type Transform } from 'team/types.js' 2 | 3 | export const composeTransforms = 4 | (transforms: Transform[]): Transform => 5 | state => 6 | transforms.reduce((state, transform) => transform(state), state) 7 | -------------------------------------------------------------------------------- /packages/auth/src/util/constants.ts: -------------------------------------------------------------------------------- 1 | import { type ValidationResult } from 'util/types.js' 2 | 3 | // avoiding enums 4 | export const SIGNATURE = 'SIGNATURE' 5 | export const ENCRYPTION = 'ENCRYPTION' 6 | export const SYMMETRIC = 'SYMMETRIC' 7 | export const LINK_HASH = 'LINK_HASH' 8 | export const LINK_TO_PREVIOUS = 'LINK_TO_PREVIOUS' 9 | export const INVITATION = 'INVITATION' 10 | export const DEVICE_ID = 'DEVICE_ID' 11 | export const SHARED_KEY = 'SHARED_KEY' 12 | 13 | export const HashPurpose = { 14 | SIGNATURE, 15 | ENCRYPTION, 16 | SYMMETRIC, 17 | LINK_HASH, 18 | LINK_TO_PREVIOUS, 19 | INVITATION, 20 | DEVICE_ID, 21 | SHARED_KEY, 22 | } as const 23 | 24 | export const VALID: ValidationResult = { isValid: true } 25 | -------------------------------------------------------------------------------- /packages/auth/src/util/getScope.ts: -------------------------------------------------------------------------------- 1 | import { type KeyScope } from '@localfirst/crdx' 2 | 3 | export const getScope = (x: T): KeyScope => ({ 4 | type: x.type, 5 | name: x.name, 6 | }) 7 | -------------------------------------------------------------------------------- /packages/auth/src/util/graphSummary.ts: -------------------------------------------------------------------------------- 1 | import { getSequence } from '@localfirst/crdx' 2 | import { actionFingerprint } from './actionFingerprint.js' 3 | import { membershipResolver as resolver } from 'team/membershipResolver.js' 4 | import { type TeamAction, type TeamContext, type TeamGraph } from 'team/types.js' 5 | 6 | export const graphSummary = (graph: TeamGraph) => { 7 | const links = getSequence(graph, resolver) 8 | .filter(l => !l.isInvalid) 9 | .map(l => actionFingerprint(l)) 10 | .join(',') 11 | return links 12 | } 13 | -------------------------------------------------------------------------------- /packages/auth/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actionFingerprint.js' 2 | export * from './arraysAreEqual.js' 3 | export * from './arrayToMap.js' 4 | export * from './graphSummary.js' 5 | export * from './clone.js' 6 | export * from './constants.js' 7 | export * from './composeTransforms.js' 8 | export * from './getScope.js' 9 | export * from './lockboxSummary.js' 10 | export * from './scopesMatch.js' 11 | export * from './types.js' 12 | -------------------------------------------------------------------------------- /packages/auth/src/util/keysetSummary.ts: -------------------------------------------------------------------------------- 1 | import { type Keyset, type KeysetWithSecrets } from '@localfirst/crdx' 2 | import { truncateHashes } from '@localfirst/shared' 3 | 4 | export const keysetSummary = (keyset: Keyset | KeysetWithSecrets) => { 5 | const { name, generation, encryption } = keyset 6 | const publicKey = typeof encryption === 'string' ? encryption : encryption.publicKey 7 | return `${name}(${truncateHashes(publicKey)})#${generation}` 8 | } 9 | -------------------------------------------------------------------------------- /packages/auth/src/util/lockboxSummary.ts: -------------------------------------------------------------------------------- 1 | import { type Lockbox } from 'lockbox/index.js' 2 | 3 | export const lockboxSummary = (l: Lockbox) => 4 | `${l.recipient.name}(${trunc(l.recipient.publicKey)}):${l.contents.name}#${l.contents.generation}` 5 | 6 | const trunc = (s: string) => s.slice(0, 5) 7 | -------------------------------------------------------------------------------- /packages/auth/src/util/scopesMatch.ts: -------------------------------------------------------------------------------- 1 | import { type KeyScope } from '@localfirst/crdx' 2 | import { assert } from '@localfirst/shared' 3 | import { getScope } from 'util/getScope.js' 4 | 5 | export const scopesMatch = (a: KeyScope, b: KeyScope) => { 6 | return a.type === b.type && a.name === b.name 7 | } 8 | 9 | export const assertScopesMatch = (a: KeyScope, b: KeyScope) => { 10 | assert( 11 | scopesMatch(a, b), 12 | `The scope of the new keys must match those of the old lockbox. 13 | New scope: ${JSON.stringify(getScope(a))} 14 | Old scope: ${JSON.stringify(getScope(b))}` 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/TestChannel.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '@herbcaudill/eventemitter42' 2 | 3 | export class TestChannel extends EventEmitter { 4 | private peers = 0 5 | private readonly buffer: Array<{ senderId: string; msg: Uint8Array }> = [] 6 | 7 | addPeer() { 8 | this.peers += 1 9 | if (this.peers > 1) { 10 | // Someone was already connected, emit any buffered messages 11 | while (this.buffer.length > 0) { 12 | const { senderId, msg } = this.buffer.pop() as { 13 | senderId: string 14 | msg: Uint8Array 15 | } 16 | this.emit('data', senderId, msg) 17 | } 18 | } 19 | } 20 | 21 | write(senderId: string, message: Uint8Array) { 22 | if (this.peers > 1) { 23 | // At least one peer besides us connected 24 | this.emit('data', senderId, message) 25 | } else { 26 | // Nobody else connected, buffer messages until someone connects 27 | this.buffer.unshift({ senderId, msg: message }) 28 | } 29 | } 30 | } 31 | 32 | type TestChannelEvents = { 33 | data: (senderId: string, message: Uint8Array) => void 34 | } 35 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/constants.ts: -------------------------------------------------------------------------------- 1 | export const laptopInfo = { 2 | ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 3 | browser: { name: 'Chrome', version: '124.0.0.0', major: '124' }, 4 | engine: { name: 'Blink', version: '124.0.0.0' }, 5 | os: { name: 'Mac OS', version: '10.15.7' }, 6 | device: { vendor: 'Apple', model: 'Macintosh' }, 7 | } 8 | 9 | export const phoneInfo = { 10 | ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1', 11 | browser: { name: 'Mobile Safari', version: '17.4.1', major: '17' }, 12 | engine: { name: 'WebKit', version: '605.1.15' }, 13 | os: { name: 'iOS', version: '17.4.1' }, 14 | device: { vendor: 'Apple', model: 'iPhone', type: 'mobile' }, 15 | } 16 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/expect/toBeValid.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest' 2 | import { type ValidationResult } from 'util/types.js' 3 | 4 | // ignore coverage 5 | expect.extend({ 6 | toBeValid(validation: ValidationResult) { 7 | if (validation.isValid) { 8 | return { 9 | message: () => 'expected validation not to pass', 10 | pass: true, 11 | } 12 | } 13 | 14 | return { 15 | message: () => validation.error.message, 16 | pass: false, 17 | } 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/expect/toLookLikeKeyset.ts: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | 3 | import { expect } from 'vitest' 4 | 5 | expect.extend({ 6 | toLookLikeKeyset(maybeKeyset: any) { 7 | const looksLikeKeyset = 8 | maybeKeyset.hasOwnProperty('encryption') && maybeKeyset.hasOwnProperty('signature') 9 | if (looksLikeKeyset) { 10 | return { 11 | message: () => 'expected not to look like a keyset', 12 | pass: true, 13 | } 14 | } 15 | 16 | return { 17 | message: () => 'expected to look like a keyset', 18 | pass: false, 19 | } 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/expect/vitest.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-definitions */ 2 | /* eslint-disable unused-imports/no-unused-imports */ 3 | 4 | // https://vitest.dev/guide/extending-matchers.html 5 | 6 | import type { Assertion, AsymmetricMatchersContaining } from 'vitest' 7 | 8 | interface CustomMatchers { 9 | toBeValid(): R 10 | toLookLikeKeyset(): R 11 | } 12 | 13 | declare module 'vitest' { 14 | interface Assertion extends CustomMatchers {} 15 | interface AsymmetricMatchersContaining extends CustomMatchers {} 16 | } 17 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'util/testing/connectionHelpers.js' 2 | export * from 'util/testing/joinTestChannel.js' 3 | export * from 'util/testing/setup.js' 4 | export * from 'util/testing/TestChannel.js' 5 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/joinTestChannel.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'connection/Connection.js' 2 | import { isServerContext, type Context } from 'connection/types.js' 3 | import { type TestChannel } from './TestChannel.js' 4 | import { pause } from '@localfirst/shared' 5 | 6 | /** Returns a function that can be used to join a specific test channel */ 7 | export const joinTestChannel = (channel: TestChannel) => (context: Context) => { 8 | const id = isServerContext(context) ? context.server.host : context.device.deviceId 9 | // Hook up send 10 | const sendMessage = (message: Uint8Array) => { 11 | channel.write(id, message) 12 | } 13 | 14 | // Instantiate the connection service 15 | const connection = new Connection({ sendMessage, context }) 16 | 17 | // Hook up receive 18 | channel.addListener('data', async (senderId, message) => { 19 | if (senderId === id) return // ignore messages that I sent 20 | await pause(1) 21 | connection.deliver(message) 22 | }) 23 | 24 | channel.addPeer() 25 | 26 | return connection 27 | } 28 | -------------------------------------------------------------------------------- /packages/auth/src/util/testing/messageSummary.ts: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | import { type SyncMessage } from '@localfirst/crdx' 3 | import { truncateHashes } from '@localfirst/shared' 4 | 5 | export const syncMessageSummary = (m: SyncMessage | undefined) => { 6 | if (m === undefined) { 7 | return 'DONE' 8 | } 9 | 10 | const { head, links, need } = m 11 | const body = { head } as any 12 | if (links) { 13 | body.links = Object.keys(links).join(', ') 14 | } 15 | 16 | if (need) { 17 | body.need = need.join(', ') 18 | } 19 | 20 | return truncateHashes(JSON.stringify(body)) 21 | } 22 | -------------------------------------------------------------------------------- /packages/auth/src/util/types.ts: -------------------------------------------------------------------------------- 1 | export type Optional = Omit & Partial 2 | 3 | // KEY TYPES 4 | 5 | export const KeyType = { 6 | GRAPH: 'GRAPH', 7 | TEAM: 'TEAM', 8 | ROLE: 'ROLE', 9 | USER: 'USER', 10 | DEVICE: 'DEVICE', 11 | SERVER: 'SERVER', 12 | EPHEMERAL: 'EPHEMERAL', 13 | } as const 14 | export type KeyType = (typeof KeyType)[keyof typeof KeyType] 15 | 16 | // VALIDATION 17 | 18 | export type InvalidResult = { 19 | isValid: false 20 | error: ValidationError 21 | } 22 | 23 | export type ValidResult = { 24 | isValid: true 25 | } 26 | 27 | export class ValidationError extends Error { 28 | constructor(message: string, details?: any) { 29 | super() 30 | this.message = message 31 | this.details = details 32 | } 33 | 34 | public name: 'Signature chain validation error' 35 | public details?: any 36 | } 37 | 38 | export type ValidationResult = ValidResult | InvalidResult 39 | -------------------------------------------------------------------------------- /packages/auth/src/util/unique.ts: -------------------------------------------------------------------------------- 1 | import { uniqBy } from 'lodash-es' 2 | 3 | export const unique = uniqBy as (array: T[], iteratee?: (item: T) => string) => T[] 4 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | }, 8 | "include": [ 9 | "src", 10 | "@types", 11 | ] 12 | } -------------------------------------------------------------------------------- /packages/auth/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: 'esm', 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | }) 10 | -------------------------------------------------------------------------------- /packages/auth/xo.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('../../xo.config.cjs') 2 | -------------------------------------------------------------------------------- /packages/crdx/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | -------------------------------------------------------------------------------- /packages/crdx/img/crdx-illustration-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/crdx/img/crdx-illustration-01.png -------------------------------------------------------------------------------- /packages/crdx/img/crdx-logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/crdx/img/crdx-logo.ai -------------------------------------------------------------------------------- /packages/crdx/img/crdx-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/local-first-web/auth/f61e3678d74f9a30946475941ef9ef0c8c45d664/packages/crdx/img/crdx-logo.png -------------------------------------------------------------------------------- /packages/crdx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@localfirst/crdx", 3 | "author": "herb@devresults.com", 4 | "version": "6.0.0", 5 | "license": "MIT", 6 | "repository": "https://github.com/local-first-web/auth/packages/crdx", 7 | "private": false, 8 | "type": "module", 9 | "exports": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "engines": { 12 | "node": ">=18" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsup", 19 | "postbuild": "tsc -p tsconfig.build.json --emitDeclarationOnly", 20 | "preinstall": "npx only-allow pnpm", 21 | "test": "vitest" 22 | }, 23 | "dependencies": { 24 | "@herbcaudill/eventemitter42": "^0.3.1", 25 | "@localfirst/crypto": "workspace:*", 26 | "@localfirst/shared": "workspace:*", 27 | "@paralleldrive/cuid2": "^2.2.2", 28 | "@types/lodash-es": "^4.17.12", 29 | "lodash-es": "^4.17.21", 30 | "msgpackr": "^1.10.0" 31 | }, 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "gitHead": "9a7b871e9e34268b32cc6e574189ec2350787b81" 36 | } 37 | -------------------------------------------------------------------------------- /packages/crdx/src/constants.ts: -------------------------------------------------------------------------------- 1 | import { type KeyScope } from './keyset/index.js' 2 | import { type ValidationResult } from './validator/types.js' 3 | 4 | // avoiding enums 5 | export const SIGNATURE = 'SIGNATURE' 6 | export const ENCRYPTION = 'ENCRYPTION' 7 | export const SYMMETRIC = 'SYMMETRIC' 8 | export const LINK_HASH = 'LINK_HASH' 9 | 10 | export const HashPurpose = { 11 | SIGNATURE, 12 | ENCRYPTION, 13 | SYMMETRIC, 14 | LINK_HASH, 15 | } as const 16 | 17 | export const ROOT = 'ROOT' 18 | export const MERGE = 'MERGE' 19 | export const VALID = { isValid: true } as ValidationResult 20 | 21 | export const EPHEMERAL_SCOPE: KeyScope = { 22 | type: 'EPHEMERAL', 23 | name: 'EPHEMERAL', 24 | } 25 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getEncryptedLinks.ts: -------------------------------------------------------------------------------- 1 | import type { Hash } from 'util/index.js' 2 | import type { Graph } from './types.js' 3 | 4 | export const getEncryptedLinks = (graph: Graph, hashes: Hash[]) => 5 | Object.fromEntries(hashes.map(hash => [hash, getEncryptedLink(graph, hash)])) 6 | 7 | export const getEncryptedLink = (graph: Graph, hash: Hash) => graph.encryptedLinks[hash] 8 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getHashes.ts: -------------------------------------------------------------------------------- 1 | import type { Graph } from './types.js' 2 | import type { Hash } from 'util/index.js' 3 | 4 | export const getHashes = (graph: Graph) => Object.keys(graph.links) as Hash[] 5 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getHead.ts: -------------------------------------------------------------------------------- 1 | import { getLink } from './getLink.js' 2 | import type { Action, Graph } from './types.js' 3 | 4 | export const getHead = (graph: Graph) => 5 | graph.head.map(hash => getLink(graph, hash)) 6 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getLink.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Link, Graph } from './types.js' 2 | import type { Hash } from 'util/index.js' 3 | 4 | export const getLink = (graph: Graph, hash: Hash): Link => 5 | graph.links[hash] 6 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getParents.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Link, Graph } from './types.js' 2 | import type { Hash } from 'util/index.js' 3 | import { getLink } from './getLink.js' 4 | 5 | // prettier-ignore 6 | export function getParents(graph: Graph, link: Link): Array> 7 | export function getParents(graph: Graph, hash: Hash): Hash[] 8 | /** 9 | * Get the parents of a link. You can pass the link itself or its hash. 10 | */ 11 | export function getParents(graph: Graph, linkOrHash: Link | Hash) { 12 | if (typeof linkOrHash === 'string') { 13 | const hash: Hash = linkOrHash 14 | const link = getLink(graph, hash) 15 | return link.body.prev 16 | } else { 17 | const link: Link = linkOrHash 18 | return link.body.prev.map(hash => getLink(graph, hash)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getPredecessors.ts: -------------------------------------------------------------------------------- 1 | import { assert, memoize } from '@localfirst/shared' 2 | import { uniq } from 'lodash-es' 3 | import { type Hash } from 'util/index.js' 4 | import { getLink } from './getLink.js' 5 | import type { Action, Graph, Link } from './types.js' 6 | 7 | export const memoizeResolver = (graph: Graph, hash: Hash) => 8 | `${graph.head.join('')}:${hash}` 9 | 10 | /** Returns the set of predecessors of `link` (not including `link`) */ 11 | export const getPredecessors = ( 12 | graph: Graph, 13 | link: Link 14 | ): Array> => 15 | getPredecessorHashes(graph, link.hash) 16 | .map(h => graph.links[h]) 17 | .filter(link => link !== undefined) 18 | 19 | export const getPredecessorHashes = memoize((graph: Graph, hash: Hash): Hash[] => { 20 | const link = getLink(graph, hash) 21 | assert(link) 22 | const parents = link.body.prev as Hash[] 23 | const predecessors = parents.flatMap(parent => getPredecessorHashes(graph, parent)) 24 | return uniq(parents.concat(predecessors)) 25 | }, memoizeResolver) 26 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getRoot.ts: -------------------------------------------------------------------------------- 1 | import { type Action, type Link, type Graph, type RootAction } from './types.js' 2 | 3 | export const getRoot = (graph: Graph) => 4 | graph.links[graph.root] as Link 5 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/getSuccessors.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from '@localfirst/shared' 2 | import { uniq } from 'lodash-es' 3 | import { type Hash } from 'util/index.js' 4 | import { getChildrenHashes } from './children.js' 5 | import type { Action, Graph, Link } from './types.js' 6 | 7 | const memoizeResolver = (graph: Graph, hash: Hash) => `${graph.head.join('')}:${hash}` 8 | 9 | export const getSuccessorHashes = memoize((graph: Graph, hash: Hash): Hash[] => { 10 | const children = getChildrenHashes(graph, hash) 11 | const successors = children.flatMap(parent => getSuccessorHashes(graph, parent)) 12 | return uniq(children.concat(successors)) 13 | }, memoizeResolver) 14 | 15 | /** Returns the set of successors of `link` (not including `link`) */ 16 | export const getSuccessors = ( 17 | graph: Graph, 18 | link: Link 19 | ): Array> => 20 | getSuccessorHashes(graph, link.hash) 21 | .map(h => graph.links[h]) 22 | .filter(link => link !== undefined) 23 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/hashLink.ts: -------------------------------------------------------------------------------- 1 | import { hash, type Hash } from '@localfirst/crypto' 2 | import { HashPurpose } from 'constants.js' 3 | 4 | const { LINK_HASH } = HashPurpose 5 | 6 | export const hashEncryptedLink = (body: Uint8Array) => { 7 | return hash(LINK_HASH, body) as Hash 8 | } 9 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/headsAreEqual.ts: -------------------------------------------------------------------------------- 1 | import { type Hash } from 'util/index.js' 2 | 3 | export const headsAreEqual = (a: Hash[] | undefined, b: Hash[] | undefined) => { 4 | if (a === undefined || b === undefined) return false 5 | if (a.length !== b.length) return false 6 | 7 | a.sort() 8 | b.sort() 9 | return a.every((hash, i) => hash === b[i]) 10 | } 11 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/index.ts: -------------------------------------------------------------------------------- 1 | export * from './append.js' 2 | export * from './children.js' 3 | export * from './concurrency.js' 4 | export * from './createGraph.js' 5 | export * from './decrypt.js' 6 | export * from './getEncryptedLinks.js' 7 | export * from './getHashes.js' 8 | export * from './getHead.js' 9 | export * from './getLink.js' 10 | export * from './getParentMap.js' 11 | export * from './getParents.js' 12 | export * from './getPredecessors.js' 13 | export * from './getRoot.js' 14 | export * from './getSequence.js' 15 | export * from './getSuccessors.js' 16 | export * from './headsAreEqual.js' 17 | export * from './isPredecessor.js' 18 | export * from './isSuccessor.js' 19 | export * from './merge.js' 20 | export * from './redactGraph.js' 21 | export * from './serialize.js' 22 | export * from './topoSort.js' 23 | export * from './types.js' 24 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/isPredecessor.ts: -------------------------------------------------------------------------------- 1 | import { type Graph, type Link } from './types.js' 2 | import { type Hash } from 'util/index.js' 3 | import { getPredecessorHashes } from './getPredecessors.js' 4 | 5 | /** Returns true if `a` is a predecessor of `b` */ 6 | export const isPredecessor = (graph: Graph, a: Link, b: Link) => { 7 | return ( 8 | a !== undefined && 9 | b !== undefined && 10 | a.hash in graph.links && 11 | b.hash in graph.links && 12 | getPredecessorHashes(graph, b.hash).includes(a.hash) 13 | ) 14 | } 15 | 16 | /** Returns true if `a` is a predecessor of `b` */ 17 | export const isPredecessorHash = (graph: Graph, a: Hash, b: Hash) => 18 | getPredecessorHashes(graph, b).includes(a) 19 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/isSuccessor.ts: -------------------------------------------------------------------------------- 1 | import { type Action, type Link, type Graph } from './types.js' 2 | import { type Hash } from 'util/index.js' 3 | import { getSuccessorHashes } from './getSuccessors.js' 4 | 5 | /** Returns true if `a` is a successor of `b` */ 6 | export const isSuccessor = ( 7 | graph: Graph, 8 | a: Link, 9 | b: Link 10 | ): boolean => { 11 | return ( 12 | a !== undefined && 13 | b !== undefined && 14 | a.hash in graph.links && 15 | b.hash in graph.links && 16 | getSuccessorHashes(graph, b.hash).includes(a.hash) 17 | ) 18 | } 19 | 20 | export const isSuccessorHash = (graph: Graph, a: Hash, b: Hash) => 21 | getSuccessorHashes(graph, b).includes(a) 22 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/redactGraph.ts: -------------------------------------------------------------------------------- 1 | import { getChildMap } from './getParentMap.js' 2 | import type { Action, EncryptedGraph, Graph } from './types.js' 3 | 4 | export const redactGraph = (graph: Graph): EncryptedGraph => { 5 | const { head, root, encryptedLinks } = graph 6 | const childMap = getChildMap(graph) 7 | return { 8 | head, 9 | root, 10 | encryptedLinks, 11 | childMap, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/serialize.ts: -------------------------------------------------------------------------------- 1 | import { pack, unpack } from 'msgpackr' 2 | import { decryptGraph } from './decrypt.js' 3 | import { redactGraph } from './redactGraph.js' 4 | import { type MaybePartlyDecryptedGraph, type Action, type Graph } from './types.js' 5 | import { type Keyring, type KeysetWithSecrets } from 'keyset/index.js' 6 | 7 | export const serialize = (graph: Graph) => { 8 | return pack(redactGraph(graph)) 9 | } 10 | 11 | export const deserialize = ( 12 | serialized: Uint8Array, 13 | keys: KeysetWithSecrets | Keyring 14 | ): Graph => { 15 | const graph = unpack(serialized) as MaybePartlyDecryptedGraph 16 | return decryptGraph({ encryptedGraph: graph, keys }) 17 | } 18 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/test/append.test.ts: -------------------------------------------------------------------------------- 1 | import { TEST_GRAPH_KEYS as keys, setup } from 'util/testing/setup.js' 2 | import { describe, expect, test } from 'vitest' 3 | import { append, createGraph, getHead, getRoot } from 'graph/index.js' 4 | import { validate } from 'validator/index.js' 5 | import 'util/testing/expect/toBeValid' 6 | 7 | const { alice } = setup('alice') 8 | const defaultUser = alice 9 | 10 | const _ = expect.objectContaining 11 | 12 | describe('graphs', () => { 13 | test('append', () => { 14 | const graph1 = createGraph({ user: defaultUser, name: 'a', keys }) 15 | const graph2 = append({ 16 | graph: graph1, 17 | action: { type: 'FOO', payload: 'b' }, 18 | user: defaultUser, 19 | keys, 20 | }) 21 | 22 | expect(validate(graph2)).toBeValid() 23 | 24 | expect(getRoot(graph2)).toEqual(_({ body: _({ payload: _({ name: 'a' }) }) })) 25 | expect(getHead(graph2)).toEqual([_({ body: _({ payload: 'b' }) })]) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/test/create.test.ts: -------------------------------------------------------------------------------- 1 | import { TEST_GRAPH_KEYS as keys, setup } from 'util/testing/setup.js' 2 | import { describe, expect, test } from 'vitest' 3 | import { createGraph, deserialize, getHead, getRoot, serialize } from 'graph/index.js' 4 | import { validate } from 'validator/index.js' 5 | import 'util/testing/expect/toBeValid.js' 6 | 7 | const { alice } = setup('alice') 8 | const defaultUser = alice 9 | 10 | const _ = expect.objectContaining 11 | 12 | describe('graphs', () => { 13 | test('create', () => { 14 | const graph = createGraph({ user: defaultUser, name: 'a', keys }) 15 | const expected = _({ body: _({ payload: _({ name: 'a' }) }) }) 16 | expect(getRoot(graph)).toEqual(expected) 17 | expect(getHead(graph)[0]).toEqual(expected) 18 | }) 19 | 20 | test('serialize/deserialize', () => { 21 | // 👨🏻‍🦲 Bob saves a graph to a file and loads it later 22 | const graph = createGraph({ user: defaultUser, name: 'Spies Я Us', keys }) 23 | 24 | // serialize 25 | const graphJson = serialize(graph) 26 | 27 | // deserialize 28 | const rehydratedGraph = deserialize(graphJson, keys) 29 | 30 | expect(validate(rehydratedGraph)).toBeValid() 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /packages/crdx/src/graph/test/getChildren.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { buildGraph, findByPayload } from 'util/testing/graph.js' 3 | import { getChildren, getRoot } from 'graph/index.js' 4 | 5 | describe('getChildren', () => { 6 | const graph = buildGraph(` 7 | ┌─ e ─ g ─┐ 8 | ┌─ c ─ d ─┤ ├─ o ─┐ 9 | a ─ b ─┤ └─── f ───┤ ├─ n 10 | ├──── h ──── i ─────┘ │ 11 | └───── j ─── k ── l ──────┘ 12 | `) 13 | test('root has 1 child', () => expect(getChildren(graph, getRoot(graph))).toHaveLength(1)) 14 | test('b has 3 children', () => 15 | expect(getChildren(graph, findByPayload(graph, 'b'))).toHaveLength(3)) 16 | test('d has 2 children', () => 17 | expect(getChildren(graph, findByPayload(graph, 'd'))).toHaveLength(2)) 18 | test('e has 1 child', () => expect(getChildren(graph, findByPayload(graph, 'e'))).toHaveLength(1)) 19 | test('n has 0 children', () => 20 | expect(getChildren(graph, findByPayload(graph, 'n'))).toHaveLength(0)) 21 | }) 22 | -------------------------------------------------------------------------------- /packages/crdx/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './graph/index.js' 2 | export * from './constants.js' 3 | export * from './keyset/index.js' 4 | export * from './store/index.js' 5 | export * from './sync/index.js' 6 | export * from './user/index.js' 7 | export * from './validator/index.js' 8 | export * from './util/types.js' 9 | -------------------------------------------------------------------------------- /packages/crdx/src/keyset/createKeyring.ts: -------------------------------------------------------------------------------- 1 | import { arrayToMap } from 'util/index.js' 2 | import { isKeyring, isKeyset, type Keyring, type KeysetWithSecrets } from './types.js' 3 | 4 | export const createKeyring = (keys: Keyring | KeysetWithSecrets | KeysetWithSecrets[]): Keyring => { 5 | // if it's already a keyring, just return it 6 | if (isKeyring(keys)) return keys 7 | 8 | // coerce a single keyset into an array of keysets 9 | if (isKeyset(keys)) keys = [keys] 10 | 11 | // organize into a map of keysets by public key 12 | return keys.reduce( 13 | arrayToMap((k: KeysetWithSecrets) => k.encryption.publicKey), 14 | {} 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/crdx/src/keyset/createKeyset.ts: -------------------------------------------------------------------------------- 1 | import { hash, asymmetric, signatures, stretch, randomKey } from '@localfirst/crypto' 2 | import { type KeyScope, type KeysetWithSecrets } from './types.js' 3 | import { HashPurpose } from 'constants.js' 4 | import { type Optional } from 'util/index.js' 5 | 6 | const { SIGNATURE, ENCRYPTION, SYMMETRIC } = HashPurpose 7 | 8 | /** Generates a full set of per-user keys from a single 32-byte secret, roughly following the 9 | * procedure outlined in the [Keybase docs on Per-User Keys](http://keybase.io/docs/teams/puk). 10 | * */ 11 | export const createKeyset = ( 12 | /** The scope associated with the new keys - e.g. `{ type: TEAM }` or `{type: ROLE, name: ADMIN}`. */ 13 | scope: Optional, 14 | 15 | /** A strong secret key used to derive the other keys. This key should be randomly generated to 16 | * begin with and never stored. If not provided, a 32-byte random key will be generated and used. */ 17 | seed: string = randomKey() 18 | ): KeysetWithSecrets => { 19 | const { type, name = type } = scope 20 | const stretchedSeed = stretch(`${name}:${type}:${seed}`) 21 | return { 22 | type, 23 | name, 24 | generation: 0, 25 | signature: signatures.keyPair(hash(SIGNATURE, stretchedSeed).slice(0, 32)), 26 | encryption: asymmetric.keyPair(hash(ENCRYPTION, stretchedSeed).slice(0, 32)), 27 | secretKey: hash(SYMMETRIC, stretchedSeed), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/crdx/src/keyset/getLatestGeneration.ts: -------------------------------------------------------------------------------- 1 | import type { Keyring, KeysetWithSecrets } from './types.js' 2 | 3 | export const getLatestGeneration = (keyring: Keyring) => { 4 | let latest: KeysetWithSecrets | undefined 5 | 6 | for (const publicKey in keyring) { 7 | const keyset = keyring[publicKey] 8 | if (latest === undefined || keyset.generation > latest.generation) { 9 | latest = keyset 10 | } 11 | } 12 | 13 | return latest 14 | } 15 | -------------------------------------------------------------------------------- /packages/crdx/src/keyset/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createKeyring.js' 2 | export * from './createKeyset.js' 3 | export * from './getLatestGeneration.js' 4 | export * from './redactKeys.js' 5 | export * from './types.js' 6 | -------------------------------------------------------------------------------- /packages/crdx/src/keyset/redactKeys.ts: -------------------------------------------------------------------------------- 1 | import { hasSecrets, type Keyset, type KeysetWithSecrets } from 'keyset/types.js' 2 | 3 | /** 4 | * There are two kinds of keysets: 5 | * 6 | * - **`KeysetWithSecrets`** includes the secret keys (for example, our user or device keys, or keys 7 | * for roles we belong to) 8 | * - **`Keyset`** only includes the public keys (for example, other users' keys, or keys for 9 | * roles we don't belong to) 10 | * 11 | * The `redact` function takes a `KeysetWithSecrets`, and returns a `Keyset`. 12 | * 13 | * ```js 14 | * const adminPublicKeys = keyset.redactKeys(adminKeys) 15 | * 16 | * { 17 | * // the metadata is unchanged 18 | * type: 'ROLE', 19 | * name: 'admin', 20 | * generation: 0, 21 | * // instead of keypairs, these are just the public keys 22 | * signature: '...', // = adminKeys.signature.publicKey 23 | * encryption: '...', // = adminKeys.encryption.publicKey 24 | * } 25 | * ``` 26 | * 27 | * You can also pass in a `Keyset`, in which case it will be returned as-is. 28 | */ 29 | export const redactKeys = (keys: KeysetWithSecrets | Keyset): Keyset => 30 | hasSecrets(keys) 31 | ? { 32 | type: keys.type, 33 | name: keys.name, 34 | generation: keys.generation, 35 | encryption: keys.encryption.publicKey, 36 | signature: keys.signature.publicKey, 37 | } 38 | : keys 39 | -------------------------------------------------------------------------------- /packages/crdx/src/keyset/test/getLatestGeneration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { getLatestGeneration } from '../getLatestGeneration.js' 3 | import { createKeyring, createKeyset } from 'index.js' 4 | 5 | const TEAM_SCOPE = { type: 'TEAM', name: 'TEAM' } 6 | 7 | describe('getLatestGeneration', () => { 8 | it('single generation', () => { 9 | const keys = createKeyset(TEAM_SCOPE) 10 | const keyring = createKeyring(keys) 11 | const latest = getLatestGeneration(keyring) 12 | expect(latest).toEqual(keys) 13 | }) 14 | 15 | it('multiple generations', () => { 16 | const keys0 = createKeyset(TEAM_SCOPE) 17 | const keys1 = { ...createKeyset(TEAM_SCOPE), generation: 1 } 18 | const keys2 = { ...createKeyset(TEAM_SCOPE), generation: 2 } 19 | const keyring = createKeyring([keys0, keys1, keys2]) 20 | const latest = getLatestGeneration(keyring) 21 | expect(latest).toEqual(keys2) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/crdx/src/store/compose.ts: -------------------------------------------------------------------------------- 1 | import { type Reducer } from './types.js' 2 | import { type Action } from 'graph/index.js' 3 | 4 | export const compose = 5 | (reducers: Array>): Reducer => 6 | (state, action) => 7 | reducers.reduce((state, reducer) => reducer(state, action), state) 8 | -------------------------------------------------------------------------------- /packages/crdx/src/store/createStore.ts: -------------------------------------------------------------------------------- 1 | import { Store } from './Store.js' 2 | import { type StoreOptions } from './StoreOptions.js' 3 | import { type Action } from 'graph/index.js' 4 | 5 | export const createStore = (options: StoreOptions) => { 6 | return new Store(options) 7 | } 8 | -------------------------------------------------------------------------------- /packages/crdx/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createStore.js' 2 | export * from './makeMachine.js' 3 | export * from './Store.js' 4 | export * from './types.js' 5 | -------------------------------------------------------------------------------- /packages/crdx/src/store/makeMachine.ts: -------------------------------------------------------------------------------- 1 | import { type Reducer } from './types.js' 2 | import { type Action, getSequence, type Graph, type Resolver } from 'graph/index.js' 3 | import { validate, type ValidatorSet } from 'validator/index.js' 4 | 5 | export const makeMachine = ({ 6 | initialState, 7 | reducer, 8 | resolver, 9 | validators, 10 | }: MachineParams) => { 11 | return (graph: Graph) => { 12 | // Validate the graph's integrity. 13 | validate(graph, validators) 14 | 15 | // Use the filter & sequencer to turn the graph into an ordered sequence 16 | const sequence = getSequence(graph, resolver) 17 | 18 | // Run the sequence through the reducer to calculate the current team state 19 | return sequence.reduce(reducer, initialState) 20 | } 21 | } 22 | 23 | type MachineParams = { 24 | initialState: S 25 | reducer: Reducer 26 | resolver: Resolver 27 | validators?: ValidatorSet 28 | } 29 | -------------------------------------------------------------------------------- /packages/crdx/src/store/test/shared/counterReducer.ts: -------------------------------------------------------------------------------- 1 | import { type RootAction } from 'graph/index.js' 2 | import { type Reducer } from 'store/types.js' 3 | 4 | // Counter 5 | 6 | export const counterReducer: Reducer = (state, link) => { 7 | const action = link.body 8 | switch (action.type) { 9 | case 'ROOT': { 10 | return { value: 0 } 11 | } 12 | 13 | case 'INCREMENT': { 14 | const step = action.payload ?? 1 15 | return { 16 | ...state, 17 | value: state.value + step, 18 | } 19 | } 20 | 21 | default: { 22 | // ignore coverage 23 | return state 24 | } 25 | } 26 | } 27 | // state 28 | 29 | export type CounterState = { 30 | value: number 31 | } 32 | // action types 33 | 34 | export type CounterAction = IncrementAction 35 | 36 | export type IncrementAction = 37 | | RootAction 38 | | { 39 | type: 'INCREMENT' 40 | payload: number 41 | } 42 | -------------------------------------------------------------------------------- /packages/crdx/src/store/types.ts: -------------------------------------------------------------------------------- 1 | import { type Action, type Link } from 'graph/index.js' 2 | 3 | export type Reducer> = ( 4 | state: S, 5 | link: Link 6 | ) => S 7 | -------------------------------------------------------------------------------- /packages/crdx/src/sync/getMissingLinks.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Graph } from 'graph/types.js' 2 | 3 | export function getMissingLinks(graph: Graph) { 4 | // collect all the `prev` hashes from all of the links in the graph 5 | const parentHashes = Object.values(graph.links) // 6 | .flatMap(link => link.body.prev) as string[] 7 | 8 | // together with the head and the root, these are all the hashes we know about 9 | const allKnownHashes = [...parentHashes, graph.root, ...graph.head] 10 | 11 | // filter out the ones we already have, so we can ask for the ones we're missing 12 | return allKnownHashes.filter(hash => !(hash in graph.links)) 13 | } 14 | -------------------------------------------------------------------------------- /packages/crdx/src/sync/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generateMessage.js' 2 | export * from './initSyncState.js' 3 | export * from './receiveMessage.js' 4 | export * from './types.js' 5 | -------------------------------------------------------------------------------- /packages/crdx/src/sync/initSyncState.ts: -------------------------------------------------------------------------------- 1 | import { type SyncState } from './types.js' 2 | 3 | export const initSyncState = (): SyncState => ({ 4 | their: { 5 | head: [], 6 | encryptedLinks: {}, 7 | need: [], 8 | parentMap: {}, 9 | }, 10 | 11 | our: { 12 | head: [], 13 | links: [], 14 | }, 15 | 16 | lastCommonHead: [], 17 | failedSyncCount: 0, 18 | }) 19 | -------------------------------------------------------------------------------- /packages/crdx/src/user/README.md: -------------------------------------------------------------------------------- 1 | ## 👩🏾‍🦱 User 2 | 3 | The local user and their private & public keys, including their device and device keys. 4 | 5 | #### `user.create(userName, device) 6 | 7 | When you first create a user, you'll need to obtain a username and details about the user's device. 8 | This information is securely saved on the device. 9 | 10 | The user name provided can be an existing username, an email address, or an ID. It needs to uniquely 11 | identify the user within this team. 12 | 13 | The name of the device needs to be unique among this user's devices. 14 | 15 | ```js 16 | import { user } from 'taco' 17 | 18 | const alicesLaptop = { 19 | userName: 'alice', 20 | deviceName: `Alice's MacBook`, 21 | } 22 | 23 | const currentUser = user.create('alice', alicesLaptop) 24 | ``` 25 | 26 | #### `user.load()` 27 | 28 | For subsequent sessions, you can load the user information directly. 29 | 30 | ```js 31 | const currentUser = user.load() 32 | ``` 33 | -------------------------------------------------------------------------------- /packages/crdx/src/user/createUser.ts: -------------------------------------------------------------------------------- 1 | import { randomKey } from '@localfirst/crypto' 2 | import { createId } from '@paralleldrive/cuid2' 3 | import { createKeyset, KeyType } from 'keyset/index.js' 4 | import { type UserWithSecrets } from 'user/types.js' 5 | 6 | /** 7 | * Creates a new local user, with randomly-generated keys. 8 | */ 9 | export const createUser = ( 10 | userName: string, 11 | userId: string = createId(), 12 | seed: string = randomKey() 13 | ): UserWithSecrets => { 14 | return { 15 | userName, 16 | userId, 17 | keys: createKeyset({ type: KeyType.USER, name: userId }, seed), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/crdx/src/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createUser.js' 2 | export * from './redact.js' 3 | export * from './types.js' 4 | -------------------------------------------------------------------------------- /packages/crdx/src/user/redact.ts: -------------------------------------------------------------------------------- 1 | import { type UserWithSecrets } from './types.js' 2 | import { redactKeys } from 'keyset/index.js' 3 | import { type User } from 'user/index.js' 4 | 5 | export const redactUser = (user: User | UserWithSecrets): User => { 6 | const { userId, userName } = user 7 | return { 8 | userId, 9 | userName, 10 | keys: redactKeys(user.keys), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/crdx/src/user/types.ts: -------------------------------------------------------------------------------- 1 | import { type Keyset, type KeysetWithSecrets } from 'keyset/index.js' 2 | 3 | export type User = { 4 | /** Unique ID populated on creation. */ 5 | userId: string 6 | 7 | /** Username (or email). Must be unique but is not used for lookups. Only provided to connect 8 | * human identities with other systems. */ 9 | userName: string 10 | 11 | /** The user's public keys. */ 12 | keys: Keyset 13 | } 14 | 15 | /** The local user and their full set of keys, including secrets. */ 16 | export type UserWithSecrets = { 17 | userId: string 18 | userName: string 19 | 20 | /** The user's secret keys. */ 21 | keys: KeysetWithSecrets 22 | } 23 | -------------------------------------------------------------------------------- /packages/crdx/src/util/arrayToMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reducer for converting an array of objects to a map containing the same objects, where the key is 3 | * taken from a field in each object (e.g. `id`, `hash`, etc.). 4 | * 5 | * ```ts 6 | * const arr = [ 7 | * {id: 1, ...}, 8 | * {id: 2, ...}, 9 | * {id: 3, ...} 10 | * ] 11 | * const mapped = arr.reduce(arrayToMap('id'), {}) 12 | * // mapped = { 13 | * // 1: {id: 1, ...}, 14 | * // 2: {id: 2, ...}, 15 | * // 3: {id: 3, ...}, 16 | * // } 17 | * ``` 18 | * @param keyAccessor 19 | */ 20 | export const arrayToMap = >( 21 | keyAccessor: string | KeyAccessor 22 | ) => { 23 | return (result: Record, current: T) => { 24 | const key = typeof keyAccessor === 'function' ? keyAccessor(current) : current[keyAccessor] 25 | return { 26 | ...result, 27 | [key as string]: current, 28 | } as Record 29 | } 30 | } 31 | 32 | type KeyAccessor = (obj: T) => string 33 | -------------------------------------------------------------------------------- /packages/crdx/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'util/arrayToMap.js' 2 | export * from 'util/types.js' 3 | -------------------------------------------------------------------------------- /packages/crdx/src/util/messageSummary.ts: -------------------------------------------------------------------------------- 1 | import { type NetworkMessage } from 'util/testing/Network.js' 2 | import { truncateHashes } from '@localfirst/shared' 3 | import { type SyncMessage } from 'sync/index.js' 4 | 5 | export const logMessages = (msgs: NetworkMessage[]) => { 6 | const result = msgs.map(m => JSON.stringify(networkMessageSummary(m))).join('\n') 7 | console.log(result) 8 | } 9 | 10 | export const networkMessageSummary = (m: NetworkMessage): any => { 11 | return { 12 | from: m.from, 13 | to: m.to, 14 | ...syncMessageSummary(m.body), 15 | } 16 | } 17 | 18 | export const syncMessageSummary = (m: SyncMessage): any => { 19 | if (m === undefined) { 20 | return 'DONE' 21 | } 22 | 23 | const { head, parentMap, links, need, error } = m 24 | const body: any = { head: head.join(',') } 25 | if (parentMap) body.linkMap = Object.keys(parentMap).join(',') 26 | if (links) body.links = Object.keys(links).join(',') 27 | if (need) body.need = need.join(',') 28 | if (error) body.error = error.message 29 | return truncateHashes(body) 30 | } 31 | -------------------------------------------------------------------------------- /packages/crdx/src/util/testing/arrayToMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Reducer for converting an array of objects to a map containing the same objects, where the key is 3 | * taken from a field in each object (e.g. `id`, `hash`, etc.). 4 | * 5 | * ```ts 6 | * const arr = [ 7 | * {id: 1, ...}, 8 | * {id: 2, ...}, 9 | * {id: 3, ...} 10 | * ] 11 | * const mapped = arr.reduce(arrayToMap('id'), {}) 12 | * // mapped = { 13 | * // 1: {id: 1, ...}, 14 | * // 2: {id: 2, ...}, 15 | * // 3: {id: 3, ...}, 16 | * // } 17 | * ``` 18 | * @param keyField 19 | */ 20 | export const arrayToMap = 21 | (keyField: string) => 22 | >( 23 | result: Record, 24 | current: T 25 | ): Record => ({ 26 | ...result, 27 | [current[keyField]]: current, 28 | }) 29 | -------------------------------------------------------------------------------- /packages/crdx/src/util/testing/expect/toBeValid.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest' 2 | import { type ValidationResult } from 'validator/index.js' 3 | 4 | // ignore coverage 5 | expect.extend({ 6 | toBeValid(validation: ValidationResult, expectedMessage?: string) { 7 | if (validation.isValid) 8 | return { 9 | message: () => 'expected validation not to pass', 10 | pass: true, 11 | } 12 | 13 | if (expectedMessage && !new RegExp(expectedMessage).test(validation.error.message)) 14 | return { 15 | message: () => 16 | `expected validation to fail with message ${expectedMessage}, but got ${validation.error.message}`, 17 | pass: true, 18 | } 19 | return { 20 | message: () => validation.error.message, 21 | pass: false, 22 | } 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /packages/crdx/src/util/testing/expect/toLookLikeKeyset.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest' 2 | import { type Keyset } from 'keyset/index.js' 3 | // ignore file coverage 4 | expect.extend({ 5 | toLookLikeKeyset(maybeKeyset: Keyset | Record) { 6 | const looksLikeKeyset = 7 | maybeKeyset.hasOwnProperty('encryption') && maybeKeyset.hasOwnProperty('signature') 8 | if (looksLikeKeyset) 9 | return { 10 | message: () => 'expected not to look like a keyset', 11 | pass: true, 12 | } 13 | return { 14 | message: () => 'expected to look like a keyset', 15 | pass: false, 16 | } 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /packages/crdx/src/util/testing/expect/vitest.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-definitions */ 2 | /* eslint-disable unused-imports/no-unused-imports */ 3 | 4 | // https://vitest.dev/guide/extending-matchers.html 5 | 6 | import type { Assertion, AsymmetricMatchersContaining } from 'vitest' 7 | 8 | interface CustomMatchers { 9 | toBeValid(): R 10 | toLookLikeKeyset(): R 11 | } 12 | 13 | declare module 'vitest' { 14 | interface Assertion extends CustomMatchers {} 15 | interface AsymmetricMatchersContaining extends CustomMatchers {} 16 | } 17 | -------------------------------------------------------------------------------- /packages/crdx/src/util/types.ts: -------------------------------------------------------------------------------- 1 | export type { Base58, Utf8, Hash, Payload } from '@localfirst/crypto' 2 | 3 | export type UnixTimestamp = number & { _unixTimestamp: false } 4 | 5 | export type Optional = Omit & Partial 6 | -------------------------------------------------------------------------------- /packages/crdx/src/validator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types.js' 2 | export * from './validate.js' 3 | export * from './validators.js' 4 | -------------------------------------------------------------------------------- /packages/crdx/src/validator/types.ts: -------------------------------------------------------------------------------- 1 | import { type Action, type Link, type Graph } from 'graph/index.js' 2 | 3 | export type InvalidResult = { 4 | isValid: false 5 | error: ValidationError 6 | } 7 | 8 | export type ValidResult = { 9 | isValid: true 10 | } 11 | 12 | export class ValidationError extends Error { 13 | public type: 'Hash graph validation error' 14 | public details?: unknown 15 | 16 | constructor(message: string, details?: any) { 17 | super() 18 | this.message = message 19 | this.details = details 20 | } 21 | } 22 | 23 | export type ValidationResult = ValidResult | InvalidResult 24 | 25 | export type Validator = ( 26 | link: Link, 27 | graph: Graph 28 | ) => ValidationResult 29 | 30 | export type ValidatorSet = Record 31 | -------------------------------------------------------------------------------- /packages/crdx/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/crdx/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | }, 8 | "include": [ 9 | "src", 10 | "@types" 11 | ] 12 | } -------------------------------------------------------------------------------- /packages/crdx/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: 'esm', 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | }) 10 | -------------------------------------------------------------------------------- /packages/crypto/.npmrc: -------------------------------------------------------------------------------- 1 | access = public 2 | -------------------------------------------------------------------------------- /packages/crypto/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 HerbCaudill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /packages/crypto/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@localfirst/crypto", 3 | "version": "6.0.0", 4 | "license": "MIT", 5 | "private": false, 6 | "author": { 7 | "name": "Herb Caudill", 8 | "email": "herb@devresults.com" 9 | }, 10 | "description": "Wrapper functions for libsodium", 11 | "type": "module", 12 | "exports": "./dist/index.js", 13 | "types": "./dist/index.d.ts", 14 | "engines": { 15 | "node": ">=18" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build": "tsup", 22 | "postbuild": "tsc -p tsconfig.build.json --emitDeclarationOnly", 23 | "preinstall": "npx only-allow pnpm", 24 | "test": "vitest" 25 | }, 26 | "dependencies": { 27 | "@localfirst/shared": "workspace:*", 28 | "@types/lodash-es": "^4.17.12", 29 | "bs58": "^5.0.0", 30 | "libsodium-wrappers-sumo": "^0.7.13", 31 | "lodash-es": "^4.17.21", 32 | "msgpackr": "^1.10.0" 33 | }, 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "gitHead": "9a7b871e9e34268b32cc6e574189ec2350787b81" 38 | } 39 | -------------------------------------------------------------------------------- /packages/crypto/src/hash.ts: -------------------------------------------------------------------------------- 1 | import sodium from 'libsodium-wrappers-sumo' 2 | import { type Payload } from './types.js' 3 | import { base58, keyToBytes } from './util/index.js' 4 | import { pack } from 'msgpackr' 5 | 6 | /** Computes a fixed-length fingerprint for an arbitrary long message. */ 7 | 8 | export const hash = ( 9 | /** A seed used to distinguish different hashes derived from a given payload. */ 10 | seed: string, 11 | /** The data to hash. */ 12 | payload: Payload 13 | ) => { 14 | return base58.encode(hashBytes(seed, payload)) 15 | } 16 | 17 | export const hashBytes = ( 18 | /** A seed used to distinguish different hashes derived from a given payload. */ 19 | seed: string, 20 | /** The data to hash. */ 21 | payload: Payload 22 | ) => { 23 | const seedBytes = keyToBytes(seed, 'utf8') 24 | const payloadBytes = pack(payload) 25 | return sodium.crypto_generichash(32, payloadBytes, seedBytes) 26 | } 27 | -------------------------------------------------------------------------------- /packages/crypto/src/index.ts: -------------------------------------------------------------------------------- 1 | import sodium from 'libsodium-wrappers-sumo' 2 | 3 | await sodium.ready 4 | 5 | export * from './asymmetric.js' 6 | export * from './hash.js' 7 | export * from './randomKey.js' 8 | export * from './signatures.js' 9 | export * from './symmetric.js' 10 | export * from './types.js' 11 | export * from './stretch.js' 12 | export * from './util/index.js' 13 | -------------------------------------------------------------------------------- /packages/crypto/src/randomKey.ts: -------------------------------------------------------------------------------- 1 | import sodium from 'libsodium-wrappers-sumo' 2 | import { base58 } from './util/index.js' 3 | import { type Base58 } from 'types.js' 4 | 5 | /** Returns an unpredictable key with the given length (32 bytes by default), as a byte array. */ 6 | export const randomKeyBytes = (length = 32) => sodium.randombytes_buf(length) 7 | 8 | /** Returns an unpredictable key with the given length (16 characters by default), as a base58-encoded string. */ 9 | export const randomKey = (length = 16) => 10 | // we make a longer key than we need, so that we have enough base58 characters to truncate to the desired length 11 | base58.encode(randomKeyBytes(length * 3)).slice(0, length) as Base58 12 | -------------------------------------------------------------------------------- /packages/crypto/src/stretch.ts: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | 3 | import sodium from 'libsodium-wrappers-sumo' 4 | import { memoize } from '@localfirst/shared' 5 | import type { Password, Base58 } from './types.js' 6 | import { base58, keyToBytes } from './util/index.js' 7 | 8 | /** 9 | * Derives a key from a low-entropy input, such as a password. Current version of libsodium 10 | * uses the Argon2id algorithm, although that may change in later versions. 11 | */ 12 | export const stretch = memoize((password: Password) => { 13 | const passwordBytes = typeof password === 'string' ? keyToBytes(password, 'utf8') : password 14 | const salt = base58.decode('H5B4DLSXw5xwNYFdz1Wr6e' as Base58) 15 | if (passwordBytes.length >= 16) { 16 | // It's long enough -- just hash to expand it to 32 bytes 17 | return sodium.crypto_generichash(32, passwordBytes, salt) 18 | } 19 | 20 | const opsLimit = sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE 21 | const memLimit = sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE 22 | return sodium.crypto_pwhash( 23 | 32, 24 | passwordBytes, 25 | salt, 26 | opsLimit, 27 | memLimit, 28 | sodium.crypto_pwhash_ALG_DEFAULT 29 | ) 30 | }) 31 | -------------------------------------------------------------------------------- /packages/crypto/src/test/randomKey.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { randomKey, randomKeyBytes } from '../index.js' 3 | 4 | describe('randomKey', () => { 5 | it('should return keys of the expected length', () => { 6 | expect(randomKey()).toHaveLength(16) 7 | expect(randomKey(8)).toHaveLength(8) 8 | expect(randomKeyBytes()).toHaveLength(32) 9 | expect(randomKeyBytes(16)).toHaveLength(16) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/crypto/src/test/stretch.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { stretch } from '../index.js' 3 | import { base58 } from '../util/index.js' 4 | 5 | describe('stretch', () => { 6 | test('returns a 32-byte key', () => { 7 | const password = 'hello123' 8 | const key = stretch(password) 9 | 10 | expect(key).toHaveLength(32) 11 | 12 | // Results are deterministic 13 | expect(base58.encode(key)).toMatchInlineSnapshot( 14 | '"8hYkvmB2xxdjzi7ZL7DNKXUFEDHsAHWs66fMXYkpdDWr"' 15 | ) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /packages/crypto/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Utf8 = string & { _utf8: false } 2 | export type Base58 = string & { _base58: false } 3 | export type Hash = Base58 & { _hash: false } 4 | 5 | export type Payload = any // msgpacker can serialize anything 6 | 7 | export type ByteKeypair = { 8 | publicKey: Uint8Array 9 | secretKey: Uint8Array 10 | } 11 | 12 | export type Base58Keypair = { 13 | publicKey: Base58 14 | secretKey: Base58 15 | } 16 | 17 | export type SignedMessage = { 18 | /** The plaintext message to be verified */ 19 | payload: Payload 20 | /** The signature for the message, encoded as a base58 string */ 21 | signature: Base58 22 | /** The signer's public key, encoded as a base58 string */ 23 | publicKey: Base58 24 | } 25 | 26 | export type Cipher = { 27 | nonce: Uint8Array 28 | message: Uint8Array 29 | } 30 | 31 | export type Encoder = (b: Uint8Array) => string 32 | export type Password = string | Uint8Array 33 | -------------------------------------------------------------------------------- /packages/crypto/src/util/base58.ts: -------------------------------------------------------------------------------- 1 | import bs58 from 'bs58' 2 | import { type Base58 } from '../types.js' 3 | 4 | const { encode, decode } = bs58 5 | 6 | export const base58 = { 7 | encode: (b: Uint8Array) => encode(b) as Base58, 8 | decode, 9 | detect: (s: string): s is Base58 => 10 | /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/.test(s), 11 | } 12 | -------------------------------------------------------------------------------- /packages/crypto/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base58.js' 2 | export * from './keypairToBase58.js' 3 | export * from './keyToBytes.js' 4 | -------------------------------------------------------------------------------- /packages/crypto/src/util/keyToBytes.ts: -------------------------------------------------------------------------------- 1 | import { base58 } from './base58.js' 2 | 3 | export const keyToBytes = (x: string, encoding: Encoding = 'base58'): Uint8Array => { 4 | if (encoding === 'utf8') { 5 | return new TextEncoder().encode(x) 6 | } 7 | 8 | if (encoding === 'base58') { 9 | return base58.decode(x) 10 | } 11 | 12 | // ignore coverage 13 | throw new Error(`Unknown encoding: ${encoding as string}`) 14 | } 15 | 16 | type Encoding = 'base58' | 'utf8' 17 | -------------------------------------------------------------------------------- /packages/crypto/src/util/keypairToBase58.ts: -------------------------------------------------------------------------------- 1 | import { base58 } from './base58.js' 2 | 3 | export const keypairToBase58 = (keypair: KeyPair) => ({ 4 | publicKey: base58.encode(keypair.publicKey), 5 | secretKey: base58.encode(keypair.privateKey), 6 | }) 7 | 8 | export type KeyPair = { 9 | privateKey: Uint8Array 10 | publicKey: Uint8Array 11 | } 12 | -------------------------------------------------------------------------------- /packages/crypto/src/util/util.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { type Base58 } from '../types.js' 3 | import { base58 } from './base58.js' 4 | import { keyToBytes } from './keyToBytes.js' 5 | 6 | describe('base58', () => { 7 | describe('detect', () => { 8 | it('recognizes a base58 string', () => { 9 | expect(base58.detect('5VbnBWz6kBnV2wfJZaPgv81Mj7QtAsPmq3QZgc3z1eKYnnG3KSHtCD67')).toBe(true) 10 | }) 11 | 12 | it('doesnt recognize a non-base58 string', () => { 13 | expect(base58.detect('1 can be confused with I and l')).toBe(false) 14 | }) 15 | }) 16 | }) 17 | 18 | describe('keyToBytes', () => { 19 | it('converts a base58 string to bytes', () => { 20 | const key = 21 | '5VbnBWz6kBnV2wfJZaPgv81Mj7QtAsPmq3QZgc3zZqbYZEzEdZQ9r24BGZpN6mt6djyr7W2v1eKYnnG3KSHtCD67' as Base58 22 | const bytes = keyToBytes(key) 23 | 24 | expect(bytes instanceof Uint8Array).toBe(true) 25 | expect(bytes).toHaveLength(64) 26 | }) 27 | 28 | it('converts a utf8 string to bytes', () => { 29 | const key = 'abcdef' 30 | const bytes = keyToBytes(key, 'utf8') 31 | 32 | expect(bytes instanceof Uint8Array).toBe(true) 33 | expect(bytes).toHaveLength(6) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /packages/crypto/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/crypto/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "dist", 6 | "rootDir": "src" 7 | }, 8 | "include": ["src", "@types"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/crypto/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: 'esm', 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | }) 10 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@localfirst/shared", 3 | "version": "6.0.0", 4 | "license": "MIT", 5 | "private": false, 6 | "author": { 7 | "name": "Herb Caudill", 8 | "email": "herb@devresults.com" 9 | }, 10 | "description": "Shared utilities for the @localfirst/auth monorepo", 11 | "type": "module", 12 | "exports": "./dist/index.js", 13 | "types": "./dist/index.d.ts", 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "build": "tsup", 19 | "postbuild": "tsc -p tsconfig.build.json --emitDeclarationOnly", 20 | "preinstall": "npx only-allow pnpm" 21 | }, 22 | "dependencies": { 23 | "@herbcaudill/eventemitter42": "^0.3.1", 24 | "cd": "^0.3.3", 25 | "debug": "^4.3.4", 26 | "eventemitter3": "^5.0.1", 27 | "lodash-es": "^4.17.21" 28 | }, 29 | "gitHead": "9a7b871e9e34268b32cc6e574189ec2350787b81" 30 | } 31 | -------------------------------------------------------------------------------- /packages/shared/src/assert.ts: -------------------------------------------------------------------------------- 1 | export function assert(value: boolean, message?: string): asserts value 2 | export function assert(value: T | undefined, message?: string): asserts value is T 3 | export function assert(value: any, message = 'Assertion failed') { 4 | if (value === false || value === null || value === undefined) { 5 | const error = new Error(trimLines(message)) 6 | error.stack = removeLine(error.stack, 'assert.js') 7 | throw error 8 | } 9 | } 10 | 11 | const trimLines = (s: string) => 12 | s 13 | .split('\n') 14 | .map(s => s.trim()) 15 | .join('\n') 16 | 17 | const removeLine = (s = '', targetText: string) => 18 | s 19 | .split('\n') 20 | .filter(line => !line.includes(targetText)) 21 | .join('\n') 22 | -------------------------------------------------------------------------------- /packages/shared/src/debug.ts: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | import _debug from 'debug' 3 | import { truncateHashes } from './truncateHashes.js' 4 | 5 | const originalFormatArgs = _debug.formatArgs 6 | 7 | _debug.formatArgs = function (args: any[]) { 8 | for (let i = 0; i < args.length; i++) { 9 | args[i] = truncateHashes(args[i]) 10 | } 11 | originalFormatArgs.call(this, args) 12 | } 13 | 14 | export const debug = _debug('localfirst') 15 | 16 | export type RegexReplacer = (substring: string, ...args: any[]) => string 17 | -------------------------------------------------------------------------------- /packages/shared/src/eventPromise.ts: -------------------------------------------------------------------------------- 1 | import { eventPromise, type EventEmitter, type EventMap } from '@herbcaudill/eventemitter42' 2 | 3 | export { eventPromise } from '@herbcaudill/eventemitter42' 4 | 5 | export const eventPromises = async ( 6 | emitters: Array>, 7 | event: string 8 | ) => { 9 | const promises = emitters.map(async emitter => eventPromise(emitter, event)) 10 | return Promise.all(promises) 11 | } 12 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './assert.js' 2 | export * from './debug.js' 3 | export * from './eventPromise.js' 4 | export * from './memoize.js' 5 | export * from './pause.js' 6 | export * from './truncateHashes.js' 7 | -------------------------------------------------------------------------------- /packages/shared/src/memoize.ts: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | 3 | import { memoize as _memoize } from 'lodash-es' 4 | 5 | const BYPASS = false 6 | 7 | export const nomemoize: Memoize = (f, _resolver) => f 8 | export const memoize = BYPASS ? nomemoize : _memoize 9 | 10 | type AnyFn = (...args: any[]) => any 11 | type Memoize = (f: F, _resolver?: (...args: Parameters) => string) => F 12 | -------------------------------------------------------------------------------- /packages/shared/src/pause.ts: -------------------------------------------------------------------------------- 1 | export const pause = async (t = 0) => 2 | new Promise(resolve => { 3 | setTimeout(() => resolve(), t) 4 | }) 5 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "dist", 6 | "rootDir": "src" 7 | }, 8 | "include": ["src", "@types"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/shared/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: 'esm', 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | }) 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'demos/*' 4 | -------------------------------------------------------------------------------- /scripts/link-local.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process' 2 | import chalk from 'chalk' 3 | 4 | const localPackages = ['auth-provider-automerge-repo', 'auth-syncserver'] 5 | 6 | const remotePackages = [ 7 | '@automerge/automerge-repo', 8 | '@automerge/automerge-repo-network-broadcastchannel', 9 | '@automerge/automerge-repo-network-websocket', 10 | '@automerge/automerge-repo-react-hooks', 11 | '@automerge/automerge-repo-storage-indexeddb', 12 | '@automerge/automerge-repo-storage-nodefs', 13 | ].join(' ') 14 | 15 | const isUnlink = process.argv.includes('--unlink') 16 | 17 | log(chalk.yellow(`${isUnlink ? 'Unlinking from' : 'Linking to'} local automerge-repo packages`)) 18 | 19 | localPackages.forEach(localPackage => { 20 | log(`${chalk.dim('package: ')} ${chalk.yellow(localPackage)}`) 21 | const cmd = `pnpm -C packages/${localPackage} ${isUnlink ? 'unlink' : 'link'} --global ${remotePackages}` 22 | log(chalk.dim(`> ${cmd}`), '') 23 | execSync(cmd, { stdio: 'inherit' }) 24 | }) 25 | 26 | function log(...lines) { 27 | console.log([''].concat(lines).join('\n')) 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "lib": [ 11 | "DOM", 12 | "ESNext" 13 | ], 14 | "module": "NodeNext", 15 | "moduleResolution": "NodeNext", 16 | "noUnusedLocals": false, 17 | "skipLibCheck": true, 18 | "sourceMap": true, 19 | "strict": true, 20 | "strictNullChecks": true, 21 | "strictPropertyInitialization": false, 22 | "target": "ESNext", 23 | "useUnknownInCatchVariables": true 24 | } 25 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from 'vite-tsconfig-paths' 2 | import { configDefaults, defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | build: { 7 | target: 'esnext', 8 | rollupOptions: { 9 | output: { 10 | preserveModules: true, 11 | inlineDynamicImports: false, 12 | }, 13 | }, 14 | }, 15 | test: { 16 | include: ['packages/**/*.test.ts'], 17 | watchExclude: configDefaults.watchExclude.filter(d => !d.includes('dist')), 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /vitest.workspace.js: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from 'vitest/config' 2 | 3 | export default defineWorkspace(['./vitest.config.ts']) 4 | -------------------------------------------------------------------------------- /wallaby.conf.cjs: -------------------------------------------------------------------------------- 1 | module.exports = function (wallaby) { 2 | return { 3 | autoDetect: true, 4 | runMode: 'onsave', 5 | slowTestThreshold: 5000, 6 | lowCoverageThreshold: 99, 7 | hints: { 8 | ignoreCoverageForFile: /ignore file coverage/, 9 | ignoreCoverage: /ignore coverage/, 10 | }, 11 | } 12 | } 13 | --------------------------------------------------------------------------------