@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.
- package/CHANGELOG.md +11 -0
- package/LICENSE.txt +7 -0
- package/dist/commit.d.ts +25 -0
- package/dist/commit.d.ts.map +1 -0
- package/dist/commit.js +80 -0
- package/dist/commit.js.map +1 -0
- package/dist/credential.d.ts +77 -0
- package/dist/credential.d.ts.map +1 -0
- package/dist/credential.js +160 -0
- package/dist/credential.js.map +1 -0
- package/dist/dpop.d.ts +40 -0
- package/dist/dpop.d.ts.map +1 -0
- package/dist/dpop.js +96 -0
- package/dist/dpop.js.map +1 -0
- package/dist/error.d.ts +12 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +21 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lthash.d.ts +18 -0
- package/dist/lthash.d.ts.map +1 -0
- package/dist/lthash.js +68 -0
- package/dist/lthash.js.map +1 -0
- package/dist/repo-commit.d.ts +51 -0
- package/dist/repo-commit.d.ts.map +1 -0
- package/dist/repo-commit.js +137 -0
- package/dist/repo-commit.js.map +1 -0
- package/dist/space-members.d.ts +27 -0
- package/dist/space-members.d.ts.map +1 -0
- package/dist/space-members.js +111 -0
- package/dist/space-members.js.map +1 -0
- package/dist/space-repo.d.ts +33 -0
- package/dist/space-repo.d.ts.map +1 -0
- package/dist/space-repo.js +136 -0
- package/dist/space-repo.js.map +1 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +19 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/memory-members-storage.d.ts +12 -0
- package/dist/storage/memory-members-storage.d.ts.map +1 -0
- package/dist/storage/memory-members-storage.js +36 -0
- package/dist/storage/memory-members-storage.js.map +1 -0
- package/dist/storage/memory-repo-storage.d.ts +17 -0
- package/dist/storage/memory-repo-storage.d.ts.map +1 -0
- package/dist/storage/memory-repo-storage.js +56 -0
- package/dist/storage/memory-repo-storage.js.map +1 -0
- package/dist/storage/types.d.ts +14 -0
- package/dist/storage/types.d.ts.map +1 -0
- package/dist/storage/types.js +3 -0
- package/dist/storage/types.js.map +1 -0
- package/dist/sync/car.d.ts +13 -0
- package/dist/sync/car.d.ts.map +1 -0
- package/dist/sync/car.js +185 -0
- package/dist/sync/car.js.map +1 -0
- package/dist/sync/consumer.d.ts +39 -0
- package/dist/sync/consumer.d.ts.map +1 -0
- package/dist/sync/consumer.js +89 -0
- package/dist/sync/consumer.js.map +1 -0
- package/dist/sync/index.d.ts +3 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +3 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/provider.d.ts +25 -0
- package/dist/sync/provider.d.ts.map +1 -0
- package/dist/sync/provider.js +60 -0
- package/dist/sync/provider.js.map +1 -0
- package/dist/sync/repo-index.d.ts +19 -0
- package/dist/sync/repo-index.d.ts.map +1 -0
- package/dist/sync/repo-index.js +58 -0
- package/dist/sync/repo-index.js.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +35 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +6 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +14 -0
- package/dist/util.js.map +1 -0
- package/package.json +44 -0
- package/src/credential.ts +262 -0
- package/src/dpop.ts +166 -0
- package/src/error.ts +38 -0
- package/src/index.ts +8 -0
- package/src/lthash.ts +80 -0
- package/src/repo-commit.ts +171 -0
- package/src/sync/consumer.ts +149 -0
- package/src/sync/index.ts +2 -0
- package/src/sync/provider.ts +82 -0
- package/src/types.ts +62 -0
- package/src/util.ts +21 -0
- package/tests/credential.test.ts +323 -0
- package/tests/dpop.test.ts +234 -0
- package/tests/lthash.test.ts +97 -0
- package/tests/repo-commit.test.ts +281 -0
- package/tests/sync.test.ts +385 -0
- package/tsconfig.build.json +16 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
- package/tsconfig.test.json +11 -0
- package/tsconfig.test.tsbuildinfo +1 -0
- package/tsconfig.tests.tsbuildinfo +1 -0
- package/vitest.config.ts +5 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Keypair,
|
|
3
|
+
hkdfSha256,
|
|
4
|
+
hmacSha256,
|
|
5
|
+
randomBytes,
|
|
6
|
+
verifySignature,
|
|
7
|
+
} from '@atproto/crypto'
|
|
8
|
+
import { type Cid, ui8Equals } from '@atproto/lex-data'
|
|
9
|
+
import { LtHash } from './lthash.js'
|
|
10
|
+
import {
|
|
11
|
+
COMMIT_VERSION,
|
|
12
|
+
type CommitCtx,
|
|
13
|
+
type RecordPath,
|
|
14
|
+
type RepoIndex,
|
|
15
|
+
type RepoOp,
|
|
16
|
+
type SignedCommit,
|
|
17
|
+
} from './types.js'
|
|
18
|
+
import { formatSetHashElement } from './util.js'
|
|
19
|
+
|
|
20
|
+
export class RepoCommit {
|
|
21
|
+
constructor(public setHash: LtHash = new LtHash()) {}
|
|
22
|
+
|
|
23
|
+
// nullish state yields an empty repo
|
|
24
|
+
static fromState(state: Uint8Array | null | undefined): RepoCommit {
|
|
25
|
+
return new RepoCommit(new LtHash(state))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static fromRecords(records: Iterable<RecordPath & { cid: Cid }>): RepoCommit {
|
|
29
|
+
const commit = new RepoCommit()
|
|
30
|
+
for (const { collection, rkey, cid } of records) {
|
|
31
|
+
commit.add(collection, rkey, cid)
|
|
32
|
+
}
|
|
33
|
+
return commit
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Fold in every record an index describes, to compare against a commit.
|
|
37
|
+
static fromIndex(index: RepoIndex): RepoCommit {
|
|
38
|
+
const commit = new RepoCommit()
|
|
39
|
+
for (const [path, cid] of Object.entries(index)) {
|
|
40
|
+
commit.setHash.add(`${path}/${cid.toString()}`)
|
|
41
|
+
}
|
|
42
|
+
return commit
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
add(collection: string, rkey: string, cid: Cid): this {
|
|
46
|
+
this.setHash.add(formatSetHashElement(collection, rkey, cid))
|
|
47
|
+
return this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
remove(collection: string, rkey: string, cid: Cid): this {
|
|
51
|
+
this.setHash.remove(formatSetHashElement(collection, rkey, cid))
|
|
52
|
+
return this
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
applyOp(op: RepoOp): this {
|
|
56
|
+
if (op.prev) this.remove(op.collection, op.rkey, op.prev)
|
|
57
|
+
if (op.cid) this.add(op.collection, op.rkey, op.cid)
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
applyOps(ops: Iterable<RepoOp>): this {
|
|
62
|
+
for (const op of ops) {
|
|
63
|
+
this.applyOp(op)
|
|
64
|
+
}
|
|
65
|
+
return this
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether this repo's contents match a signed commit. Verify the commit first —
|
|
70
|
+
* on its own this says nothing about authenticity.
|
|
71
|
+
*/
|
|
72
|
+
matches(commit: SignedCommit): boolean {
|
|
73
|
+
return ui8Equals(this.setHash.digest(), commit.hash)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sign a commit over the current contents.
|
|
78
|
+
*
|
|
79
|
+
* The signature covers only the ctx, never the digest, so a leaked commit proves
|
|
80
|
+
* nothing about what the author wrote. The digest is bound to the ctx by a
|
|
81
|
+
* symmetric MAC instead: readers get integrity, third parties get nothing. A
|
|
82
|
+
* fresh `ikm` per commit means each reader receives a distinct one.
|
|
83
|
+
*/
|
|
84
|
+
async sign(ctx: CommitCtx, keypair: Keypair): Promise<SignedCommit> {
|
|
85
|
+
const hash = this.setHash.digest()
|
|
86
|
+
const ikm = randomBytes(32)
|
|
87
|
+
const ctxBytes = encodeCommitCtx(ctx, ikm)
|
|
88
|
+
return {
|
|
89
|
+
ver: COMMIT_VERSION,
|
|
90
|
+
hash,
|
|
91
|
+
ikm,
|
|
92
|
+
mac: computeMac(ikm, ctxBytes, hash),
|
|
93
|
+
sig: await keypair.sign(ctxBytes),
|
|
94
|
+
rev: ctx.rev,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Verify a commit's signature (authenticity) and MAC (integrity). Once this passes,
|
|
101
|
+
* `hash` is trusted as the author's claim about their repo, which is what makes
|
|
102
|
+
* {@link RepoCommit#matches} meaningful.
|
|
103
|
+
*/
|
|
104
|
+
export const verifyCommit = async (
|
|
105
|
+
commit: SignedCommit,
|
|
106
|
+
ctx: CommitCtx,
|
|
107
|
+
didKey: string,
|
|
108
|
+
): Promise<boolean> => {
|
|
109
|
+
if (commit.ver !== COMMIT_VERSION) return false
|
|
110
|
+
if (commit.rev !== ctx.rev) return false
|
|
111
|
+
|
|
112
|
+
const ctxBytes = encodeCommitCtx(ctx, commit.ikm)
|
|
113
|
+
const mac = computeMac(commit.ikm, ctxBytes, commit.hash)
|
|
114
|
+
if (!ui8Equals(mac, commit.mac)) return false
|
|
115
|
+
|
|
116
|
+
return verifySignature(didKey, ctxBytes, commit.sig)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const computeMac = (
|
|
120
|
+
ikm: Uint8Array,
|
|
121
|
+
ctxBytes: Uint8Array,
|
|
122
|
+
hash: Uint8Array,
|
|
123
|
+
): Uint8Array => {
|
|
124
|
+
return hmacSha256(hkdfSha256(ikm, ctxBytes), hash)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const DOMAIN_PREFIX = new TextEncoder().encode('atproto-space-v1')
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ```
|
|
131
|
+
* ctx = "atproto-space-v1"
|
|
132
|
+
* || uint16be(len(space)) || space
|
|
133
|
+
* || uint16be(len(author)) || author
|
|
134
|
+
* || uint16be(len(rev)) || rev
|
|
135
|
+
* || uint16be(len(ikm)) || ikm
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* Length prefixes are big-endian per the TLS variable-length vector convention —
|
|
139
|
+
* deliberately the opposite byte order from the set hash's lanes.
|
|
140
|
+
*/
|
|
141
|
+
export const encodeCommitCtx = (
|
|
142
|
+
ctx: CommitCtx,
|
|
143
|
+
ikm: Uint8Array,
|
|
144
|
+
): Uint8Array => {
|
|
145
|
+
const encoder = new TextEncoder()
|
|
146
|
+
const fields = [
|
|
147
|
+
encoder.encode(ctx.space),
|
|
148
|
+
encoder.encode(ctx.author),
|
|
149
|
+
encoder.encode(ctx.rev),
|
|
150
|
+
ikm,
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
let size = DOMAIN_PREFIX.length
|
|
154
|
+
for (const field of fields) {
|
|
155
|
+
if (field.length > 0xffff) {
|
|
156
|
+
throw new Error('commit ctx field exceeds uint16 length prefix')
|
|
157
|
+
}
|
|
158
|
+
size += 2 + field.length
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const out = new Uint8Array(size)
|
|
162
|
+
out.set(DOMAIN_PREFIX)
|
|
163
|
+
let offset = DOMAIN_PREFIX.length
|
|
164
|
+
for (const field of fields) {
|
|
165
|
+
out[offset++] = (field.length >>> 8) & 0xff
|
|
166
|
+
out[offset++] = field.length & 0xff
|
|
167
|
+
out.set(field, offset)
|
|
168
|
+
offset += field.length
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type check, readCarStream } from '@atproto/common'
|
|
2
|
+
import { decode } from '@atproto/lex-cbor'
|
|
3
|
+
import type { Cid, LexMap } from '@atproto/lex-data'
|
|
4
|
+
import { RepoVerificationError } from '../error.js'
|
|
5
|
+
import { RepoCommit, verifyCommit } from '../repo-commit.js'
|
|
6
|
+
import {
|
|
7
|
+
type CommitCtx,
|
|
8
|
+
type RepoIndex,
|
|
9
|
+
type SignedCommit,
|
|
10
|
+
type SpaceRecord,
|
|
11
|
+
def,
|
|
12
|
+
} from '../types.js'
|
|
13
|
+
import { parseRecordPath } from '../util.js'
|
|
14
|
+
|
|
15
|
+
export type VerifyRepoParams = {
|
|
16
|
+
space: string
|
|
17
|
+
author: string
|
|
18
|
+
didKey: string
|
|
19
|
+
// False for an index-only car, which carries no record blocks. Defaults to true.
|
|
20
|
+
expectValues?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type VerifiedRecord = {
|
|
24
|
+
collection: string
|
|
25
|
+
rkey: string
|
|
26
|
+
cid: Cid
|
|
27
|
+
record: SpaceRecord
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type VerifiedRepo = {
|
|
31
|
+
commit: SignedCommit
|
|
32
|
+
index: RepoIndex
|
|
33
|
+
repo: RepoCommit
|
|
34
|
+
records: AsyncGenerator<VerifiedRecord>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Verify a serialized repo, streaming out its records.
|
|
39
|
+
*
|
|
40
|
+
* The three stages follow the CAR's layout, so nothing needs buffering: the
|
|
41
|
+
* commit, then the index against the commit's hash (which authenticates every
|
|
42
|
+
* path/cid pair without reading a record), then each block against its index
|
|
43
|
+
* entry. That last stage runs as `records` is drained, so it must be consumed to
|
|
44
|
+
* know the repo was complete — {@link verifyRepoCarFull} does that.
|
|
45
|
+
*/
|
|
46
|
+
export const verifyRepoCar = async (
|
|
47
|
+
car: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
48
|
+
params: VerifyRepoParams,
|
|
49
|
+
): Promise<VerifiedRepo> => {
|
|
50
|
+
// The reader hashes every block against its cid as it streams.
|
|
51
|
+
const { roots, blocks } = await readCarStream(car)
|
|
52
|
+
if (roots.length !== 2) {
|
|
53
|
+
throw new RepoVerificationError(
|
|
54
|
+
`expected 2 car roots (commit, index), got ${roots.length}`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
const [commitRoot, indexRoot] = roots
|
|
58
|
+
|
|
59
|
+
const commitBlock = await blocks.next()
|
|
60
|
+
if (commitBlock.done || !commitBlock.value.cid.equals(commitRoot)) {
|
|
61
|
+
throw new RepoVerificationError('expected the commit block to lead the car')
|
|
62
|
+
}
|
|
63
|
+
const commit = parseBlock(commitBlock.value.bytes, def.signedCommit)
|
|
64
|
+
|
|
65
|
+
const ctx: CommitCtx = { ...params, rev: commit.rev }
|
|
66
|
+
if (!(await verifyCommit(commit, ctx, params.didKey))) {
|
|
67
|
+
throw new RepoVerificationError('commit failed verification')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const indexBlock = await blocks.next()
|
|
71
|
+
if (indexBlock.done || !indexBlock.value.cid.equals(indexRoot)) {
|
|
72
|
+
throw new RepoVerificationError(
|
|
73
|
+
'expected the index block to follow the commit',
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
const index = parseBlock(indexBlock.value.bytes, def.repoIndex)
|
|
77
|
+
|
|
78
|
+
const repo = RepoCommit.fromIndex(index)
|
|
79
|
+
if (!repo.matches(commit)) {
|
|
80
|
+
throw new RepoVerificationError('index does not match the commit hash')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
commit,
|
|
85
|
+
index,
|
|
86
|
+
repo,
|
|
87
|
+
records: verifyRecords(blocks, index, params.expectValues !== false),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Verify a serialized repo and collect its records. Prefer {@link verifyRepoCar}
|
|
93
|
+
* for large repos, where streaming avoids holding every record at once.
|
|
94
|
+
*/
|
|
95
|
+
export const verifyRepoCarFull = async (
|
|
96
|
+
car: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
97
|
+
params: VerifyRepoParams,
|
|
98
|
+
): Promise<Omit<VerifiedRepo, 'records'> & { records: VerifiedRecord[] }> => {
|
|
99
|
+
const { records, ...rest } = await verifyRepoCar(car, params)
|
|
100
|
+
const collected: VerifiedRecord[] = []
|
|
101
|
+
for await (const record of records) {
|
|
102
|
+
collected.push(record)
|
|
103
|
+
}
|
|
104
|
+
return { ...rest, records: collected }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The car holds one record block per index entry, in the index's order.
|
|
108
|
+
async function* verifyRecords(
|
|
109
|
+
blocks: AsyncIterable<{ cid: Cid; bytes: Uint8Array }>,
|
|
110
|
+
index: RepoIndex,
|
|
111
|
+
expectValues: boolean,
|
|
112
|
+
): AsyncGenerator<VerifiedRecord> {
|
|
113
|
+
const paths = Object.keys(index)
|
|
114
|
+
let i = 0
|
|
115
|
+
|
|
116
|
+
for await (const block of blocks) {
|
|
117
|
+
if (i >= paths.length) {
|
|
118
|
+
throw new RepoVerificationError('car has more blocks than index entries')
|
|
119
|
+
}
|
|
120
|
+
const path = paths[i]
|
|
121
|
+
const cid = index[path]
|
|
122
|
+
i++
|
|
123
|
+
|
|
124
|
+
if (!block.cid.equals(cid)) {
|
|
125
|
+
throw new RepoVerificationError(
|
|
126
|
+
`expected block ${cid} at ${path}, got ${block.cid}`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
const { collection, rkey } = parseRecordPath(path)
|
|
130
|
+
yield { collection, rkey, cid, record: decode(block.bytes) as LexMap }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isIndexOnly = !expectValues && i === 0
|
|
134
|
+
if (i < paths.length && !isIndexOnly) {
|
|
135
|
+
throw new RepoVerificationError(
|
|
136
|
+
`car is missing ${paths.length - i} record(s) named in the index`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parseBlock = <T>(bytes: Uint8Array, def: check.Def<T>): T => {
|
|
142
|
+
const parsed = def.schema.safeParse(decode(bytes))
|
|
143
|
+
if (!parsed.success) {
|
|
144
|
+
throw new RepoVerificationError(
|
|
145
|
+
`invalid ${def.name}: ${parsed.error.message}`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
return parsed.data
|
|
149
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type CarBlock, writeCarStream } from '@atproto/common'
|
|
2
|
+
import { cidForLex, encode } from '@atproto/lex-cbor'
|
|
3
|
+
import { type Cid, cidForCbor } from '@atproto/lex-data'
|
|
4
|
+
import type { RepoIndex, SignedCommit, SpaceRecord } from '../types.js'
|
|
5
|
+
import { formatRecordPath } from '../util.js'
|
|
6
|
+
|
|
7
|
+
export type SerializedRecord = {
|
|
8
|
+
collection: string
|
|
9
|
+
rkey: string
|
|
10
|
+
cid: Cid
|
|
11
|
+
bytes: Uint8Array
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Serialize a repo as a car: two roots in order — the signed commit, then the
|
|
16
|
+
* index — followed by one block per index entry, in the index's order. Blobs are
|
|
17
|
+
* excluded.
|
|
18
|
+
*
|
|
19
|
+
* Records are collected up front because the index has to precede the blocks it
|
|
20
|
+
* describes.
|
|
21
|
+
*
|
|
22
|
+
* With `excludeValues` only the two roots are written. The index still
|
|
23
|
+
* authenticates against the commit, since the set hash is folded from the index's
|
|
24
|
+
* entries rather than from the blocks.
|
|
25
|
+
*/
|
|
26
|
+
export async function* serializeRepo(
|
|
27
|
+
commit: SignedCommit,
|
|
28
|
+
records: AsyncIterable<SerializedRecord> | Iterable<SerializedRecord>,
|
|
29
|
+
opts: { excludeValues?: boolean } = {},
|
|
30
|
+
): AsyncIterable<Uint8Array> {
|
|
31
|
+
const byPath = new Map<string, SerializedRecord>()
|
|
32
|
+
for await (const record of records) {
|
|
33
|
+
byPath.set(formatRecordPath(record.collection, record.rkey), record)
|
|
34
|
+
}
|
|
35
|
+
// A consumer walks the index as the cbor encoder ordered its keys, so blocks
|
|
36
|
+
// have to follow that order too.
|
|
37
|
+
const paths = [...byPath.keys()].sort(byCanonicalKey)
|
|
38
|
+
|
|
39
|
+
const index: RepoIndex = {}
|
|
40
|
+
for (const path of paths) {
|
|
41
|
+
index[path] = byPath.get(path)!.cid
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const commitBytes = encode(commit)
|
|
45
|
+
const indexBytes = encode(index)
|
|
46
|
+
const [commitRoot, indexRoot] = await Promise.all([
|
|
47
|
+
cidForCbor(commitBytes),
|
|
48
|
+
cidForCbor(indexBytes),
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
yield* writeCarStream(
|
|
52
|
+
[commitRoot, indexRoot],
|
|
53
|
+
(function* (): Generator<CarBlock> {
|
|
54
|
+
yield { cid: commitRoot, bytes: commitBytes }
|
|
55
|
+
yield { cid: indexRoot, bytes: indexBytes }
|
|
56
|
+
if (opts.excludeValues) return
|
|
57
|
+
for (const path of paths) {
|
|
58
|
+
const record = byPath.get(path)!
|
|
59
|
+
yield { cid: record.cid, bytes: record.bytes }
|
|
60
|
+
}
|
|
61
|
+
})(),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Canonical dag-cbor map key order: shortest first, then bytewise.
|
|
66
|
+
const byCanonicalKey = (a: string, b: string): number => {
|
|
67
|
+
if (a.length !== b.length) return a.length - b.length
|
|
68
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const serializeRecord = async (
|
|
72
|
+
collection: string,
|
|
73
|
+
rkey: string,
|
|
74
|
+
record: SpaceRecord,
|
|
75
|
+
): Promise<SerializedRecord> => {
|
|
76
|
+
return {
|
|
77
|
+
collection,
|
|
78
|
+
rkey,
|
|
79
|
+
cid: await cidForLex(record),
|
|
80
|
+
bytes: encode(record),
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { check } from '@atproto/common'
|
|
3
|
+
import { type Cid, type LexMap, ifCid } from '@atproto/lex-data'
|
|
4
|
+
|
|
5
|
+
export type SpaceRecord = LexMap
|
|
6
|
+
|
|
7
|
+
export type RecordPath = {
|
|
8
|
+
collection: string
|
|
9
|
+
rkey: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A create has no `prev`, a delete no `cid`, an update both.
|
|
13
|
+
export type RepoOp = RecordPath & {
|
|
14
|
+
cid: Cid | null
|
|
15
|
+
prev: Cid | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const COMMIT_VERSION = 1
|
|
19
|
+
|
|
20
|
+
export type CommitCtx = {
|
|
21
|
+
space: string
|
|
22
|
+
author: string
|
|
23
|
+
rev: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cidSchema = z.unknown().transform((input, ctx): Cid => {
|
|
27
|
+
const cid = ifCid(input)
|
|
28
|
+
if (cid) return cid
|
|
29
|
+
|
|
30
|
+
ctx.addIssue({
|
|
31
|
+
code: z.ZodIssueCode.custom,
|
|
32
|
+
message: 'Not a valid CID',
|
|
33
|
+
})
|
|
34
|
+
return z.NEVER
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const bytes = z.instanceof(Uint8Array<ArrayBufferLike>)
|
|
38
|
+
|
|
39
|
+
const signedCommit = z.object({
|
|
40
|
+
ver: z.literal(COMMIT_VERSION),
|
|
41
|
+
hash: bytes,
|
|
42
|
+
ikm: bytes,
|
|
43
|
+
sig: bytes,
|
|
44
|
+
mac: bytes,
|
|
45
|
+
rev: z.string(),
|
|
46
|
+
})
|
|
47
|
+
export type SignedCommit = z.infer<typeof signedCommit>
|
|
48
|
+
|
|
49
|
+
// `{collection}/{rkey}` to the record's cid.
|
|
50
|
+
const repoIndex = z.record(z.string(), cidSchema)
|
|
51
|
+
export type RepoIndex = z.infer<typeof repoIndex>
|
|
52
|
+
|
|
53
|
+
export const def = {
|
|
54
|
+
signedCommit: {
|
|
55
|
+
name: 'signed commit',
|
|
56
|
+
schema: signedCommit,
|
|
57
|
+
} as check.Def<SignedCommit>,
|
|
58
|
+
repoIndex: {
|
|
59
|
+
name: 'repo index',
|
|
60
|
+
schema: repoIndex,
|
|
61
|
+
} as check.Def<RepoIndex>,
|
|
62
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Cid } from '@atproto/lex-data'
|
|
2
|
+
import type { RecordPath } from './types.js'
|
|
3
|
+
|
|
4
|
+
export const formatRecordPath = (collection: string, rkey: string): string => {
|
|
5
|
+
return collection + '/' + rkey
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const parseRecordPath = (path: string): RecordPath => {
|
|
9
|
+
const { length, 0: collection, 1: rkey } = path.split('/')
|
|
10
|
+
if (length !== 2) throw new Error(`invalid record path: ${path}`)
|
|
11
|
+
return { collection, rkey }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The element a record contributes to a repo's set hash.
|
|
15
|
+
export const formatSetHashElement = (
|
|
16
|
+
collection: string,
|
|
17
|
+
rkey: string,
|
|
18
|
+
cid: Cid,
|
|
19
|
+
): string => {
|
|
20
|
+
return `${collection}/${rkey}/${cid.toString()}`
|
|
21
|
+
}
|