diff --git a/examples/grid/tsconfig.json b/examples/grid/tsconfig.json index abdbecc9..10f42946 100644 --- a/examples/grid/tsconfig.json +++ b/examples/grid/tsconfig.json @@ -5,7 +5,7 @@ "baseUrl": "src", "esModuleInterop": true, "importHelpers": true, - "jsx": "react-jsx", + "jsx": "react", "lib": ["dom", "esnext"], "moduleResolution": "node", "noEmit": true, diff --git a/examples/todo/public/favicon.ico b/examples/todo/public/favicon.ico new file mode 100644 index 00000000..f9037fbe Binary files /dev/null and b/examples/todo/public/favicon.ico differ diff --git a/examples/todo/public/index.html b/examples/todo/public/index.html index f21908d8..4e166f50 100644 --- a/examples/todo/public/index.html +++ b/examples/todo/public/index.html @@ -2,12 +2,33 @@ + + + + + + + @localfirst/state example: todo +
diff --git a/examples/todo/public/logo192.png b/examples/todo/public/logo192.png new file mode 100644 index 00000000..cc579d5e Binary files /dev/null and b/examples/todo/public/logo192.png differ diff --git a/examples/todo/public/logo512.png b/examples/todo/public/logo512.png new file mode 100644 index 00000000..5fb9c26c Binary files /dev/null and b/examples/todo/public/logo512.png differ diff --git a/examples/todo/public/manifest.json b/examples/todo/public/manifest.json new file mode 100644 index 00000000..a62ac979 --- /dev/null +++ b/examples/todo/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "TODO Lists", + "name": "Offline and Synchronized TODO List App", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/todo/src/components/App.tsx b/examples/todo/src/components/App.tsx index 4c60b1bf..5f476c0c 100644 --- a/examples/todo/src/components/App.tsx +++ b/examples/todo/src/components/App.tsx @@ -1,9 +1,12 @@ import { randomDiscoveryKey } from 'lib/randomName' -import React from 'react' +import React, { useState } from 'react' +import Redux from 'redux' import { Provider } from 'react-redux' import { useQueryParam } from 'use-query-params' +import { Toolbar } from '@localfirst/toolbar' import { Todos } from '.' import { useStore } from '../redux/useStore' +import { storeManager } from '../redux/store' export const App: React.FC = () => { const [key, setKey] = useQueryParam('key') @@ -15,9 +18,12 @@ export const App: React.FC = () => { } const appStore = useStore(key, generateNewKey) + const [_, setAppStore] = useState() + const onStoreReady = (store: Redux.Store) => setAppStore(store) return appStore ? ( + ) : null diff --git a/examples/todo/src/index.tsx b/examples/todo/src/index.tsx index 1bd8b9b1..af378ac6 100644 --- a/examples/todo/src/index.tsx +++ b/examples/todo/src/index.tsx @@ -1,9 +1,15 @@ import React from 'react' import ReactDOM from 'react-dom' import { App } from './components' +import * as serviceWorker from './serviceWorker' const start = () => { ReactDOM.render(, document.getElementById('root')) } start() + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +serviceWorker.register() diff --git a/examples/todo/src/serviceWorker.js b/examples/todo/src/serviceWorker.js new file mode 100644 index 00000000..b4856a0f --- /dev/null +++ b/examples/todo/src/serviceWorker.js @@ -0,0 +1,142 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) + ); + + export function register(config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } + } + + function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); + } + + function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' }, + }) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); + } + + export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.unregister(); + }) + .catch(error => { + console.error(error.message); + }); + } + } + \ No newline at end of file diff --git a/examples/toolbar/package.json b/examples/toolbar/package.json index e953235c..c94160fd 100644 --- a/examples/toolbar/package.json +++ b/examples/toolbar/package.json @@ -20,12 +20,12 @@ "dependencies": { "@emotion/react": "11", "@localfirst/state": "^1.0.12", + "@philschatz/auth": "0.0.2", "formik": "^2.0.1-rc.13", "friendly-words": "1", "react": "17", "react-dom": "^16.0.1", "redux": "4", - "taco-js": "0", "tailwindcss": "^1.7.6", "use-persisted-state": "0", "use-query-params": "0" diff --git a/examples/toolbar/src/components/Status.tsx b/examples/toolbar/src/components/Status.tsx index ea682d1c..447cefa6 100644 --- a/examples/toolbar/src/components/Status.tsx +++ b/examples/toolbar/src/components/Status.tsx @@ -1,8 +1,9 @@ /** @jsxImportSource @emotion/react */ import { CLOSE, OPEN, PEER, PEER_REMOVE, StoreManager } from '@localfirst/state' -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { Group } from './Group' import { StatusLight } from './StatusLight' +import { PEER_UPDATE } from '@localfirst/state/dist/src/constants' interface StatusProps { storeManager: StoreManager @@ -12,8 +13,8 @@ export const Status = ({ storeManager }: StatusProps) => { const [online, setOnline] = useState(false) const [peers, setPeers] = useState([]) - const onPeer = (updatedPeers: string[]) => { - setPeers(updatedPeers) + const onPeer = (updatedPeers: string[], updatedAuthenticatedUserInfo: {generation: number, name: string, type: 'ADMIN' | 'MEMBER'}[]) => { + setPeers(updatedAuthenticatedUserInfo.map((v, i) => `${(v && v.type) === 'ADMIN' ? '👑' : ''} ${(v && v.name) ? v.name : `?${updatedPeers[i]}?`}`)) } const onOpen = () => { @@ -31,6 +32,7 @@ export const Status = ({ storeManager }: StatusProps) => { storeManager.on(CLOSE, onClose) storeManager.on(PEER, onPeer) storeManager.on(PEER_REMOVE, onPeer) + storeManager.on(PEER_UPDATE, onPeer) return removeListeners // return cleanup function } @@ -50,12 +52,41 @@ export const Status = ({ storeManager }: StatusProps) => { ? `one peer is connected` : `${peers.length} other peers are connected` const statusMessage = online ? `online; ${peerCountMessage}` : 'offline' + + const peerItems = peers.map(p =>
  • ) + return ( +
      + {peerItems} +
    ) } + + +const avatars = [ + '👶', // Baby + '🧒', // Child + '🧑', // Person + '👱', // Person: Blond Hair + '🧔', // Person: Beard + '👨‍🦰', // Man: Red Hair + '👨‍🦳', // Man: White Hair + '👩', // Woman + '👩‍🦰', // Woman: Red Hair + '🧑‍🦰', // Person: Red Hair + '👩‍🦱', // Woman: Curly Hair + '🧑‍🦳', // Person: White Hair + '👱‍♀️', // Woman: Blond Hair + '🧓', // Older Person +] +const hashCode = (s: string) => s.split('').reduce((a,b)=>{a=((a<<5)-a)+b.charCodeAt(0);return a&a},0) +function getAvatar(username: string) { + if (username.endsWith('?')) { return null } + return avatars[Math.abs(hashCode(username)) % avatars.length] +} \ No newline at end of file diff --git a/examples/toolbar/src/components/Toolbar.tsx b/examples/toolbar/src/components/Toolbar.tsx index 13fc825d..f2f90d49 100644 --- a/examples/toolbar/src/components/Toolbar.tsx +++ b/examples/toolbar/src/components/Toolbar.tsx @@ -19,7 +19,7 @@ export const Toolbar = ({ children, }: React.PropsWithChildren>) => { // Hooks - const [discoveryKey, setDiscoveryKey] = useQueryParam('id', StringParam) + const [discoveryKey, setDiscoveryKey] = useQueryParam('key', StringParam) const [, setAppStore] = useState() const [busy, setBusy] = useState(false) @@ -39,6 +39,7 @@ export const Toolbar = ({ const newDiscoveryKey = randomDiscoveryKey() setDiscoveryKey(newDiscoveryKey) const newStore = await storeManager.createStore(newDiscoveryKey) + await storeManager.listenToConnections(newDiscoveryKey) setAppStore(newStore) onStoreReady(newStore, newDiscoveryKey) setBusy(false) @@ -52,6 +53,7 @@ export const Toolbar = ({ setBusy(true) setDiscoveryKey(newDiscoveryKey) const newStore = await storeManager.joinStore(newDiscoveryKey) + await storeManager.listenToConnections(newDiscoveryKey) setAppStore(newStore) onStoreReady(newStore, newDiscoveryKey) setBusy(false) diff --git a/examples/toolbar/src/lib/localUser.ts b/examples/toolbar/src/lib/localUser.ts index 1923e5b6..447c81a9 100644 --- a/examples/toolbar/src/lib/localUser.ts +++ b/examples/toolbar/src/lib/localUser.ts @@ -1,4 +1,4 @@ -import { loadUser, createUser } from 'taco-js' +import { loadUser, createUser } from '@philschatz/auth' import { randomUserName } from './randomName' const _createUser = () => { diff --git a/examples/toolbar/tsconfig.json b/examples/toolbar/tsconfig.json index f98679cd..1b9e856f 100644 --- a/examples/toolbar/tsconfig.json +++ b/examples/toolbar/tsconfig.json @@ -6,7 +6,7 @@ "composite": true, "declaration": true, "esModuleInterop": true, - "jsx": "react-jsx", + "jsx": "react", "lib": ["dom", "esnext"], "module": "commonjs", "moduleResolution": "node", diff --git a/packages/state/package.json b/packages/state/package.json index 81482285..90f124ed 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -18,8 +18,9 @@ }, "dependencies": { "@localfirst/relay-client": "^1.0.9", - "@localfirst/storage-abstract": "^1.0.2", + "@localfirst/storage-abstract": "^1.0.6", "@localfirst/storage-indexeddb": "^1.0.10", + "@philschatz/auth": "0.0.2", "@stablelib/base64": "1", "@stablelib/utf8": "1", "automerge": "0", @@ -27,6 +28,7 @@ "debug": "4", "fast-memoize": "2", "immutable": "4.0.0-rc.12", + "query-string": "^6.13.8", "redux-devtools-extension": "2", "scryptsy": "2" }, diff --git a/packages/state/src/Connection.test.ts b/packages/state/src/Connection.test.ts index 02dbcc61..f601ccf1 100644 --- a/packages/state/src/Connection.test.ts +++ b/packages/state/src/Connection.test.ts @@ -2,6 +2,7 @@ import A from 'automerge' import { Repo } from './Repo' import { Server } from '@localfirst/relay' import { newid } from '@localfirst/relay-client' +import * as Auth from '@philschatz/auth' import { Connection } from './Connection' import { WebSocket } from 'mock-socket' @@ -33,6 +34,13 @@ describe('Connection', () => { let repo: Repo let server: Server + const user = Auth.createUser({ + userName: 'Alice', + deviceName: 'Laptop', + deviceType: 1 + }) + const team = Auth.createTeam('dream', {user}) + beforeAll(async () => { server = new Server({ port }) await server.listen({ silent: true }) @@ -58,7 +66,7 @@ describe('Connection', () => { it('should send messages to the peer when local state changes', async () => { const peer = new WebSocket(url) - const _ = new Connection(repo, peer, fakeDispatch) + const _ = new Connection(team, repo, peer, fakeDispatch) await _yield() expect(peer.send).toHaveBeenCalled() @@ -71,7 +79,7 @@ describe('Connection', () => { it('should call close on peer when close is called', () => { const peer = new WebSocket(url) - const connection = new Connection(repo, peer, fakeDispatch) + const connection = new Connection(team, repo, peer, fakeDispatch) connection.close() expect(peer.close).toHaveBeenCalled() }) diff --git a/packages/state/src/Connection.ts b/packages/state/src/Connection.ts index 42342647..d578e751 100644 --- a/packages/state/src/Connection.ts +++ b/packages/state/src/Connection.ts @@ -1,7 +1,8 @@ import debug from 'debug' import { EventEmitter } from 'events' import { AnyAction, Dispatch } from 'redux' -import { RECEIVE_MESSAGE_FROM_PEER } from './constants' +import * as Auth from '@philschatz/auth' +import { RECEIVE_MESSAGE_FROM_PEER, PEER_UPDATE } from './constants' import { Repo } from './Repo' import { Synchronizer } from './Synchronizer' import { Message } from './Message' @@ -14,14 +15,16 @@ const log = debug('lf:connection') * networking stack and with the Redux store. */ export class Connection extends EventEmitter { + private team: Auth.Team private Synchronizer: Synchronizer private peerSocket: WebSocket | null private dispatch: Dispatch private repo: Repo - - constructor(repo: Repo, peerSocket: WebSocket, dispatch: Dispatch) { + private authenticatedUser: {generation: number, name: string, type: 'ADMIN' | 'MEMBER'} | undefined + constructor(team: Auth.Team, repo: Repo, peerSocket: WebSocket, dispatch: Dispatch) { super() log('new connection') + this.team = team this.repo = repo this.peerSocket = peerSocket this.dispatch = dispatch @@ -33,19 +36,57 @@ export class Connection extends EventEmitter { } receive = async ({ data }: any) => { - const message = JSON.parse(data.toString()) + let message = JSON.parse(data.toString()) log('receive %o', message) - this.emit('receive', message) - await this.Synchronizer.receive(message) // Synchronizer will update repo directly - // hit the dispatcher to force it to pick up - this.dispatch({ type: RECEIVE_MESSAGE_FROM_PEER }) + if (message.action === 'AUTH:JOIN') { + const proof = message.payload + try { + this.team.admit(proof) + log('admitted user to team') + this.peerSocket?.send(JSON.stringify({action: 'AUTH:ADMITTED', payload: this.team.chain})) + } catch(e) { + console.error('Admission to team failed', e) + return + } + } else { + if (message.action === 'ENCRYPTED') { + try { + message = JSON.parse(this.team.decrypt(message.envelope)) + } catch (e) { + console.error(e) // Decryption problem. Log it and move on + return + } + if (!this.team.verify(message)) { + throw new Error('ERROR! Signed with unknown keys') + } + if (JSON.stringify(this.authenticatedUser) !== JSON.stringify(message.author)) { + this.authenticatedUser = message.author + this.emit(PEER_UPDATE, this.authenticatedUser) + } + message = message.contents + } + this.emit('receive', message) + await this.Synchronizer.receive(message) // Synchronizer will update repo directly + // hit the dispatcher to force it to pick up + this.dispatch({ type: RECEIVE_MESSAGE_FROM_PEER }) + } } - send = (message: Message) => { - log('send %o', JSON.stringify(message)) + send = (message: Message, forcePlaintext = false) => { + const enc = { + action: 'ENCRYPTED', + envelope: this.team.encrypt(this.team.sign(message)) + } + if (this.peerSocket) try { - this.peerSocket.send(JSON.stringify(message)) + if (forcePlaintext) { + log('send plaintext %o', JSON.stringify(message)) + this.peerSocket.send(JSON.stringify(message)) + } else { + log('send encrypted %o', JSON.stringify(message)) + this.peerSocket.send(JSON.stringify(enc)) + } } catch { log('tried to send but peer is no longer connected', this.peerSocket) } @@ -56,4 +97,6 @@ export class Connection extends EventEmitter { this.peerSocket.close() this.peerSocket = null } + + getAuthenticatedUser = () => this.authenticatedUser } diff --git a/packages/state/src/ConnectionManager.ts b/packages/state/src/ConnectionManager.ts index bdf69243..2b7376c4 100644 --- a/packages/state/src/ConnectionManager.ts +++ b/packages/state/src/ConnectionManager.ts @@ -1,10 +1,13 @@ import { Client, newid, Peer } from '@localfirst/relay-client' import debug from 'debug' import { EventEmitter } from 'events' +import A from 'automerge' import * as Redux from 'redux' +import * as Auth from '@philschatz/auth' import { Connection } from './Connection' -import { PEER, OPEN, CLOSE, PEER_REMOVE } from './constants' +import { PEER, OPEN, CLOSE, PEER_REMOVE, PEER_UPDATE } from './constants' import { Repo } from './Repo' +import { performAuthHandshake } from './TeamManager' const log = debug('lf:connectionmanager') @@ -16,11 +19,15 @@ export class ConnectionManager extends EventEmitter { private connections: { [peerId: string]: Connection } = {} private dispatch: Redux.Dispatch private repo: Repo + private invitationOrTeam: Invitation | Auth.Team + private discoveryKey: string - constructor({ repo, dispatch, discoveryKey, urls, clientId = newid() }: ClientOptions) { + constructor({ invitationOrTeam, repo, dispatch, discoveryKey, urls, clientId = newid() }: ClientOptions) { super() + this.invitationOrTeam = invitationOrTeam this.repo = repo this.dispatch = dispatch + this.discoveryKey = discoveryKey // TODO: randomly select a URL if more than one is provided? select best based on ping? this.client = new Client({ id: clientId, url: urls[0] }) @@ -31,19 +38,34 @@ export class ConnectionManager extends EventEmitter { this.client.on(CLOSE, () => this.emit(CLOSE)) } - private addPeer = (peer: Peer, discoveryKey: string) => { + private addPeer = async (peer: Peer, discoveryKey: string) => { if (!this.dispatch || !this.repo) return const socket = peer.get(discoveryKey) - if (socket) this.connections[peer.id] = new Connection(this.repo, socket, this.dispatch) + + // Use the team or perform a handshake to get the team + let team: Auth.Team + if (this.invitationOrTeam instanceof Auth.Team) { + team = await this.invitationOrTeam + } else { + team = await performAuthHandshake(this.repo, this.discoveryKey, this.invitationOrTeam, socket) + this.invitationOrTeam = team + } + + if (socket) { + const c = new Connection(team, this.repo, socket, this.dispatch) + this.connections[peer.id] = c + c.on(PEER_UPDATE, () => this.emit(PEER_UPDATE, Object.keys(this.connections), Object.values(this.connections).map(c => c.getAuthenticatedUser()))) + } peer.on(CLOSE, () => this.removePeer(peer.id)) - this.emit(PEER, Object.keys(this.connections)) + this.emit(PEER, Object.keys(this.connections), Object.values(this.connections).map(c => c.getAuthenticatedUser())) log('added peer', peer.id) + } private removePeer = (peerId: string) => { if (this.connections[peerId]) this.connections[peerId].close() delete this.connections[peerId] - this.emit(PEER_REMOVE, Object.keys(this.connections)) + this.emit(PEER_REMOVE, Object.keys(this.connections), Object.values(this.connections).map(c => c.getAuthenticatedUser())) log('removed peer', peerId) } @@ -60,10 +82,18 @@ export class ConnectionManager extends EventEmitter { } } +export interface Invitation { + username: string, + invitationSeed: string +} + interface ClientOptions { + invitationOrTeam: Invitation | Auth.Team repo: Repo dispatch: Redux.Dispatch discoveryKey: string urls: string[] clientId?: string } + + diff --git a/packages/state/src/StoreManager.ts b/packages/state/src/StoreManager.ts index 2f0446d8..3d1632e1 100644 --- a/packages/state/src/StoreManager.ts +++ b/packages/state/src/StoreManager.ts @@ -5,12 +5,15 @@ import debug from 'debug' import { EventEmitter } from 'events' import { applyMiddleware, createStore, Middleware, Store } from 'redux' import { composeWithDevTools } from 'redux-devtools-extension' -import { ConnectionManager } from './ConnectionManager' -import { CLOSE, DEFAULT_RELAYS, OPEN, PEER, PEER_REMOVE } from './constants' +import * as Auth from '@philschatz/auth' +import querystring from 'query-string' +import { ConnectionManager, Invitation } from './ConnectionManager' +import { CLOSE, DEFAULT_RELAYS, OPEN, PEER, PEER_REMOVE, PEER_UPDATE } from './constants' import { getMiddleware } from './getMiddleware' import { getReducer } from './getReducer' import { Repo } from './Repo' -import { ProxyReducer, RepoSnapshot, Snapshot } from './types' +import { ProxyReducer, RepoSnapshot, Snapshot, ensure } from './types' +import { getTeamManager } from './TeamManager' let log = debug('lf:StoreManager') @@ -81,6 +84,11 @@ export class StoreManager extends EventEmitter { private getStore = async (discoveryKey: string, isCreating: boolean = false) => { this.log(`${isCreating ? 'creating' : 'joining'} ${discoveryKey}`) + // userStore and Toolbar both call joinStore and end up creating different Repo instances. Let's just reuse them. + if (this.store) { + return this.store + } + const clientId = localStorage.getItem('clientId') || newid() localStorage.setItem('clientId', clientId) @@ -95,22 +103,70 @@ export class StoreManager extends EventEmitter { const state = await this.repo.init(this.initialState, isCreating) // Create Redux store to expose to app this.store = this.createReduxStore(state) + return this.store + } + + async listenToConnections(discoveryKey: string) { + + let invitationOrTeam: Invitation | Auth.Team + let maybeTeam: Auth.Team | undefined = await getTeamManager().instantiateTeamIfAvailable(ensure(this.repo), discoveryKey) + + if (maybeTeam) { + invitationOrTeam = maybeTeam + } else { + const {invitationUser, invitationSeed} = querystring.parse(window.location.search, { + parseBooleans: false, + parseNumbers: false, + arrayFormat: "none" + }) + if (invitationUser && invitationSeed) { + invitationOrTeam = {username: invitationUser.toString(), invitationSeed: invitationSeed.toString()} + } else if (confirm('You were not given an invitation to this page. Do you want to create a new Team?')) { + // Create a new team + const user = Auth.createUser({ + userName: 'Alice', + deviceName: 'Laptop', + deviceType: 1 + }) + const t = Auth.createTeam('dream', {user}) + const team = await getTeamManager().instantiateTeamDefinitely(ensure(this.repo), discoveryKey, t.chain) + invitationOrTeam = team + } else { + alert('You have chosen not to create or join a team. There is nothing left to do. Closing.') + throw new Error('Did not choose to join a team or create a new team') + } + } + + // Generate an invitation and alert the user so they can use it: + if (invitationOrTeam instanceof Auth.Team) { + if (invitationOrTeam.memberIsAdmin(invitationOrTeam.userName)) { + const username = `Friend${(new Number(Math.round(Math.random() * 0x10000)).toString())}` + const {invitationSeed} = invitationOrTeam.invite(username) + const qs = querystring.stringify({ + ...querystring.parse(window.location.search), + invitationUser: username, + invitationSeed + }) + window.history.replaceState(null, '', `?${qs}`) + alert(`Invite a person by copying and pasting the URL in the browser to your friend`) + } + } + // Connect to discovery server to find peers and sync up with them this.connectionManager = new ConnectionManager({ + invitationOrTeam, discoveryKey, - dispatch: this.store.dispatch, - repo: this.repo, + dispatch: ensure(this.store).dispatch, + repo: ensure(this.repo), urls: this.urls, }) pipeEvents({ source: this.connectionManager, target: this, - events: [OPEN, CLOSE, PEER, PEER_REMOVE], + events: [OPEN, CLOSE, PEER, PEER_REMOVE, PEER_UPDATE], }) - - return this.store } private createReduxStore(state: RepoSnapshot) { @@ -172,4 +228,4 @@ const pipeEvents = ({ source: EventEmitter target: EventEmitter events: string[] -}) => events.forEach((event) => source.on(event, (payload) => target.emit(event, payload))) +}) => events.forEach((event) => source.on(event, (...payload) => target.emit(event, ...payload))) diff --git a/packages/state/src/Synchronizer.ts b/packages/state/src/Synchronizer.ts index 391a378f..ea029be9 100644 --- a/packages/state/src/Synchronizer.ts +++ b/packages/state/src/Synchronizer.ts @@ -44,7 +44,7 @@ import { Clock, ClockMap, RepoHistory, RepoSnapshot } from './types' */ export class Synchronizer { public repo: Repo - private send: (msg: Message) => void + private send: (msg: Message, forcePlaintext?: boolean) => void private theirClock: ClockMap private isOpen = false private log: debug.Debugger @@ -171,7 +171,7 @@ export class Synchronizer { private async sendHello() { const documentCount = this.repo.count this.log('sending hello', documentCount) - this.send({ type: message.HELLO, documentCount }) + this.send({ type: message.HELLO, documentCount }, true/*forcePlaintext*/) } /** Checks whether we have more recent information than they do; if so, sends changes */ diff --git a/packages/state/src/TeamManager.ts b/packages/state/src/TeamManager.ts new file mode 100644 index 00000000..aa2962ac --- /dev/null +++ b/packages/state/src/TeamManager.ts @@ -0,0 +1,183 @@ +import { EventEmitter } from 'events' +import A from 'automerge' +import * as Auth from '@philschatz/auth' +import { MESSAGE } from './constants' +import { Repo } from './Repo' +import { ensure } from './types' + +const pendingSaves: Promise[] = [] + +const AUTH_KEY = '_AUTH' + +// A Team is associated with a discoveryKey and may be in one of the following states: +// - loaded +// - loading from storage +// - invited-to +export class TeamManager { + + private teams: Map = new Map() + + getInvitation(discoveryKey: string) { + const t = this.teams.get(discoveryKey) + return t && t[1] + } + + async instantiateTeamIfAvailable(repo: Repo, discoveryKey: string, invitation?: Invitation): Promise { + let team = this.teams.get(discoveryKey) + if (team) { + return team[0] + } else if (invitation) { + this.teams.set(discoveryKey, [undefined, invitation]) + } else { + // Try loading the team from Storage + if (repo.has(AUTH_KEY)) { + const state = ensure(await repo.get(AUTH_KEY)) as Auth.TeamSignatureChain + const user = ensure(Auth.loadUser()) + const team = new Auth.Team({ + source: state, + context: {user} + }) + this.addListener(repo, discoveryKey, team) + return team + } + } + } + + async instantiateTeamDefinitely(repo: Repo, discoveryKey: string, state: any): Promise { + let team = this.teams.get(discoveryKey) + if (team && team[0]) { + throw new Error('BUG: Should not already have a team loaded up') + } else { + // Try loading the team from Storage + repo.set(AUTH_KEY, A.from(removeUndefined(state))) + const user = ensure(Auth.loadUser()) + const team = new Auth.Team({ + source: state, + context: {user} + }) + + await this.addTeam(repo, discoveryKey, team) + return team + } + } + + async addTeam(repo: Repo, discoveryKey: string, team: Auth.Team) { + if (this.teams.has(discoveryKey)) { + throw new Error('BUG? seems this already has a Team') + } + this.teams.set(discoveryKey, [team, undefined]) + await repo.set(AUTH_KEY, A.from(removeUndefined(team.chain))) + this.addListener(repo, discoveryKey, team) + } + + private addListener(repo: Repo, discoveryKey: string, team: Auth.Team) { + team.on('updated', async ({head: headId}: {head: string}) => { + const headNode = removeUndefined(team.chain.links[headId]) + // Perform saves in order. Otherwise the storage gets corrupted + if (pendingSaves.length > 0) { + await Promise.all(pendingSaves) + pendingSaves.splice(0, pendingSaves.length) // clear when done saving + } + + pendingSaves.push(repo.change(AUTH_KEY, (doc) => { + if (!doc.root) { + doc.root = headId + } + if (!doc.links) { + doc.links = {} + } + if (!doc.links[headId]) { + doc.head = headId + doc.links[headId] = headNode + // Loop over all the previous nodes and ensure they were added (out of sync updated events) + let curr = headNode + while (true) { + const prevId = (curr.body as any).prev + if (!prevId) { + break + } + const prev = team.chain.links[prevId] + if (!doc.links[prevId]) { + doc.links[prevId] = removeUndefined(prev) + } else { + break // prev node is already in the chain so we can skip it and all the previous ones + } + curr = prev + } + } + })) + }) + } +} + +export interface Invitation { + username: string, + invitationSeed: string +} + +const teamManager = new TeamManager() +export function getTeamManager() { + return teamManager +} + +function removeUndefined(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T +} + +export async function performAuthHandshake(repo: Repo, discoveryKey: string, invite: Invitation, socket: WebSocket): Promise { + function send(action: string, payload: T) { + socket.send(JSON.stringify({action, payload})) + } + console.log('generating proof using invitation:', invite) + const proof = Auth.generateProof(invite.invitationSeed, invite.username) + console.log('Sending AUTH:JOIN to peer') + send('AUTH:JOIN', proof) + + return new Promise((resolve, reject) => { + const listener = async ({data}: MessageEvent) => { + const message = JSON.parse(data) + const {type, action, payload} = message + if (type === 'HELLO') { + // ignore + return + } + switch(action) { + case 'ENCRYPTED': + console.log('Ignoring encrypted message') + break + case 'AUTH:JOIN': + console.log('Well this is awkward. The peer without a Team is being asked to respond to an AUTH:JOIN event. All we can do is ignore it') + break + case 'AUTH:ADMITTED': + // const authHistoryChanges = payload + // const authHistoryDoc = A.applyChanges(A.from({}), authHistoryChanges) + const authHistoryDoc = payload + const user = Auth.createUser({ + userName: invite.username, + deviceName: 'Laptop', + deviceType: 1, // DeviceType.laptop, + seed: invite.invitationSeed + }) + const team = new Auth.Team({ + source: authHistoryDoc, + context: {user: user} + }) + team.encrypt('Just testing for runtime error. Howdy Everyone! If this fails then BUG???? none of the lockboxes have a publicKey that matches the ephemeral one for this invitee. Ensure that the code for the tempkeys in Auth.Team.join are seeded with "invitationSeed" instead of "this.seed"') + + // Update my keys + team.join(proof) + + // We successfully loaded the team. Resolve promises and remove this authentication listener + socket.removeEventListener(MESSAGE, listener) + resolve(team) + await getTeamManager().addTeam(repo, discoveryKey, team) + break + default: + console.error(message) + throw new Error(`BUG: unsupported message action "${action}"`) + } + } + socket.addEventListener(MESSAGE, listener) + + }) + } \ No newline at end of file diff --git a/packages/state/src/constants.ts b/packages/state/src/constants.ts index 9a0892d4..e16f5430 100644 --- a/packages/state/src/constants.ts +++ b/packages/state/src/constants.ts @@ -17,5 +17,6 @@ export const CLOSE = 'close' export const ERROR = 'error' export const PEER = 'peer' export const PEER_REMOVE = 'peer_remove' +export const PEER_UPDATE = 'peer_update' export const MESSAGE = 'message' export const DATA = 'data' diff --git a/packages/state/src/types.ts b/packages/state/src/types.ts index 1d4049f0..e48cd866 100644 --- a/packages/state/src/types.ts +++ b/packages/state/src/types.ts @@ -2,6 +2,7 @@ import A from 'automerge' import { AnyAction } from 'redux' export { ChangeFn } from 'automerge' import { Clock, Snapshot, SnapshotRecord, ChangeSet } from '@localfirst/storage-abstract' +export { Snapshot } from '@localfirst/storage-abstract' // change function operating on a specific item in a collection type CollectionChange = { @@ -69,3 +70,8 @@ export interface RepoSnapshot { } export * from '@localfirst/storage-abstract' + +export function ensure(arg: T | undefined) { + if (!arg) { throw new Error('BUG: Assertion failed and argument was undefined at this point in time')} + return arg +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 8f0d46c0..61a5cdd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2967,6 +2967,18 @@ "@octokit/openapi-types" "^2.0.0" "@types/node" ">= 8" +"@philschatz/auth@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@philschatz/auth/-/auth-0.0.2.tgz#48cada0f86da3fd09c097b7fb6c49a3b77d399c7" + integrity sha512-GsSgbzB0cVDwYnE5OZgo95uJHfM62+dQ6PbjtVNW7KhFE+tF4XqVRXSGRIDlQP0QO+7sXiYBWSSLRAZv9xPGrw== + dependencies: + "@herbcaudill/crypto" "0" + debug "4" + fast-memoize "2" + msgpack-lite "0" + ramda "0" + xstate "4" + "@pmmmwh/react-refresh-webpack-plugin@0.4.2": version "0.4.2" resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.2.tgz#1f9741e0bde9790a0e13272082ed7272a083620d" @@ -15495,6 +15507,15 @@ query-string@^5.0.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +query-string@^6.13.8: + version "6.13.8" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.8.tgz#8cf231759c85484da3cf05a851810d8e825c1159" + integrity sha512-jxJzQI2edQPE/NPUOusNjO/ZOGqr1o2OBa/3M00fU76FsLXDVbJDv/p7ng5OdQyorKrkRz1oqfwmbe5MAMePQg== + dependencies: + decode-uri-component "^0.2.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -17700,18 +17721,6 @@ table@^6.0.4: slice-ansi "^4.0.0" string-width "^4.2.0" -taco-js@0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/taco-js/-/taco-js-0.2.1.tgz#7643fbb45cfabfae826067d222f30cf1881a4473" - integrity sha512-lipvIwKK0HlmCRGBb2JtBrU1E8PJVXPM8m2uroEgT7UbhzKv2MWBK7Z9xtwro/pZ7GI8fHhH6USmOQSbhrciww== - dependencies: - "@herbcaudill/crypto" "0" - debug "4" - fast-memoize "2" - msgpack-lite "0" - ramda "0" - xstate "4" - tailwindcss@1, tailwindcss@^1.7.6: version "1.9.6" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.6.tgz#0c5089911d24e1e98e592a31bfdb3d8f34ecf1a0"