@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,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,2 @@
1
+ export * from './consumer.js'
2
+ export * from './provider.js'
@@ -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
+ }