@estiva-app/protocol 0.1.0

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/src/events.ts ADDED
@@ -0,0 +1,821 @@
1
+ /**
2
+ * Buzz-shaped Nostr event builders.
3
+ *
4
+ * Every builder here mirrors a function in Buzz's own SDK
5
+ * (`crates/buzz-sdk/src/builders.rs`) exactly — same kind, same tags, **same tag
6
+ * order**. Tag order is part of the NIP-01 id preimage, so a reordered tag
7
+ * produces a different event id. Where a shape was verified against Buzz's code,
8
+ * the Rust function is named in the comment.
9
+ *
10
+ * Verified: `buildMessage` + `buildCreateChannel` output was compared against
11
+ * `build_message` / `build_create_channel` by running Buzz's own crates over this
12
+ * emitter's output — byte-identical event ids (Peek's
13
+ * docs/buzz-compat/INTEROP_PROOF.md §4).
14
+ *
15
+ * ## This file used to exist three times
16
+ *
17
+ * Until SHA-3 the same builders lived in `peek/convex/nostr/`, `ship/lib/nostr/`
18
+ * and `estiva-agent/lib/nostr/`, and a `diff -r` somebody had to remember to run
19
+ * was what kept two of them honest. They had already drifted: Peek's
20
+ * `buildMessage` grew an `about` parameter emitting `a` tags and Ship's never
21
+ * did, so the same logical message produced different bytes depending on which
22
+ * app sent it. Nothing failed, because each copy was self-consistent — which is
23
+ * the failure mode this package exists to remove.
24
+ *
25
+ * The union is what ships here. Where the two disagreed, Peek's shape won, and
26
+ * `test/wire-vectors.test.ts` pins the bytes both apps were producing *before*
27
+ * the extraction so the merge cannot have moved either one.
28
+ *
29
+ * ## Pure, and global-free
30
+ *
31
+ * No signing, no I/O, no randomness, no ambient globals. That is what lets this
32
+ * run in Convex's default runtime, in a browser bundle, and under `tsx` from the
33
+ * same published artifact. Signing lives in `./sign.ts`, which needs entropy.
34
+ */
35
+ import { sha256 } from '@noble/hashes/sha256'
36
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'
37
+
38
+ /** A Nostr tag: an array of strings whose first element is the tag name. */
39
+ export type NostrTag = string[]
40
+
41
+ /** An event before signing. `pubkey` is 64-char lowercase hex. */
42
+ export interface UnsignedEvent {
43
+ pubkey: string
44
+ created_at: number
45
+ kind: number
46
+ tags: NostrTag[]
47
+ content: string
48
+ }
49
+
50
+ /** A signed event, ready to submit to the relay. */
51
+ export interface SignedEvent extends UnsignedEvent {
52
+ id: string
53
+ sig: string
54
+ }
55
+
56
+ /**
57
+ * Buzz kind numbers used across the suite (crates/buzz-core/src/kind.rs).
58
+ *
59
+ * The union of what Peek and Ship each declared before SHA-3. A kind number is
60
+ * a fact about the relay, not about an app, so there is no reason for two apps
61
+ * to hold different subsets of it — and a subset is how an app ends up unable to
62
+ * *read* a kind its neighbour writes.
63
+ */
64
+ export const KIND = {
65
+ PROFILE: 0,
66
+ DELETION: 5,
67
+ REACTION: 7,
68
+ STREAM_MESSAGE: 9,
69
+ NIP29_PUT_USER: 9000,
70
+ NIP29_EDIT_METADATA: 9002,
71
+ NIP29_CREATE_GROUP: 9007,
72
+ NIP29_DELETE_GROUP: 9008,
73
+ /**
74
+ * Estiva assertion — a statement *about* something in the channel, rather
75
+ * than a message in it (PEEK-128). `resolution` is the first and, for now,
76
+ * only subtype; the `t` tag names it so later subtypes can join without a
77
+ * new kind.
78
+ *
79
+ * The number sits in the **regular** range (1000–9999), which is what makes
80
+ * it stored and append-only. A replaceable kind would overwrite the previous
81
+ * assertion and destroy exactly the history this exists to keep. It clears
82
+ * NIP-29's 9000–9030 block on purpose.
83
+ */
84
+ ASSERTION: 9101,
85
+ /**
86
+ * NIP-22 comment (REW-10). Ship's comments are posted into the project's
87
+ * Folder — which *is* a Peek topic's channel — so they arrive in a channel as
88
+ * ordinary messages and must be read alongside `STREAM_MESSAGE`.
89
+ *
90
+ * Both kinds, permanently: a `kind:9` is not replaceable, so every comment
91
+ * written before Ship flipped stays `kind:9` forever and this pair can never
92
+ * shrink to one.
93
+ */
94
+ COMMENT: 1111,
95
+ /** NIP-84 highlight. */
96
+ HIGHLIGHT: 9802,
97
+ /** NIP-FC File — see docs/buzz-compat/nips/NIP-FC.md in the Peek repo. */
98
+ FILE: 30840,
99
+ /** NIP-FC Component. */
100
+ COMPONENT: 30841,
101
+ /**
102
+ * NIP-42 relay auth — the challenge response that turns a connected socket
103
+ * into an authenticated one (`Kind::Authentication`, PEE-5).
104
+ *
105
+ * Unlike every other kind here, this one is **never published**: Buzz builds
106
+ * it into `buzz-auth/src/nip42.rs`, never stores it, and never logs it —
107
+ * "AUTH events are never stored or logged (may contain bearer tokens)" is a
108
+ * comment in that file. It goes over the socket and is gone.
109
+ */
110
+ RELAY_AUTH: 22242,
111
+ HTTP_AUTH: 27235,
112
+ } as const
113
+
114
+ /** `build_reaction` (builders.rs:463) caps the emoji at 64 chars. */
115
+ export const MAX_EMOJI_CHARS = 64
116
+
117
+ /** kind:9 content cap — `check_content(content, 64 * 1024)` in build_message. */
118
+ export const MAX_MESSAGE_BYTES = 64 * 1024
119
+
120
+ /** `mention_tags` in builders.rs rejects more than this many mentions. */
121
+ export const MAX_MENTIONS = 50
122
+
123
+ /**
124
+ * NIP-01 event id: sha256 over the canonical serialization
125
+ * `[0, pubkey, created_at, kind, tags, content]`.
126
+ *
127
+ * `JSON.stringify` produces exactly the escaping NIP-01 requires (`\n`, `\"`,
128
+ * `\\`, `\r`, `\t`, `\b`, `\f`, `\uXXXX` for other control chars) with no
129
+ * insignificant whitespace, so no custom serializer is needed.
130
+ *
131
+ * Cross-checked against `nostr-tools/pure`'s `getEventHash` over 103 events
132
+ * recorded from production: all 103 ids agree. See `test/oracle.test.ts` — the
133
+ * third-party implementation is a devDependency of this package and a dependency
134
+ * of nothing, so the check costs consumers no bytes.
135
+ */
136
+ export function computeEventId(e: UnsignedEvent): string {
137
+ const serialized = JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags, e.content])
138
+ return bytesToHex(sha256(utf8ToBytes(serialized)))
139
+ }
140
+
141
+ /** Apps store ms; Nostr `created_at` is seconds. */
142
+ export function toNostrSeconds(ms: number): number {
143
+ return Math.floor(ms / 1000)
144
+ }
145
+
146
+ function assertHex64(value: string, label: string): void {
147
+ if (!/^[0-9a-f]{64}$/.test(value)) {
148
+ throw new Error(`${label} must be 64 lowercase hex chars, got: ${value.slice(0, 16)}…`)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Buzz's `canonical_channel_name` (crates/buzz-core/src/channel.rs:15):
154
+ * strip leading '#' and whitespace, then trim the end. Applied by
155
+ * `build_create_channel` before the name reaches the tag, so we must match it or
156
+ * our ids diverge for any title with a leading '#' or space.
157
+ */
158
+ export function canonicalChannelName(name: string): string {
159
+ return name.replace(/^[#\s]+/, '').replace(/\s+$/, '')
160
+ }
161
+
162
+ /** NIP-01 addressable reference: `<kind>:<pubkey>:<d-tag>`. */
163
+ export function addr(kind: number, pubkey: string, dTag: string): string {
164
+ return `${kind}:${pubkey}:${dTag}`
165
+ }
166
+
167
+ /**
168
+ * kind:0 profile — mirrors `build_profile` (builders.rs:537).
169
+ *
170
+ * Buzz builds the content with `serde_json::Map`, which is a `BTreeMap` unless
171
+ * the `preserve_order` feature is enabled. It is not enabled in Buzz's
172
+ * workspace, so **keys serialize in alphabetical order**: about, display_name,
173
+ * name, nip05, picture. We sort to match; getting this wrong changes the content
174
+ * string and therefore the event id.
175
+ *
176
+ * An app must not call this: the identity service is the sole publisher of
177
+ * `kind:0` and refuses it for every app before consulting any allowlist
178
+ * (SPEC §4.2). It is here because the *identity service* and the seed scripts
179
+ * need it, and because a second copy of this key ordering is the kind of thing
180
+ * that drifts.
181
+ */
182
+ export function buildProfile(
183
+ pubkey: string,
184
+ createdAtMs: number,
185
+ fields: {
186
+ display_name?: string
187
+ name?: string
188
+ picture?: string
189
+ about?: string
190
+ nip05?: string
191
+ },
192
+ ): UnsignedEvent {
193
+ assertHex64(pubkey, 'pubkey')
194
+ const present = Object.entries(fields).filter(([, v]) => v !== undefined && v !== '')
195
+ present.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
196
+ const content = `{${present.map(([k, v]) => `${JSON.stringify(k)}:${JSON.stringify(v)}`).join(',')}}`
197
+ return {
198
+ pubkey,
199
+ created_at: toNostrSeconds(createdAtMs),
200
+ kind: KIND.PROFILE,
201
+ tags: [],
202
+ content,
203
+ }
204
+ }
205
+
206
+ /** Who a pubkey belongs to, as far as the relay knows. */
207
+ export interface Profile {
208
+ /** kind:0 `display_name` or `name`. Absent when nobody has published one. */
209
+ displayName?: string
210
+ /** kind:0 `picture` — an avatar URL chosen by whichever app published it. */
211
+ picture?: string
212
+ }
213
+
214
+ /**
215
+ * The inverse of `buildProfile`: a kind:0 event to the fields an app shows.
216
+ *
217
+ * Both name keys are read because both are written: Peek publishes
218
+ * `display_name`, Ship publishes `name`, and a person who has used both apps
219
+ * should resolve either way rather than on a coin flip. Anything that will not
220
+ * parse costs a name, never the read that asked for it.
221
+ *
222
+ * One parser is what keeps a person from having two names depending on which
223
+ * app they appear in — which is the whole reason this is not left to each
224
+ * consumer.
225
+ */
226
+ export function parseProfile(event: { content: string } | undefined): Profile {
227
+ if (!event) return {}
228
+ try {
229
+ const meta = JSON.parse(event.content)
230
+ const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined)
231
+ return { displayName: str(meta.display_name) ?? str(meta.name), picture: str(meta.picture) }
232
+ } catch {
233
+ return {}
234
+ }
235
+ }
236
+
237
+ export type ChannelVisibility = 'open' | 'private'
238
+ export type ChannelKind = 'stream' | 'forum' | 'dm'
239
+
240
+ /**
241
+ * kind:9007 create channel — mirrors `build_create_channel` (builders.rs:674).
242
+ * Tag order is fixed: h, name, [visibility], [channel_type], [about], [ttl].
243
+ */
244
+ export function buildCreateChannel(
245
+ pubkey: string,
246
+ createdAtMs: number,
247
+ args: {
248
+ channelUuid: string
249
+ name: string
250
+ visibility?: ChannelVisibility
251
+ channelType?: ChannelKind
252
+ about?: string
253
+ ttlSeconds?: number
254
+ },
255
+ ): UnsignedEvent {
256
+ assertHex64(pubkey, 'pubkey')
257
+ const name = canonicalChannelName(args.name)
258
+ if (name.trim() === '') throw new Error('channel name is required')
259
+ const tags: NostrTag[] = [
260
+ ['h', args.channelUuid],
261
+ ['name', name],
262
+ ]
263
+ if (args.visibility) tags.push(['visibility', args.visibility])
264
+ if (args.channelType) tags.push(['channel_type', args.channelType])
265
+ if (args.about) tags.push(['about', args.about])
266
+ if (args.ttlSeconds !== undefined) tags.push(['ttl', String(args.ttlSeconds)])
267
+ return {
268
+ pubkey,
269
+ created_at: toNostrSeconds(createdAtMs),
270
+ kind: KIND.NIP29_CREATE_GROUP,
271
+ tags,
272
+ content: '',
273
+ }
274
+ }
275
+
276
+ /**
277
+ * kind:9008 NIP-29 delete-group — what makes a deleted container actually gone.
278
+ *
279
+ * Deleting a topic in Peek used to remove it and its messages from Convex and
280
+ * publish nothing at all. Create propagated as a 9007; delete propagated
281
+ * nowhere. So a conversation somebody deleted, believing it gone, stayed
282
+ * readable to every admitted member indefinitely — measured on production at 145
283
+ * messages across 37 channels with zero deletion requests against any of them
284
+ * (PEEK-170).
285
+ *
286
+ * **9008 rather than a kind:5 per message**, and the difference is capability
287
+ * rather than efficiency. Buzz rejects a multi-target kind:5 outright, and
288
+ * NIP-09 is honoured only for the key that signed the original — so
289
+ * per-message deletion could never reach anybody else's messages in the
290
+ * container. Delete-group soft-deletes the channel, and Buzz's read guards
291
+ * (`c.deleted_at IS NULL` in both `get_accessible_channels` and `is_member`)
292
+ * then hide the whole container regardless of who wrote what is in it.
293
+ *
294
+ * Requires the channel's **owner** — Buzz enforces "only owner can delete
295
+ * group" in `validate_admin_event`, and a container's creator becomes its owner
296
+ * at create. Anybody else is refused, which the caller reports rather than hides.
297
+ */
298
+ export function buildDeleteChannel(
299
+ pubkey: string,
300
+ createdAtMs: number,
301
+ args: { channelUuid: string },
302
+ ): UnsignedEvent {
303
+ assertHex64(pubkey, 'pubkey')
304
+ return {
305
+ pubkey,
306
+ created_at: toNostrSeconds(createdAtMs),
307
+ kind: KIND.NIP29_DELETE_GROUP,
308
+ tags: [['h', args.channelUuid]],
309
+ content: '',
310
+ }
311
+ }
312
+
313
+ /**
314
+ * kind:9002 edit metadata — renaming a channel (PEE-2).
315
+ *
316
+ * Buzz applies each recognised tag it finds (`name`, `about`, `archived`,
317
+ * `topic`, `purpose`, `visibility`, `ttl`) and refuses an event carrying none
318
+ * of them, so this builds exactly the fields asked for and nothing else.
319
+ *
320
+ * The name goes through `canonicalChannelName` for the same reason
321
+ * `buildCreateChannel` does: Buzz canonicalises before storing, so sending the
322
+ * raw string means the app and the relay disagree about what the container is
323
+ * called. A name that canonicalises away to nothing is not a rename, and
324
+ * throwing here is better than a round trip to be told so.
325
+ *
326
+ * Requires the channel's owner or admin — the relay refuses anyone else.
327
+ */
328
+ export function buildEditChannelMetadata(
329
+ pubkey: string,
330
+ createdAtMs: number,
331
+ args: { channelUuid: string; name?: string; about?: string },
332
+ ): UnsignedEvent {
333
+ assertHex64(pubkey, 'pubkey')
334
+ const tags: NostrTag[] = [['h', args.channelUuid]]
335
+ if (args.name !== undefined) {
336
+ const name = canonicalChannelName(args.name)
337
+ if (name.trim() === '') throw new Error('channel name is required')
338
+ tags.push(['name', name])
339
+ }
340
+ if (args.about !== undefined) tags.push(['about', args.about])
341
+ if (tags.length === 1) throw new Error('nothing to edit')
342
+ return {
343
+ pubkey,
344
+ created_at: toNostrSeconds(createdAtMs),
345
+ kind: KIND.NIP29_EDIT_METADATA,
346
+ tags,
347
+ content: '',
348
+ }
349
+ }
350
+
351
+ export type MemberRole = 'owner' | 'admin' | 'member'
352
+
353
+ /**
354
+ * kind:9000 add member — mirrors `build_add_member` (builders.rs:565).
355
+ * Tag order: h, p, [role]. The target pubkey is lowercased by Buzz.
356
+ */
357
+ export function buildAddMember(
358
+ pubkey: string,
359
+ createdAtMs: number,
360
+ args: { channelUuid: string; targetPubkey: string; role?: MemberRole },
361
+ ): UnsignedEvent {
362
+ assertHex64(pubkey, 'pubkey')
363
+ const target = args.targetPubkey.toLowerCase()
364
+ assertHex64(target, 'targetPubkey')
365
+ const tags: NostrTag[] = [
366
+ ['h', args.channelUuid],
367
+ ['p', target],
368
+ ]
369
+ if (args.role) tags.push(['role', args.role])
370
+ return {
371
+ pubkey,
372
+ created_at: toNostrSeconds(createdAtMs),
373
+ kind: KIND.NIP29_PUT_USER,
374
+ tags,
375
+ content: '',
376
+ }
377
+ }
378
+
379
+ /**
380
+ * kind:7 reaction — mirrors `build_reaction` (builders.rs:463).
381
+ *
382
+ * Note there is **no `h` tag**: Buzz derives the channel from the *target's*
383
+ * `#e` tag and explicitly ignores a client-supplied `#h` (`NOSTR.md`). Adding one
384
+ * would change the event id for no benefit.
385
+ */
386
+ export function buildReaction(
387
+ pubkey: string,
388
+ createdAtMs: number,
389
+ args: { targetEventId: string; emoji: string },
390
+ ): UnsignedEvent {
391
+ assertHex64(pubkey, 'pubkey')
392
+ assertHex64(args.targetEventId, 'targetEventId')
393
+ if ([...args.emoji].length > MAX_EMOJI_CHARS) {
394
+ throw new Error(`emoji longer than ${MAX_EMOJI_CHARS} chars`)
395
+ }
396
+ return {
397
+ pubkey,
398
+ created_at: toNostrSeconds(createdAtMs),
399
+ kind: KIND.REACTION,
400
+ tags: [['e', args.targetEventId]],
401
+ content: args.emoji,
402
+ }
403
+ }
404
+
405
+ /**
406
+ * kind:5 deletion — mirrors `build_remove_reaction` (builders.rs:495).
407
+ *
408
+ * Buzz accepts self-authored deletions (and an owner deleting their agent's).
409
+ * NIP-09 is a *request*, author-scoped, and it removes the record rather than
410
+ * the work — so an app must hide the control from non-authors rather than offer
411
+ * one that silently fails (SPEC §6.5).
412
+ */
413
+ export function buildDeletion(
414
+ pubkey: string,
415
+ createdAtMs: number,
416
+ args: { targetEventId: string },
417
+ ): UnsignedEvent {
418
+ assertHex64(pubkey, 'pubkey')
419
+ assertHex64(args.targetEventId, 'targetEventId')
420
+ return {
421
+ pubkey,
422
+ created_at: toNostrSeconds(createdAtMs),
423
+ kind: KIND.DELETION,
424
+ tags: [['e', args.targetEventId]],
425
+ content: '',
426
+ }
427
+ }
428
+
429
+ /**
430
+ * NIP-10 reply context.
431
+ *
432
+ * Mirrors Buzz's `ThreadRef` + `thread_tags` (builders.rs:173), which has a
433
+ * detail that is easy to get wrong: for a **direct** reply (parent is the root)
434
+ * Buzz emits a SINGLE tag marked `"reply"` — not a `"root"` tag. Only a nested
435
+ * reply emits both. Getting this wrong changes the event id and, worse, produces
436
+ * threads Buzz's clients read differently.
437
+ */
438
+ export interface ThreadRef {
439
+ /** Thread root event id (64-hex). */
440
+ rootId: string
441
+ /** Direct parent event id. Equal to `rootId` for a direct reply. */
442
+ parentId: string
443
+ }
444
+
445
+ /** Exactly Buzz's `thread_tags` (builders.rs:173). */
446
+ export function threadTags(ref: ThreadRef): NostrTag[] {
447
+ if (ref.rootId === ref.parentId) {
448
+ return [['e', ref.rootId, '', 'reply']]
449
+ }
450
+ return [
451
+ ['e', ref.rootId, '', 'root'],
452
+ ['e', ref.parentId, '', 'reply'],
453
+ ]
454
+ }
455
+
456
+ /**
457
+ * kind:9 stream message — mirrors `build_message` (builders.rs:219).
458
+ * Tag order: h, [thread tags], [a about], [p mentions], [broadcast], [imeta].
459
+ *
460
+ * **This is the shape verified byte-identical against Buzz's SDK** — see Peek's
461
+ * docs/buzz-compat/INTEROP_PROOF.md §4.
462
+ *
463
+ * **`about` is where the two copies had drifted.** Peek grew it so a pasted
464
+ * NIP-19 pointer could carry its address as an `a` tag and Ship could route the
465
+ * conversation to the issue directly; Ship's copy never received it and could
466
+ * not emit one at all. Peek's shape is what ships. Omitting `about` produces
467
+ * byte-identical output to Ship's old builder — pinned by
468
+ * `test/wire-vectors.test.ts`, because "the change is a no-op for Ship" is
469
+ * exactly the kind of claim that deserves a fixture rather than a sentence.
470
+ */
471
+ export function buildMessage(
472
+ pubkey: string,
473
+ createdAtMs: number,
474
+ args: {
475
+ channelUuid: string
476
+ content: string
477
+ threadRef?: ThreadRef
478
+ /** Addressable objects this conversation concerns (for cross-app routing). */
479
+ about?: string[]
480
+ /** Mentioned pubkeys (64-hex). Deduplicated, lowercased, capped at 50. */
481
+ mentions?: string[]
482
+ broadcast?: boolean
483
+ /** Raw `imeta` tag vectors for media attachments. */
484
+ mediaTags?: NostrTag[]
485
+ },
486
+ ): UnsignedEvent {
487
+ assertHex64(pubkey, 'pubkey')
488
+ const bytes = utf8ToBytes(args.content).length
489
+ if (bytes > MAX_MESSAGE_BYTES) {
490
+ throw new Error(`content is ${bytes} bytes, max ${MAX_MESSAGE_BYTES}`)
491
+ }
492
+
493
+ const tags: NostrTag[] = [['h', args.channelUuid]]
494
+
495
+ if (args.threadRef) tags.push(...threadTags(args.threadRef))
496
+
497
+ // Keep a pasted NIP-19 pointer in content for portable display, and carry
498
+ // its address here so a consumer can route the conversation to the object.
499
+ for (const address of [...new Set(args.about ?? [])]) tags.push(['a', address])
500
+
501
+ if (args.mentions && args.mentions.length > 0) {
502
+ if (args.mentions.length > MAX_MENTIONS) {
503
+ throw new Error(`too many mentions: ${args.mentions.length} > ${MAX_MENTIONS}`)
504
+ }
505
+ const seen = new Set<string>()
506
+ for (const hex of args.mentions) {
507
+ const lower = hex.toLowerCase()
508
+ assertHex64(lower, 'mention pubkey')
509
+ if (!seen.has(lower)) {
510
+ seen.add(lower)
511
+ tags.push(['p', lower])
512
+ }
513
+ }
514
+ }
515
+
516
+ if (args.broadcast) tags.push(['broadcast', '1'])
517
+ for (const mt of args.mediaTags ?? []) tags.push(mt)
518
+
519
+ return {
520
+ pubkey,
521
+ created_at: toNostrSeconds(createdAtMs),
522
+ kind: KIND.STREAM_MESSAGE,
523
+ tags,
524
+ content: args.content,
525
+ }
526
+ }
527
+
528
+ /** Assertion subtypes. Only `resolution` exists today (PEEK-128). */
529
+ export const ASSERTION_SUBTYPE = { RESOLUTION: 'resolution' } as const
530
+
531
+ /** What a resolution assertion says happened. */
532
+ export type ResolutionAction = 'resolved' | 'reopened'
533
+
534
+ /** Rationale cap — the same 64 KiB ceiling a message content carries. */
535
+ export const MAX_RATIONALE_BYTES = MAX_MESSAGE_BYTES
536
+
537
+ /**
538
+ * kind:9101 resolution assertion — "this thread is resolved", said on the wire
539
+ * so any Estiva app reading the channel can see it (PEEK-128).
540
+ *
541
+ * **Append-only.** A reopen is another assertion, never a deletion of the
542
+ * resolve that preceded it; current state is folded from the ordered run, not
543
+ * read off a single canonical event. That is why this is a regular kind rather
544
+ * than a replaceable one — see `KIND.ASSERTION`.
545
+ *
546
+ * The event carries the claim and who made it. It does **not** carry authority:
547
+ * there is no "proposed" or "endorsed" mode here, because how much weight a
548
+ * given actor's assertion deserves is a policy question that belongs to the app
549
+ * reading it, not to the protocol. That is also why the *fold* of these
550
+ * assertions is not in this package — see the README on the line this package
551
+ * does not cross.
552
+ *
553
+ * Tag order is fixed so the event id is reproducible: h, e(target), t, action,
554
+ * [e(support)].
555
+ */
556
+ export function buildResolution(
557
+ pubkey: string,
558
+ createdAtMs: number,
559
+ args: {
560
+ channelUuid: string
561
+ /** Event id of the message whose resolution state this asserts. */
562
+ targetEventId: string
563
+ action: ResolutionAction
564
+ /** Optional reply that carried the resolution, for readers that want it. */
565
+ supportingEventId?: string
566
+ /** Optional free text. */
567
+ rationale?: string
568
+ },
569
+ ): UnsignedEvent {
570
+ assertHex64(pubkey, 'pubkey')
571
+ assertHex64(args.targetEventId, 'targetEventId')
572
+ if (args.supportingEventId) assertHex64(args.supportingEventId, 'supportingEventId')
573
+ if (args.action !== 'resolved' && args.action !== 'reopened') {
574
+ throw new Error(`unknown resolution action: ${String(args.action)}`)
575
+ }
576
+ const rationale = args.rationale ?? ''
577
+ const bytes = utf8ToBytes(rationale).length
578
+ if (bytes > MAX_RATIONALE_BYTES) {
579
+ throw new Error(`rationale is ${bytes} bytes, max ${MAX_RATIONALE_BYTES}`)
580
+ }
581
+
582
+ const tags: NostrTag[] = [
583
+ ['h', args.channelUuid],
584
+ ['e', args.targetEventId],
585
+ ['t', ASSERTION_SUBTYPE.RESOLUTION],
586
+ ['action', args.action],
587
+ ]
588
+ // Marked so a reader can tell the supporting reply from the target, which
589
+ // share the `e` tag name.
590
+ if (args.supportingEventId) tags.push(['e', args.supportingEventId, '', 'support'])
591
+
592
+ return {
593
+ pubkey,
594
+ created_at: toNostrSeconds(createdAtMs),
595
+ kind: KIND.ASSERTION,
596
+ tags,
597
+ content: rationale,
598
+ }
599
+ }
600
+
601
+ /** NIP-32 self-label: a namespace and a value, both indexable. */
602
+ export interface Label {
603
+ /** Namespace, e.g. "nfb.highlight". */
604
+ namespace: string
605
+ /** Value within that namespace, e.g. "insight". */
606
+ value: string
607
+ }
608
+
609
+ /**
610
+ * kind:30840 File — NIP-FC.
611
+ *
612
+ * `componentDTags` are listed **in document order**; order lives on the File so
613
+ * reordering is a single edit in one place.
614
+ */
615
+ export function buildFile(
616
+ pubkey: string,
617
+ createdAtMs: number,
618
+ args: {
619
+ /** Stable File id. MUST NOT encode the relay or org — Files are portable. */
620
+ fileId: string
621
+ title: string
622
+ componentDTags: string[]
623
+ /** Channel scope. A File in a private channel inherits its access rules. */
624
+ channelUuid?: string
625
+ /** File-level metadata; shape is the app's business. */
626
+ metadata?: Record<string, unknown>
627
+ },
628
+ ): UnsignedEvent {
629
+ assertHex64(pubkey, 'pubkey')
630
+ const tags: NostrTag[] = [
631
+ ['d', args.fileId],
632
+ ['title', args.title],
633
+ ]
634
+ if (args.channelUuid) tags.push(['h', args.channelUuid])
635
+ for (const d of args.componentDTags) {
636
+ tags.push(['a', addr(KIND.COMPONENT, pubkey, d)])
637
+ }
638
+ return {
639
+ pubkey,
640
+ created_at: toNostrSeconds(createdAtMs),
641
+ kind: KIND.FILE,
642
+ tags,
643
+ content: args.metadata ? JSON.stringify(args.metadata) : '',
644
+ }
645
+ }
646
+
647
+ /**
648
+ * kind:30841 Component — NIP-FC.
649
+ *
650
+ * `type` must be namespaced `<namespace>/<name>`. The protocol defines the
651
+ * container; the payload shape belongs to the type.
652
+ */
653
+ export function buildComponent(
654
+ pubkey: string,
655
+ createdAtMs: number,
656
+ args: {
657
+ componentId: string
658
+ /** Parent File's `d` tag. */
659
+ fileId: string
660
+ /** Namespaced, e.g. `nfb/todo`. */
661
+ type: string
662
+ payload: Record<string, unknown>
663
+ channelUuid?: string
664
+ labels?: Label[]
665
+ },
666
+ ): UnsignedEvent {
667
+ assertHex64(pubkey, 'pubkey')
668
+ if (!args.type.includes('/')) {
669
+ throw new Error(`component type must be namespaced "<namespace>/<name>", got "${args.type}"`)
670
+ }
671
+ const tags: NostrTag[] = [
672
+ ['d', args.componentId],
673
+ ['a', addr(KIND.FILE, pubkey, args.fileId)],
674
+ ['type', args.type],
675
+ ]
676
+ if (args.channelUuid) tags.push(['h', args.channelUuid])
677
+ for (const label of args.labels ?? []) {
678
+ tags.push(['L', label.namespace])
679
+ tags.push(['l', label.value, label.namespace])
680
+ }
681
+ return {
682
+ pubkey,
683
+ created_at: toNostrSeconds(createdAtMs),
684
+ kind: KIND.COMPONENT,
685
+ tags,
686
+ content: JSON.stringify(args.payload),
687
+ }
688
+ }
689
+
690
+ /**
691
+ * kind:9802 highlight — NIP-84.
692
+ *
693
+ * `.content` is the excerpt itself. `e`/`a` tags point at a source event, `r` at
694
+ * a URL; `p` attributes the original author.
695
+ *
696
+ * Optional NIP-32 `L`/`l` self-labels categorise the highlight. NIP-32 §52
697
+ * allows those tags on non-1985 events for exactly this ("self-reporting"), so
698
+ * a highlight can say *what kind* of highlight it is without a bespoke kind.
699
+ */
700
+ export function buildHighlight(
701
+ pubkey: string,
702
+ createdAtMs: number,
703
+ args: {
704
+ /** The excerpt. */
705
+ content: string
706
+ /** Channel to post into (Buzz scopes by `h`). */
707
+ channelUuid?: string
708
+ /** Source event being highlighted. */
709
+ sourceEventId?: string
710
+ /** Source URL, when the highlight came from outside Nostr. */
711
+ sourceUrl?: string
712
+ /** Original author(s) of the highlighted material. */
713
+ attribution?: string[]
714
+ labels?: Label[]
715
+ },
716
+ ): UnsignedEvent {
717
+ assertHex64(pubkey, 'pubkey')
718
+ const tags: NostrTag[] = []
719
+ if (args.channelUuid) tags.push(['h', args.channelUuid])
720
+ if (args.sourceEventId) {
721
+ assertHex64(args.sourceEventId, 'sourceEventId')
722
+ tags.push(['e', args.sourceEventId])
723
+ }
724
+ if (args.sourceUrl) tags.push(['r', args.sourceUrl])
725
+ for (const p of args.attribution ?? []) {
726
+ const lower = p.toLowerCase()
727
+ assertHex64(lower, 'attribution pubkey')
728
+ tags.push(['p', lower, '', 'author'])
729
+ }
730
+ // NIP-32: the `L` namespace declaration precedes its `l` values.
731
+ for (const label of args.labels ?? []) {
732
+ tags.push(['L', label.namespace])
733
+ tags.push(['l', label.value, label.namespace])
734
+ }
735
+ return {
736
+ pubkey,
737
+ created_at: toNostrSeconds(createdAtMs),
738
+ kind: KIND.HIGHLIGHT,
739
+ tags,
740
+ content: args.content,
741
+ }
742
+ }
743
+
744
+ /**
745
+ * The relay's clock tolerance for a NIP-42 AUTH event, in seconds.
746
+ *
747
+ * `TIMESTAMP_TOLERANCE_SECS` in `crates/buzz-auth/src/nip42.rs` — the same ±60s
748
+ * NIP-98 uses, checked against the *relay's* clock. It is the one failure here
749
+ * a correct client can still hit: a browser whose clock is more than a minute
750
+ * out signs a perfectly valid event that is refused every time, and the socket
751
+ * is left open and permanently unauthenticated rather than closed.
752
+ */
753
+ export const RELAY_AUTH_TOLERANCE_SECS = 60
754
+
755
+ /**
756
+ * The relay URL a NIP-42 `relay` tag must carry, as Buzz computes it.
757
+ *
758
+ * `nip42_expected_relay_url` (`buzz-relay/src/api/bridge.rs:225`) is literally
759
+ * `format!("{scheme}://{}", tenant.host())` — scheme from the deployment, host
760
+ * from **the tenant the connection arrived on**, never the deployment-wide
761
+ * `config.relay_url`. Buzz has a test asserting exactly that
762
+ * (`nip42_expected_relay_url_uses_tenant_host_not_config_host`), because it
763
+ * regressed once.
764
+ *
765
+ * So: an origin, with no path and no trailing slash, derived from the URL we
766
+ * actually connected to. `normalize_relay_url` on the relay side would forgive
767
+ * a trailing slash, but it would not forgive a path or a different host.
768
+ */
769
+ export function relayAuthUrl(connectUrl: string): string {
770
+ return new URL(connectUrl).origin
771
+ }
772
+
773
+ /**
774
+ * `URL` is not in `lib.es2022`, and this package compiles with `types: []` and
775
+ * no `lib: dom` so that one published `.d.ts` works in Peek's Convex tree,
776
+ * Peek's browser bundle and the agent's `tsx` run (ADR 0002 §4a).
777
+ *
778
+ * Declared **inside this module**, so nothing is added to the global scope of
779
+ * any consumer, and read **inside a function body**, so importing this module
780
+ * touches no global at all. Both matter: an eager `const C = URL` at module
781
+ * scope would throw on import in a runtime that lacks it, which is the failure
782
+ * `test/runtime-agnostic.test.ts` exists to catch.
783
+ */
784
+ declare const URL: { new (raw: string): { origin: string } }
785
+
786
+ /**
787
+ * An unsigned kind:22242 answering a relay's AUTH challenge.
788
+ *
789
+ * Built here rather than at a call site because the tag layout is part of the
790
+ * event id preimage, and a second copy would be a silent divergence the relay
791
+ * notices and we do not.
792
+ *
793
+ * **Tag order is not load-bearing for this one kind**, unusually for this file.
794
+ * Buzz looks both tags up by name — `tags.find(TagKind::Challenge)` and
795
+ * `tags.find(TagKind::Relay)` in `verify_nip42_event` — and the two reference
796
+ * clients disagree anyway: rust-nostr's `EventBuilder::auth` emits challenge
797
+ * first, nostr-tools' `makeAuthEvent` emits relay first. NIP-42's own example
798
+ * uses relay-then-challenge, which is what this follows. Nothing downstream
799
+ * compares this event's id to anything, because nothing ever stores it.
800
+ */
801
+ export function buildUnsignedRelayAuthEvent(args: {
802
+ /** Left empty for `/sign`, which overwrites it with the token's subject. */
803
+ pubkey: string
804
+ /** The URL the socket connected to. Reduced to an origin — see above. */
805
+ relayUrl: string
806
+ /** The challenge exactly as the relay sent it. Compared byte for byte. */
807
+ challenge: string
808
+ /** Override for tests; defaults to now. `Date.now` is in `lib.es2022`. */
809
+ nowMs?: number
810
+ }): UnsignedEvent {
811
+ return {
812
+ pubkey: args.pubkey,
813
+ created_at: Math.floor((args.nowMs ?? Date.now()) / 1000),
814
+ kind: KIND.RELAY_AUTH,
815
+ tags: [
816
+ ['relay', relayAuthUrl(args.relayUrl)],
817
+ ['challenge', args.challenge],
818
+ ],
819
+ content: '',
820
+ }
821
+ }