@atproto/space 0.0.0-spaces-alpha-20260818022935

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE.txt +7 -0
  3. package/dist/commit.d.ts +25 -0
  4. package/dist/commit.d.ts.map +1 -0
  5. package/dist/commit.js +80 -0
  6. package/dist/commit.js.map +1 -0
  7. package/dist/credential.d.ts +77 -0
  8. package/dist/credential.d.ts.map +1 -0
  9. package/dist/credential.js +160 -0
  10. package/dist/credential.js.map +1 -0
  11. package/dist/dpop.d.ts +40 -0
  12. package/dist/dpop.d.ts.map +1 -0
  13. package/dist/dpop.js +96 -0
  14. package/dist/dpop.js.map +1 -0
  15. package/dist/error.d.ts +12 -0
  16. package/dist/error.d.ts.map +1 -0
  17. package/dist/error.js +21 -0
  18. package/dist/error.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/lthash.d.ts +18 -0
  24. package/dist/lthash.d.ts.map +1 -0
  25. package/dist/lthash.js +68 -0
  26. package/dist/lthash.js.map +1 -0
  27. package/dist/repo-commit.d.ts +51 -0
  28. package/dist/repo-commit.d.ts.map +1 -0
  29. package/dist/repo-commit.js +137 -0
  30. package/dist/repo-commit.js.map +1 -0
  31. package/dist/space-members.d.ts +27 -0
  32. package/dist/space-members.d.ts.map +1 -0
  33. package/dist/space-members.js +111 -0
  34. package/dist/space-members.js.map +1 -0
  35. package/dist/space-repo.d.ts +33 -0
  36. package/dist/space-repo.d.ts.map +1 -0
  37. package/dist/space-repo.js +136 -0
  38. package/dist/space-repo.js.map +1 -0
  39. package/dist/storage/index.d.ts +3 -0
  40. package/dist/storage/index.d.ts.map +1 -0
  41. package/dist/storage/index.js +19 -0
  42. package/dist/storage/index.js.map +1 -0
  43. package/dist/storage/memory-members-storage.d.ts +12 -0
  44. package/dist/storage/memory-members-storage.d.ts.map +1 -0
  45. package/dist/storage/memory-members-storage.js +36 -0
  46. package/dist/storage/memory-members-storage.js.map +1 -0
  47. package/dist/storage/memory-repo-storage.d.ts +17 -0
  48. package/dist/storage/memory-repo-storage.d.ts.map +1 -0
  49. package/dist/storage/memory-repo-storage.js +56 -0
  50. package/dist/storage/memory-repo-storage.js.map +1 -0
  51. package/dist/storage/types.d.ts +14 -0
  52. package/dist/storage/types.d.ts.map +1 -0
  53. package/dist/storage/types.js +3 -0
  54. package/dist/storage/types.js.map +1 -0
  55. package/dist/sync/car.d.ts +13 -0
  56. package/dist/sync/car.d.ts.map +1 -0
  57. package/dist/sync/car.js +185 -0
  58. package/dist/sync/car.js.map +1 -0
  59. package/dist/sync/consumer.d.ts +39 -0
  60. package/dist/sync/consumer.d.ts.map +1 -0
  61. package/dist/sync/consumer.js +89 -0
  62. package/dist/sync/consumer.js.map +1 -0
  63. package/dist/sync/index.d.ts +3 -0
  64. package/dist/sync/index.d.ts.map +1 -0
  65. package/dist/sync/index.js +3 -0
  66. package/dist/sync/index.js.map +1 -0
  67. package/dist/sync/provider.d.ts +25 -0
  68. package/dist/sync/provider.d.ts.map +1 -0
  69. package/dist/sync/provider.js +60 -0
  70. package/dist/sync/provider.js.map +1 -0
  71. package/dist/sync/repo-index.d.ts +19 -0
  72. package/dist/sync/repo-index.d.ts.map +1 -0
  73. package/dist/sync/repo-index.js +58 -0
  74. package/dist/sync/repo-index.js.map +1 -0
  75. package/dist/types.d.ts +49 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +35 -0
  78. package/dist/types.js.map +1 -0
  79. package/dist/util.d.ts +6 -0
  80. package/dist/util.d.ts.map +1 -0
  81. package/dist/util.js +14 -0
  82. package/dist/util.js.map +1 -0
  83. package/package.json +44 -0
  84. package/src/credential.ts +262 -0
  85. package/src/dpop.ts +166 -0
  86. package/src/error.ts +38 -0
  87. package/src/index.ts +8 -0
  88. package/src/lthash.ts +80 -0
  89. package/src/repo-commit.ts +171 -0
  90. package/src/sync/consumer.ts +149 -0
  91. package/src/sync/index.ts +2 -0
  92. package/src/sync/provider.ts +82 -0
  93. package/src/types.ts +62 -0
  94. package/src/util.ts +21 -0
  95. package/tests/credential.test.ts +323 -0
  96. package/tests/dpop.test.ts +234 -0
  97. package/tests/lthash.test.ts +97 -0
  98. package/tests/repo-commit.test.ts +281 -0
  99. package/tests/sync.test.ts +385 -0
  100. package/tsconfig.build.json +16 -0
  101. package/tsconfig.build.tsbuildinfo +1 -0
  102. package/tsconfig.json +7 -0
  103. package/tsconfig.test.json +11 -0
  104. package/tsconfig.test.tsbuildinfo +1 -0
  105. package/tsconfig.tests.tsbuildinfo +1 -0
  106. package/vitest.config.ts +5 -0
@@ -0,0 +1,262 @@
1
+ import { type Keypair, randomStr, verifySignature } from '@atproto/crypto'
2
+ import { fromBase64, toBase64 } from '@atproto/lex-data'
3
+ import { SpaceTokenError } from './error.js'
4
+
5
+ /**
6
+ * How a space authority is addressed when acting as the space host: the service
7
+ * entry a delegation token and a client attestation name as their audience. Note
8
+ * this is the *audience*, not necessarily where requests are sent — an authority
9
+ * that publishes no such entry is still reached at its `#atproto_pds` endpoint.
10
+ */
11
+ export const spaceHostAud = (spaceDid: string): string =>
12
+ `${spaceDid}#atproto_space_host`
13
+
14
+ // The three token classes share a wire shape and differ only in who signs them,
15
+ // who they're addressed to, and how long they live — hence data, not three impls.
16
+ // A credential is multi-use across repo hosts, so it carries no aud; it is bound
17
+ // to the holder's DPoP key instead (see dpop.ts).
18
+ export const SPACE_TOKEN_TYPES = {
19
+ delegation: {
20
+ typ: 'atproto-space-delegation+jwt',
21
+ kid: '#atproto',
22
+ expiresInSec: 60,
23
+ requireAud: true,
24
+ requireCnf: false,
25
+ singleUse: true,
26
+ },
27
+ // An authority that publishes a dedicated `#atproto_space` key signs with it
28
+ // and passes that `kid`. Absent one, the space signing key is the account's
29
+ // `#atproto` key, which is the case for any authority hosted on a PDS.
30
+ credential: {
31
+ typ: 'atproto-space-credential+jwt',
32
+ kid: '#atproto',
33
+ expiresInSec: 7200,
34
+ requireAud: false,
35
+ requireCnf: true,
36
+ singleUse: false,
37
+ },
38
+ clientAttestation: {
39
+ typ: 'atproto-client-attestation+jwt',
40
+ kid: undefined,
41
+ expiresInSec: 60,
42
+ requireAud: true,
43
+ requireCnf: false,
44
+ singleUse: true,
45
+ },
46
+ } as const
47
+
48
+ export type SpaceTokenType = keyof typeof SPACE_TOKEN_TYPES
49
+
50
+ // `sub` is the space URI, or the client_id for a client attestation.
51
+ export type SpaceTokenPayload = {
52
+ iss: string
53
+ sub: string
54
+ aud?: string
55
+ iat: number
56
+ exp: number
57
+ jti: string
58
+ cnf?: { jkt: string }
59
+ }
60
+
61
+ export type SpaceTokenHeader = {
62
+ alg: string
63
+ typ: string
64
+ kid?: string
65
+ }
66
+
67
+ export type SpaceToken = {
68
+ header: SpaceTokenHeader
69
+ payload: SpaceTokenPayload
70
+ }
71
+
72
+ export const CLOCK_SKEW_SEC = 5
73
+
74
+ export type CreateSpaceTokenOpts = {
75
+ iss: string
76
+ sub: string
77
+ aud?: string
78
+ dpopJkt?: string
79
+ expiresInSec?: number
80
+ kid?: string
81
+ }
82
+
83
+ export const createSpaceToken = async (
84
+ type: SpaceTokenType,
85
+ opts: CreateSpaceTokenOpts,
86
+ keypair: Keypair,
87
+ ): Promise<string> => {
88
+ const spec = SPACE_TOKEN_TYPES[type]
89
+ if (spec.requireAud && !opts.aud) {
90
+ throw new SpaceTokenError(`a ${type} token requires an "aud"`)
91
+ }
92
+ if (spec.requireCnf && !opts.dpopJkt) {
93
+ throw new SpaceTokenError(`a ${type} token requires a "dpopJkt"`)
94
+ }
95
+
96
+ const iat = Math.floor(Date.now() / 1000)
97
+ const header: SpaceTokenHeader = { alg: keypair.jwtAlg, typ: spec.typ }
98
+ const kid = opts.kid ?? spec.kid
99
+ if (kid) header.kid = kid
100
+
101
+ const payload: SpaceTokenPayload = {
102
+ iss: opts.iss,
103
+ sub: opts.sub,
104
+ ...(opts.aud ? { aud: opts.aud } : undefined),
105
+ ...(opts.dpopJkt ? { cnf: { jkt: opts.dpopJkt } } : undefined),
106
+ iat,
107
+ exp: iat + (opts.expiresInSec ?? spec.expiresInSec),
108
+ jti: randomStr(16, 'hex'),
109
+ }
110
+
111
+ const signingInput = `${jsonToB64Url(header)}.${jsonToB64Url(payload)}`
112
+ const sig = await keypair.sign(new TextEncoder().encode(signingInput))
113
+ return `${signingInput}.${toBase64(sig, 'base64url')}`
114
+ }
115
+
116
+ // Structural validation only, no signature check. This is as far as we go for
117
+ // client attestations, whose key comes from the client's JWKS, not a DID doc.
118
+ export const parseSpaceToken = (
119
+ type: SpaceTokenType,
120
+ jwt: string,
121
+ ): SpaceToken & { signingInput: Uint8Array; sig: Uint8Array } => {
122
+ const spec = SPACE_TOKEN_TYPES[type]
123
+
124
+ const parts = jwt.split('.')
125
+ if (parts.length !== 3) {
126
+ throw new SpaceTokenError('malformed token: expected 3 parts', 'BadJwt')
127
+ }
128
+ const [headerB64, payloadB64, sigB64] = parts
129
+
130
+ const header = decodeJsonPart<SpaceTokenHeader>(headerB64, 'header')
131
+ const payload = decodeJsonPart<SpaceTokenPayload>(payloadB64, 'payload')
132
+
133
+ if (header.typ !== spec.typ) {
134
+ throw new SpaceTokenError(
135
+ `wrong token type: expected "${spec.typ}", got "${header.typ}"`,
136
+ 'BadJwtType',
137
+ )
138
+ }
139
+ if (typeof header.alg !== 'string' || !header.alg) {
140
+ throw new SpaceTokenError('missing token "alg"', 'BadJwt')
141
+ }
142
+ if (!payload.iss) {
143
+ throw new SpaceTokenError('missing token "iss"', 'BadJwtIss')
144
+ }
145
+ if (!payload.sub) {
146
+ throw new SpaceTokenError('missing token "sub"', 'BadJwtSub')
147
+ }
148
+ if (typeof payload.exp !== 'number') {
149
+ throw new SpaceTokenError('missing token "exp"', 'BadJwt')
150
+ }
151
+ if (spec.requireAud && !payload.aud) {
152
+ throw new SpaceTokenError('missing token "aud"', 'BadJwtAudience')
153
+ }
154
+ if (spec.requireCnf && !payload.cnf?.jkt) {
155
+ throw new SpaceTokenError('missing token "cnf.jkt"', 'BadJwtCnf')
156
+ }
157
+ if (spec.singleUse && (typeof payload.jti !== 'string' || !payload.jti)) {
158
+ throw new SpaceTokenError(
159
+ `a ${type} token requires a "jti" to be consumed by`,
160
+ 'BadJwt',
161
+ )
162
+ }
163
+ if (type === 'clientAttestation' && payload.iss !== payload.sub) {
164
+ throw new SpaceTokenError(
165
+ 'client attestation "iss" and "sub" must both be the client_id',
166
+ 'BadJwtIss',
167
+ )
168
+ }
169
+
170
+ return {
171
+ header,
172
+ payload,
173
+ signingInput: new TextEncoder().encode(`${headerB64}.${payloadB64}`),
174
+ sig: fromBase64(sigB64, 'base64url'),
175
+ }
176
+ }
177
+
178
+ export type VerifySpaceTokenOpts = {
179
+ /** Passed `kid` so a verifier can honour the key id. */
180
+ getSigningKey: (
181
+ iss: string,
182
+ kid: string | undefined,
183
+ forceRefresh: boolean,
184
+ ) => string | Promise<string>
185
+ aud?: string
186
+ sub?: string
187
+ }
188
+
189
+ export const verifySpaceToken = async (
190
+ type: SpaceTokenType,
191
+ jwt: string,
192
+ opts: VerifySpaceTokenOpts,
193
+ ): Promise<SpaceToken> => {
194
+ const { header, payload, signingInput, sig } = parseSpaceToken(type, jwt)
195
+
196
+ const now = Math.floor(Date.now() / 1000)
197
+ if (now - CLOCK_SKEW_SEC >= payload.exp) {
198
+ throw new SpaceTokenError('token expired', 'JwtExpired')
199
+ }
200
+ if (opts.aud !== undefined && payload.aud !== opts.aud) {
201
+ throw new SpaceTokenError(
202
+ 'token audience does not match this service',
203
+ 'BadJwtAudience',
204
+ )
205
+ }
206
+ if (opts.sub !== undefined && payload.sub !== opts.sub) {
207
+ throw new SpaceTokenError(
208
+ 'token subject does not match the requested space',
209
+ 'BadJwtSub',
210
+ )
211
+ }
212
+ const didKey = await opts.getSigningKey(payload.iss, header.kid, false)
213
+ if (await matchesSignature(didKey, header, signingInput, sig)) {
214
+ return { header, payload }
215
+ }
216
+
217
+ // The signing key may have rotated since the one we hold was cached.
218
+ const freshDidKey = await opts.getSigningKey(payload.iss, header.kid, true)
219
+ if (
220
+ freshDidKey !== didKey &&
221
+ (await matchesSignature(freshDidKey, header, signingInput, sig))
222
+ ) {
223
+ return { header, payload }
224
+ }
225
+
226
+ throw new SpaceTokenError('invalid token signature', 'BadJwtSignature')
227
+ }
228
+
229
+ const matchesSignature = async (
230
+ didKey: string,
231
+ header: SpaceTokenHeader,
232
+ signingInput: Uint8Array,
233
+ sig: Uint8Array,
234
+ ): Promise<boolean> => {
235
+ try {
236
+ return await verifySignature(didKey, signingInput, sig, {
237
+ jwtAlg: header.alg,
238
+ })
239
+ } catch (err) {
240
+ throw new SpaceTokenError(
241
+ `could not verify token signature: ${errMsg(err)}`,
242
+ 'BadJwtSignature',
243
+ )
244
+ }
245
+ }
246
+
247
+ const jsonToB64Url = (json: Record<string, unknown>): string =>
248
+ toBase64(new TextEncoder().encode(JSON.stringify(json)), 'base64url')
249
+
250
+ const decodeJsonPart = <T>(b64: string, part: string): T => {
251
+ try {
252
+ return JSON.parse(new TextDecoder().decode(fromBase64(b64, 'base64url')))
253
+ } catch (err) {
254
+ throw new SpaceTokenError(
255
+ `could not parse token ${part}: ${errMsg(err)}`,
256
+ 'BadJwt',
257
+ )
258
+ }
259
+ }
260
+
261
+ const errMsg = (err: unknown): string =>
262
+ err instanceof Error ? err.message : String(err)
package/src/dpop.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { EmbeddedJWK, calculateJwkThumbprint, errors, jwtVerify } from 'jose'
2
+ import { randomStr, sha256 } from '@atproto/crypto'
3
+ import type { Key } from '@atproto/jwk'
4
+ import { toBase64 } from '@atproto/lex-data'
5
+ import { CLOCK_SKEW_SEC } from './credential.js'
6
+ import { DpopProofError } from './error.js'
7
+
8
+ /**
9
+ * DPoP binding for space credentials (RFC 9449).
10
+ *
11
+ * A credential reads a whole space and is presented to every repo host in it. As a
12
+ * bearer token it would be a shared secret: a host given one to serve its own repo
13
+ * could replay it against every other host in the space.
14
+ */
15
+
16
+ export const DPOP_PROOF_TYP = 'dpop+jwt'
17
+ export const MAX_PROOF_AGE_SEC = 60
18
+ const SIGNING_ALG = 'ES256'
19
+
20
+ export type DpopProof = {
21
+ jti: string
22
+ jkt: string
23
+ htm: string
24
+ htu: string
25
+ }
26
+
27
+ export type CreateDpopProofOpts = {
28
+ htm: string
29
+ htu: string
30
+ /** Present when proving possession of a key bound to an access credential. */
31
+ credential?: string
32
+ }
33
+
34
+ export const createDpopProof = async (
35
+ key: Key,
36
+ opts: CreateDpopProofOpts,
37
+ ): Promise<string> => {
38
+ const jwk = key.bareJwk
39
+ if (!jwk) {
40
+ throw new DpopProofError('a DPoP proof requires an asymmetric key')
41
+ }
42
+ if (!key.algorithms.includes(SIGNING_ALG)) {
43
+ throw new DpopProofError(
44
+ `a DPoP key must support ${SIGNING_ALG}, got: ${key.algorithms.join(', ')}`,
45
+ )
46
+ }
47
+
48
+ return key.createJwt(
49
+ { alg: SIGNING_ALG, typ: DPOP_PROOF_TYP, jwk },
50
+ {
51
+ jti: randomStr(16, 'hex'),
52
+ htm: opts.htm,
53
+ htu: normalizeHtu(opts.htu),
54
+ ...(opts.credential !== undefined
55
+ ? { ath: await hashCredential(opts.credential) }
56
+ : undefined),
57
+ iat: Math.floor(Date.now() / 1000),
58
+ },
59
+ )
60
+ }
61
+
62
+ type DpopRequest = {
63
+ htm: string
64
+ htu: string
65
+ }
66
+
67
+ export type VerifyDpopProofOpts = DpopRequest &
68
+ (
69
+ | {
70
+ credential: string
71
+ jkt: string
72
+ }
73
+ | {
74
+ credential?: undefined
75
+ jkt?: undefined
76
+ }
77
+ )
78
+
79
+ /** Checks everything but replay; the caller records the returned `jti` itself. */
80
+ export const verifyDpopProof = async (
81
+ proof: string,
82
+ opts: VerifyDpopProofOpts,
83
+ ): Promise<DpopProof> => {
84
+ // EmbeddedJWK verifies against the proof's own `jwk` header; the thumbprint
85
+ // comparison below is what ties that key to the credential.
86
+ //
87
+ // Without the skew allowance a holder whose clock runs even slightly fast is
88
+ // rejected outright, since jose refuses an `iat` in the verifier's future.
89
+ const { protectedHeader, payload } = await jwtVerify(proof, EmbeddedJWK, {
90
+ typ: DPOP_PROOF_TYP,
91
+ algorithms: [SIGNING_ALG],
92
+ maxTokenAge: MAX_PROOF_AGE_SEC,
93
+ clockTolerance: CLOCK_SKEW_SEC,
94
+ }).catch((err) => {
95
+ if (err instanceof errors.JWTExpired) {
96
+ throw new DpopProofError(
97
+ `DPoP proof is expired: ${errMsg(err)}`,
98
+ 'DpopProofExpired',
99
+ )
100
+ }
101
+ throw new DpopProofError(
102
+ `could not verify DPoP proof: ${errMsg(err)}`,
103
+ 'BadDpopProofSignature',
104
+ )
105
+ })
106
+
107
+ const { jti, htm, htu, ath } = payload
108
+ if (typeof jti !== 'string' || !jti) {
109
+ throw new DpopProofError('missing DPoP proof "jti"')
110
+ }
111
+ if (htm !== opts.htm) {
112
+ throw new DpopProofError('DPoP proof "htm" does not match the request')
113
+ }
114
+ if (typeof htu !== 'string' || htu !== normalizeHtu(opts.htu)) {
115
+ throw new DpopProofError('DPoP proof "htu" does not match the request')
116
+ }
117
+ if (opts.credential !== undefined) {
118
+ if (ath !== (await hashCredential(opts.credential))) {
119
+ throw new DpopProofError('DPoP proof "ath" does not match the credential')
120
+ }
121
+ } else if (ath !== undefined) {
122
+ throw new DpopProofError(
123
+ 'DPoP proof "ath" must be omitted when obtaining a credential',
124
+ )
125
+ }
126
+
127
+ // Present because jwtVerify used EmbeddedJWK.
128
+ const jkt = await calculateJwkThumbprint(
129
+ protectedHeader.jwk!,
130
+ 'sha256',
131
+ ).catch((err) => {
132
+ throw new DpopProofError(
133
+ `could not compute DPoP key thumbprint: ${errMsg(err)}`,
134
+ )
135
+ })
136
+ if (opts.jkt !== undefined && jkt !== opts.jkt) {
137
+ throw new DpopProofError(
138
+ 'DPoP proof is not signed by the key the credential is bound to',
139
+ 'DpopKeyMismatch',
140
+ )
141
+ }
142
+
143
+ return { jti, jkt, htm, htu }
144
+ }
145
+
146
+ /** The RFC 7638 thumbprint of a DPoP key. */
147
+ export const dpopJktForKey = async (key: Key): Promise<string> => {
148
+ const jwk = key.bareJwk
149
+ if (!jwk) {
150
+ throw new DpopProofError('a DPoP key must be asymmetric')
151
+ }
152
+ return calculateJwkThumbprint(jwk, 'sha256')
153
+ }
154
+
155
+ // Strips query and fragment (RFC 9449 §4.2), so one proof covers any query on a path.
156
+ const normalizeHtu = (url: string): string => {
157
+ const parsed = new URL(url)
158
+ return parsed.origin + parsed.pathname
159
+ }
160
+
161
+ const hashCredential = async (credential: string): Promise<string> => {
162
+ return toBase64(await sha256(credential), 'base64url')
163
+ }
164
+
165
+ const errMsg = (err: unknown): string =>
166
+ err instanceof Error ? err.message : String(err)
package/src/error.ts ADDED
@@ -0,0 +1,38 @@
1
+ export class SpaceTokenError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly code:
5
+ | 'BadJwt'
6
+ | 'BadJwtType'
7
+ | 'BadJwtIss'
8
+ | 'BadJwtSub'
9
+ | 'BadJwtAudience'
10
+ | 'BadJwtCnf'
11
+ | 'BadJwtSignature'
12
+ | 'JwtExpired' = 'BadJwt',
13
+ ) {
14
+ super(message)
15
+ this.name = 'SpaceTokenError'
16
+ }
17
+ }
18
+
19
+ export class DpopProofError extends Error {
20
+ constructor(
21
+ message: string,
22
+ public readonly code:
23
+ | 'BadDpopProof'
24
+ | 'BadDpopProofSignature'
25
+ | 'DpopProofExpired'
26
+ | 'DpopKeyMismatch' = 'BadDpopProof',
27
+ ) {
28
+ super(message)
29
+ this.name = 'DpopProofError'
30
+ }
31
+ }
32
+
33
+ export class RepoVerificationError extends Error {
34
+ constructor(message: string) {
35
+ super(message)
36
+ this.name = 'RepoVerificationError'
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './credential.js'
2
+ export * from './dpop.js'
3
+ export * from './error.js'
4
+ export * from './lthash.js'
5
+ export * from './repo-commit.js'
6
+ export * from './sync/index.js'
7
+ export * from './types.js'
8
+ export * from './util.js'
package/src/lthash.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { blake3 } from '@noble/hashes/blake3'
2
+ import { sha256 } from '@noble/hashes/sha256'
3
+
4
+ const LANES = 1024
5
+ const LANE_BYTES = 2 // @NOTE cannot change without reworking the Uint16Array impl
6
+ export const LTHASH_STATE_BYTES = LANES * LANE_BYTES // 2048
7
+
8
+ /**
9
+ * A homomorphic set hash. Each element expands to 1024 little-endian u16 lanes
10
+ * which are summed into the state mod 2^16. Addition and subtraction commute, so
11
+ * the state depends only on the current set, not on insertion order.
12
+ */
13
+ export class LtHash {
14
+ // Two views over one buffer: bytes for I/O, lanes for u16 arithmetic (which
15
+ // wraps mod 2^16 on its own).
16
+ private readonly bytes: Uint8Array
17
+ private readonly lanes: Uint16Array
18
+
19
+ constructor(state?: Uint8Array | null) {
20
+ if (state && state.length !== LTHASH_STATE_BYTES) {
21
+ throw new Error(
22
+ `LtHash state must be ${LTHASH_STATE_BYTES} bytes, got ${state.length}`,
23
+ )
24
+ }
25
+ const buffer = new ArrayBuffer(LTHASH_STATE_BYTES)
26
+ this.bytes = new Uint8Array(buffer)
27
+ this.lanes = new Uint16Array(buffer)
28
+ if (state) this.bytes.set(state)
29
+ }
30
+
31
+ add(element: string): this {
32
+ const lanes = expand(element)
33
+ for (let i = 0; i < LANES; i++) {
34
+ this.lanes[i] += lanes[i]
35
+ }
36
+ return this
37
+ }
38
+
39
+ remove(element: string): this {
40
+ const lanes = expand(element)
41
+ for (let i = 0; i < LANES; i++) {
42
+ this.lanes[i] -= lanes[i]
43
+ }
44
+ return this
45
+ }
46
+
47
+ // The full state, for persistence.
48
+ state(): Uint8Array {
49
+ return new Uint8Array(this.bytes)
50
+ }
51
+
52
+ digest(): Uint8Array {
53
+ return sha256(this.bytes)
54
+ }
55
+
56
+ isEmpty(): boolean {
57
+ for (let i = 0; i < LANES; i++) {
58
+ if (this.lanes[i] !== 0) return false
59
+ }
60
+ return true
61
+ }
62
+
63
+ equals(other: LtHash): boolean {
64
+ for (let i = 0; i < LANES; i++) {
65
+ if (this.lanes[i] !== other.lanes[i]) return false
66
+ }
67
+ return true
68
+ }
69
+ }
70
+
71
+ // Expand to 1024 u16 lanes with BLAKE3 in XOF mode.
72
+ const expand = (element: string): Uint16Array => {
73
+ const expanded = blake3(new TextEncoder().encode(element), {
74
+ dkLen: LTHASH_STATE_BYTES,
75
+ })
76
+ // Copy: the result may be a view at an offset a Uint16Array can't straddle.
77
+ const buffer = new ArrayBuffer(LTHASH_STATE_BYTES)
78
+ new Uint8Array(buffer).set(expanded)
79
+ return new Uint16Array(buffer)
80
+ }