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 (
+
)
}
+
+
+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"