+3
-5
eslint.config.js
+3
-5
eslint.config.js
···
6
import react from 'eslint-plugin-react'
7
import reactHooks from 'eslint-plugin-react-hooks'
8
import tsdoc from 'eslint-plugin-tsdoc'
9
import globals from 'globals'
10
import path from 'node:path'
11
import tseslint from 'typescript-eslint'
12
13
const gitignore = path.resolve(import.meta.dirname, '.gitignore')
14
15
-
export default tseslint.config(
16
includeIgnoreFile(gitignore, '.gitignore'),
17
18
// all files by default get shared globals
···
54
'@typescript-eslint/restrict-template-expressions': 'off',
55
56
// I need to be able to do `while(true)`, come on yall...
57
-
'@typescript-eslint/no-unnecessary-condition': [
58
-
'warn',
59
-
{allowConstantLoopConditions: 'always'},
60
-
],
61
62
// this breaks when I want to use a type parameter to allow type checking inline arguments
63
// it's "unnecessary type parameters" or an `as` declaration, which I don't like
···
6
import react from 'eslint-plugin-react'
7
import reactHooks from 'eslint-plugin-react-hooks'
8
import tsdoc from 'eslint-plugin-tsdoc'
9
+
import {defineConfig} from 'eslint/config'
10
import globals from 'globals'
11
import path from 'node:path'
12
import tseslint from 'typescript-eslint'
13
14
const gitignore = path.resolve(import.meta.dirname, '.gitignore')
15
16
+
export default defineConfig(
17
includeIgnoreFile(gitignore, '.gitignore'),
18
19
// all files by default get shared globals
···
55
'@typescript-eslint/restrict-template-expressions': 'off',
56
57
// I need to be able to do `while(true)`, come on yall...
58
+
'@typescript-eslint/no-unnecessary-condition': ['warn', {allowConstantLoopConditions: 'always'}],
59
60
// this breaks when I want to use a type parameter to allow type checking inline arguments
61
// it's "unnecessary type parameters" or an `as` declaration, which I don't like
+1
-1
prettier.config.js
+1
-1
prettier.config.js
+1
-3
src/client/components/feed-import-nytimes.tsx
+1
-3
src/client/components/feed-import-nytimes.tsx
···
113
return (
114
<div className="feed-import-nytimes">
115
<button type="button" onClick={importAll} disabled={importing$.value}>
116
-
{importing$.value
117
-
? `Importing... (${imported$.value}/${NYTIMES_FEEDS.length})`
118
-
: 'Import All NY Times Feeds'}
119
</button>
120
121
{errors$.value.length > 0 && (
···
113
return (
114
<div className="feed-import-nytimes">
115
<button type="button" onClick={importAll} disabled={importing$.value}>
116
+
{importing$.value ? `Importing... (${imported$.value}/${NYTIMES_FEEDS.length})` : 'Import All NY Times Feeds'}
117
</button>
118
119
{errors$.value.length > 0 && (
+1
-3
src/client/components/feed-import-podcasts.tsx
+1
-3
src/client/components/feed-import-podcasts.tsx
···
61
return (
62
<div className="feed-import-podcasts">
63
<button type="button" onClick={importAll} disabled={importing$.value}>
64
-
{importing$.value
65
-
? `Importing... (${imported$.value}/${PODCAST_FEEDS.length})`
66
-
: 'Import Podcast Feeds'}
67
</button>
68
69
{errors$.value.length > 0 && (
+1
-3
src/client/components/feed-import-tech.tsx
+1
-3
src/client/components/feed-import-tech.tsx
+1
-5
src/client/page-app.tsx
+1
-5
src/client/page-app.tsx
+2
-6
src/client/root/context-database.tsx
+2
-6
src/client/root/context-database.tsx
···
13
useDbSignal: <T>(querier: DbQuerier<T>) => ReadonlySignal<T | undefined>
14
}
15
16
-
export const DatabaseProvider: preact.FunctionComponent<{children: preact.ComponentChildren}> = (
17
-
props,
18
-
) => {
19
const db = useMemo(() => new Database(), [])
20
21
function useDbSignal<T>(querier: DbQuerier<T>): ReadonlySignal<T | undefined> {
···
32
return signal
33
}
34
35
-
return (
36
-
<DatabaseContext.Provider value={{db, useDbSignal}}>{props.children}</DatabaseContext.Provider>
37
-
)
38
}
39
40
export function useDatabase() {
···
13
useDbSignal: <T>(querier: DbQuerier<T>) => ReadonlySignal<T | undefined>
14
}
15
16
+
export const DatabaseProvider: preact.FunctionComponent<{children: preact.ComponentChildren}> = (props) => {
17
const db = useMemo(() => new Database(), [])
18
19
function useDbSignal<T>(querier: DbQuerier<T>): ReadonlySignal<T | undefined> {
···
30
return signal
31
}
32
33
+
return <DatabaseContext.Provider value={{db, useDbSignal}}>{props.children}</DatabaseContext.Provider>
34
}
35
36
export function useDatabase() {
+3
-14
src/client/skypod/action-dispatch/context.tsx
+3
-14
src/client/skypod/action-dispatch/context.tsx
···
8
9
import {Action, ActionMap, ActionOpts} from '#skypod/actions'
10
11
-
export type MiddlewareFn = (
12
-
this: undefined,
13
-
action: Action,
14
-
) => void | Action[] | Promise<void | Action[]>
15
16
export type MiddlewareRouterFn<K extends keyof ActionMap> = (
17
this: undefined,
···
89
}
90
},
91
92
-
action: <N extends keyof ActionMap>(
93
-
msg: N,
94
-
dat: ActionMap[N]['dat'],
95
-
opt?: ActionOpts,
96
-
): ActionMap[N] => {
97
const clk = identity.clock.now()
98
return {typ: 'act', clk, msg, dat, opt} as ActionMap[N]
99
},
···
135
},
136
})
137
138
-
return (
139
-
<ActionDispatchContext.Provider value={context.current}>
140
-
{props.children}
141
-
</ActionDispatchContext.Provider>
142
-
)
143
}
144
145
export function useActionDispatch() {
···
8
9
import {Action, ActionMap, ActionOpts} from '#skypod/actions'
10
11
+
export type MiddlewareFn = (this: undefined, action: Action) => void | Action[] | Promise<void | Action[]>
12
13
export type MiddlewareRouterFn<K extends keyof ActionMap> = (
14
this: undefined,
···
86
}
87
},
88
89
+
action: <N extends keyof ActionMap>(msg: N, dat: ActionMap[N]['dat'], opt?: ActionOpts): ActionMap[N] => {
90
const clk = identity.clock.now()
91
return {typ: 'act', clk, msg, dat, opt} as ActionMap[N]
92
},
···
128
},
129
})
130
131
+
return <ActionDispatchContext.Provider value={context.current}>{props.children}</ActionDispatchContext.Provider>
132
}
133
134
export function useActionDispatch() {
+2
-14
src/client/skypod/feed-processor/worker.ts
+2
-14
src/client/skypod/feed-processor/worker.ts
···
47
48
async processPending() {
49
const pendingFeeds = this.#db.feeds.where('lastRefresh.status').equals('pending')
50
-
return await this.#db.withLock(
51
-
'feeds',
52
-
pendingFeeds,
53
-
this.#clock,
54
-
this.#owner,
55
-
this.#processLocked,
56
-
)
57
}
58
59
async processUrls(urls: string[]) {
60
const requestedFeeds = this.#db.feeds.where('url').anyOf(urls)
61
-
return await this.#db.withLock(
62
-
'feeds',
63
-
requestedFeeds,
64
-
this.#clock,
65
-
this.#owner,
66
-
this.#processLocked,
67
-
)
68
}
69
70
#poll = () => {
···
47
48
async processPending() {
49
const pendingFeeds = this.#db.feeds.where('lastRefresh.status').equals('pending')
50
+
return await this.#db.withLock('feeds', pendingFeeds, this.#clock, this.#owner, this.#processLocked)
51
}
52
53
async processUrls(urls: string[]) {
54
const requestedFeeds = this.#db.feeds.where('url').anyOf(urls)
55
+
return await this.#db.withLock('feeds', requestedFeeds, this.#clock, this.#owner, this.#processLocked)
56
}
57
58
#poll = () => {
+1
-4
src/cmd/register-ident.ts
+1
-4
src/cmd/register-ident.ts
+5
-12
src/common/crypto/cipher.ts
+5
-12
src/common/crypto/cipher.ts
···
43
const nonce = encoder.encode(`${nonceStr}-nonce-cryptosystem`)
44
const derivedsalt = new Uint8Array([...salt, ...nonce])
45
46
-
return await crypto.subtle.deriveKey(
47
-
{...deriveAlgo, salt: derivedsalt, iterations},
48
-
derivedkey,
49
-
encrAlgo,
50
-
false,
51
-
['encrypt', 'decrypt'],
52
-
)
53
}
54
55
/**
···
96
async encrypt(data: string | Uint8Array): Promise<string> {
97
const iv = crypto.getRandomValues(new Uint8Array(12))
98
const encoded = asUint8Array(data)
99
-
const encrypted = await crypto.subtle.encrypt(
100
-
{...encrAlgo, iv},
101
-
this.#cryptokey,
102
-
encoded as BufferSource,
103
-
)
104
105
// output = [iv + encrypted] which gives us the auth tag
106
···
43
const nonce = encoder.encode(`${nonceStr}-nonce-cryptosystem`)
44
const derivedsalt = new Uint8Array([...salt, ...nonce])
45
46
+
return await crypto.subtle.deriveKey({...deriveAlgo, salt: derivedsalt, iterations}, derivedkey, encrAlgo, false, [
47
+
'encrypt',
48
+
'decrypt',
49
+
])
50
}
51
52
/**
···
93
async encrypt(data: string | Uint8Array): Promise<string> {
94
const iv = crypto.getRandomValues(new Uint8Array(12))
95
const encoded = asUint8Array(data)
96
+
const encrypted = await crypto.subtle.encrypt({...encrAlgo, iv}, this.#cryptokey, encoded as BufferSource)
97
98
// output = [iv + encrypted] which gives us the auth tag
99
+13
-15
src/common/crypto/jwts.ts
+13
-15
src/common/crypto/jwts.ts
···
20
* schema describing a decoded JWT.
21
* **important** - this does no claims validation, only decoding from string to JWT!
22
*/
23
-
export const jwtSchema: z.ZodType<JWTToken, string> = z
24
-
.jwt({abort: true})
25
-
.transform((token, ctx) => {
26
-
try {
27
-
const claims = jose.decodeJwt(token)
28
-
return {claims, token}
29
-
} catch (e) {
30
-
ctx.issues.push({
31
-
code: 'custom',
32
-
message: `error while decoding token: ${e}`,
33
-
input: token,
34
-
})
35
36
-
return z.NEVER
37
-
}
38
-
})
39
40
/**
41
* schema describing a verified payload in a JWT.
···
20
* schema describing a decoded JWT.
21
* **important** - this does no claims validation, only decoding from string to JWT!
22
*/
23
+
export const jwtSchema: z.ZodType<JWTToken, string> = z.jwt({abort: true}).transform((token, ctx) => {
24
+
try {
25
+
const claims = jose.decodeJwt(token)
26
+
return {claims, token}
27
+
} catch (e) {
28
+
ctx.issues.push({
29
+
code: 'custom',
30
+
message: `error while decoding token: ${e}`,
31
+
input: token,
32
+
})
33
34
+
return z.NEVER
35
+
}
36
+
})
37
38
/**
39
* schema describing a verified payload in a JWT.
+2
-9
src/common/socket.ts
+2
-9
src/common/socket.ts
···
66
}
67
68
/** exactly take socket, but will additionally apply a json decoding */
69
-
export async function takeSocketJson<T>(
70
-
ws: WebSocket,
71
-
schema: z.ZodType<T>,
72
-
signal?: AbortSignal,
73
-
): Promise<T> {
74
const data = await takeSocket(ws, signal)
75
return parseJson.pipe(schema).parseAsync(data)
76
}
···
191
* exactly stream socket, but will additionally apply a json decoding
192
* messages not validating will end the stream with an error
193
*/
194
-
export async function* streamSocketJson(
195
-
ws: WebSocket,
196
-
config?: Partial<ConfigProps>,
197
-
): AsyncGenerator {
198
for await (const message of streamSocket(ws, config)) {
199
yield parseJson.parseAsync(message)
200
}
···
66
}
67
68
/** exactly take socket, but will additionally apply a json decoding */
69
+
export async function takeSocketJson<T>(ws: WebSocket, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
70
const data = await takeSocket(ws, signal)
71
return parseJson.pipe(schema).parseAsync(data)
72
}
···
187
* exactly stream socket, but will additionally apply a json decoding
188
* messages not validating will end the stream with an error
189
*/
190
+
export async function* streamSocketJson(ws: WebSocket, config?: Partial<ConfigProps>): AsyncGenerator {
191
for await (const message of streamSocket(ws, config)) {
192
yield parseJson.parseAsync(message)
193
}
+1
-4
src/realm/client/components/connection-manager.tsx
+1
-4
src/realm/client/components/connection-manager.tsx
+1
-3
src/realm/client/context-identity.tsx
+1
-3
src/realm/client/context-identity.tsx
+1
-4
src/realm/client/service-connection-peer.ts
+1
-4
src/realm/client/service-connection-peer.ts
+1
-3
src/realm/client/service-connection.ts
+1
-3
src/realm/client/service-connection.ts
+1
-5
src/realm/protocol/index.ts
+1
-5
src/realm/protocol/index.ts
+3
-14
src/realm/protocol/messages.ts
+3
-14
src/realm/protocol/messages.ts
···
4
import {IdentBrand} from './brands'
5
import {deviceCapsSchema, deviceInfoSchema} from './device'
6
import {LogicalClock} from './logical-clock'
7
-
import {
8
-
makeEmptyRequestSchema,
9
-
makeEventSchema,
10
-
makeRequestSchema,
11
-
makeResponseSchema,
12
-
} from './schema'
13
14
export const serverPeerIdSchema = z.literal('server')
15
export type ServerPeerId = z.infer<typeof serverPeerIdSchema>
···
18
19
/// preauth
20
21
-
export const preauthRegisterReqSchema = makeRequestSchema(
22
-
'preauth.register',
23
-
z.object({pubkey: jwkSchema}),
24
-
)
25
26
export const preauthAuthnReqSchema = makeEmptyRequestSchema('preauth.authn')
27
···
70
}),
71
)
72
73
-
export const realmRtcPongResponseSchema = makeResponseSchema(
74
-
'realm.rtc.pong',
75
-
z.object({peerClocks: peerClocksSchema}),
76
-
)
77
78
export const realmRtcAnnounceRequestSchema = makeRequestSchema(
79
'realm.rtc.announce',
···
4
import {IdentBrand} from './brands'
5
import {deviceCapsSchema, deviceInfoSchema} from './device'
6
import {LogicalClock} from './logical-clock'
7
+
import {makeEmptyRequestSchema, makeEventSchema, makeRequestSchema, makeResponseSchema} from './schema'
8
9
export const serverPeerIdSchema = z.literal('server')
10
export type ServerPeerId = z.infer<typeof serverPeerIdSchema>
···
13
14
/// preauth
15
16
+
export const preauthRegisterReqSchema = makeRequestSchema('preauth.register', z.object({pubkey: jwkSchema}))
17
18
export const preauthAuthnReqSchema = makeEmptyRequestSchema('preauth.authn')
19
···
62
}),
63
)
64
65
+
export const realmRtcPongResponseSchema = makeResponseSchema('realm.rtc.pong', z.object({peerClocks: peerClocksSchema}))
66
67
export const realmRtcAnnounceRequestSchema = makeRequestSchema(
68
'realm.rtc.announce',
+4
-18
src/realm/server/handler-preauth.ts
+4
-18
src/realm/server/handler-preauth.ts
···
5
import {jwtPayload, jwtSchema, JWTToken, verifyJwtToken} from '#common/crypto/jwts'
6
import {normalizeError, ProtocolError} from '#common/errors'
7
import {takeSocket} from '#common/socket'
8
-
import {
9
-
IdentBrand,
10
-
IdentID,
11
-
preauthReqSchema,
12
-
PreauthResponse,
13
-
RealmBrand,
14
-
RealmID,
15
-
} from '#realm/protocol/index'
16
17
import * as realms from './state'
18
···
21
* - if the realm does not exist (by realm id), we create a new one, and add the identity (success).
22
* - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey.
23
*/
24
-
export async function preauthHandler(
25
-
ws: WebSocket,
26
-
signal?: AbortSignal,
27
-
): Promise<realms.AuthenticatedIdentity> {
28
const timeout = timeoutSignal(3000)
29
const combinedSignal = combineSignals(signal, timeout.signal)
30
···
75
const realm = realmMap.require(realmid)
76
77
if (!invitation.claims.jti) throw new Error('invitation requires nonce!')
78
-
if (!(await realms.validateNonce(realmid, invitation.claims.jti)))
79
-
throw new Error('invitation already used!')
80
81
const inviterid = IdentBrand.parse(invitation.claims.iss)
82
const inviterkey = realm.storage.identities.require(inviterid)
···
107
}
108
}
109
110
-
async function preauthResponse(
111
-
auth: realms.AuthenticatedIdentity,
112
-
seq?: number,
113
-
): Promise<PreauthResponse> {
114
const peers: Array<IdentID | 'server'> = Array.from(auth.realm.sockets.keys())
115
peers.unshift('server')
116
···
5
import {jwtPayload, jwtSchema, JWTToken, verifyJwtToken} from '#common/crypto/jwts'
6
import {normalizeError, ProtocolError} from '#common/errors'
7
import {takeSocket} from '#common/socket'
8
+
import {IdentBrand, IdentID, preauthReqSchema, PreauthResponse, RealmBrand, RealmID} from '#realm/protocol/index'
9
10
import * as realms from './state'
11
···
14
* - if the realm does not exist (by realm id), we create a new one, and add the identity (success).
15
* - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey.
16
*/
17
+
export async function preauthHandler(ws: WebSocket, signal?: AbortSignal): Promise<realms.AuthenticatedIdentity> {
18
const timeout = timeoutSignal(3000)
19
const combinedSignal = combineSignals(signal, timeout.signal)
20
···
65
const realm = realmMap.require(realmid)
66
67
if (!invitation.claims.jti) throw new Error('invitation requires nonce!')
68
+
if (!(await realms.validateNonce(realmid, invitation.claims.jti))) throw new Error('invitation already used!')
69
70
const inviterid = IdentBrand.parse(invitation.claims.iss)
71
const inviterkey = realm.storage.identities.require(inviterid)
···
96
}
97
}
98
99
+
async function preauthResponse(auth: realms.AuthenticatedIdentity, seq?: number): Promise<PreauthResponse> {
100
const peers: Array<IdentID | 'server'> = Array.from(auth.realm.sockets.keys())
101
peers.unshift('server')
102
+4
-16
src/realm/server/handler-realm.ts
+4
-16
src/realm/server/handler-realm.ts
···
24
* ance we've retrieved authentication details, we go into the main realm loop.
25
* read messages as they come in and dispatch actions.
26
*/
27
-
export async function realmHandler(
28
-
ws: WebSocket,
29
-
auth: realm.AuthenticatedIdentity,
30
-
signal?: AbortSignal,
31
-
) {
32
realmBroadcast(auth, await buildRtcPeerJoined(auth))
33
34
try {
···
77
}
78
}
79
80
-
async function buildRtcPeerJoined(
81
-
auth: realm.AuthenticatedIdentity,
82
-
): Promise<protocol.RealmRtcPeerJoinedEvent> {
83
return {
84
typ: 'evt',
85
msg: 'realm.rtc.peer-joined',
···
112
recipients: protocol.IdentID[] | boolean = false,
113
) {
114
const echo = recipients === true || Array.isArray(recipients)
115
-
const recips = Array.isArray(recipients)
116
-
? recipients
117
-
: Array.from(auth.realm.storage.identities.keys())
118
const json = JSON.stringify(payload)
119
120
for (const recip of recips) {
···
129
}
130
}
131
132
-
async function socketPeerPing(
133
-
ws: WebSocket,
134
-
auth: realm.AuthenticatedIdentity,
135
-
ping: protocol.RealmRtcPingRequest,
136
-
) {
137
console.debug('ping from', auth.identid, ping.dat)
138
139
const peerClocks = await auth.realm.storage.buildSyncState()
···
24
* ance we've retrieved authentication details, we go into the main realm loop.
25
* read messages as they come in and dispatch actions.
26
*/
27
+
export async function realmHandler(ws: WebSocket, auth: realm.AuthenticatedIdentity, signal?: AbortSignal) {
28
realmBroadcast(auth, await buildRtcPeerJoined(auth))
29
30
try {
···
73
}
74
}
75
76
+
async function buildRtcPeerJoined(auth: realm.AuthenticatedIdentity): Promise<protocol.RealmRtcPeerJoinedEvent> {
77
return {
78
typ: 'evt',
79
msg: 'realm.rtc.peer-joined',
···
106
recipients: protocol.IdentID[] | boolean = false,
107
) {
108
const echo = recipients === true || Array.isArray(recipients)
109
+
const recips = Array.isArray(recipients) ? recipients : Array.from(auth.realm.storage.identities.keys())
110
const json = JSON.stringify(payload)
111
112
for (const recip of recips) {
···
121
}
122
}
123
124
+
async function socketPeerPing(ws: WebSocket, auth: realm.AuthenticatedIdentity, ping: protocol.RealmRtcPingRequest) {
125
console.debug('ping from', auth.identid, ping.dat)
126
127
const peerClocks = await auth.realm.storage.buildSyncState()
+2
-11
src/realm/server/state-storage.ts
+2
-11
src/realm/server/state-storage.ts
···
88
return new RealmStorage(realmid, identities, db, metapath)
89
}
90
91
-
static async ensure(
92
-
realmid: RealmID,
93
-
registrantid: IdentID,
94
-
registrantkey: CryptoKey,
95
-
): Promise<RealmStorage> {
96
try {
97
return await this.open(realmid)
98
} catch {
···
118
#db: Level
119
#metadataPath: string
120
121
-
private constructor(
122
-
realmid: RealmID,
123
-
identities: StrictMap<IdentID, CryptoKey>,
124
-
db: Level,
125
-
metadataPath: string,
126
-
) {
127
this.realmid = realmid
128
this.identities = identities
129
···
88
return new RealmStorage(realmid, identities, db, metapath)
89
}
90
91
+
static async ensure(realmid: RealmID, registrantid: IdentID, registrantkey: CryptoKey): Promise<RealmStorage> {
92
try {
93
return await this.open(realmid)
94
} catch {
···
114
#db: Level
115
#metadataPath: string
116
117
+
private constructor(realmid: RealmID, identities: StrictMap<IdentID, CryptoKey>, db: Level, metadataPath: string) {
118
this.realmid = realmid
119
this.identities = identities
120
+1
-5
src/skypod/actions.ts
+1
-5
src/skypod/actions.ts
···
18
export type ActionOpts = z.infer<typeof actionOptionsSchema>
19
export type ActionMap = {[K in Action as K['msg']]: K}
20
21
-
export function makeAction<N extends Action['msg']>(
22
-
msg: N,
23
-
clk: LCTimestamp,
24
-
dat: ActionMap[N]['dat'],
25
-
): ActionMap[N] {
26
return {typ: 'act', clk, msg, dat} as ActionMap[N]
27
}
···
18
export type ActionOpts = z.infer<typeof actionOptionsSchema>
19
export type ActionMap = {[K in Action as K['msg']]: K}
20
21
+
export function makeAction<N extends Action['msg']>(msg: N, clk: LCTimestamp, dat: ActionMap[N]['dat']): ActionMap[N] {
22
return {typ: 'act', clk, msg, dat} as ActionMap[N]
23
}