mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* Add geohash bulletin board: persistent signed notices over mesh sync
New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.
Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.
UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Board: bound orphan tombstones and reject future-dated posts at ingest
Two hardening fixes from Codex review of the geohash bulletin board:
- Orphan tombstones (P1): retention was derived solely from the
sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
with far-future deletedAt persisted and re-entered sync unboundedly.
Retention is now also clamped to receive time (now + 7d + 1h skew --
no post can outlive that), and orphans are capped at 100 globally and
5 per author key with oldest-received evicted first. Matched
tombstones and disk restores keep their existing behavior.
- Future-dated posts (P2): ingest only checked expiresAt > now, letting
posts dated years ahead sort above honest posts and squat the 200
global slots without ever pruning. The single ingest chokepoint
(radio, sync, and disk restore all funnel through it) now rejects
createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
decoder's span rule is unchanged.
Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
79 lines
3.2 KiB
Swift
79 lines
3.2 KiB
Swift
//
|
|
// SyncTypeFlagsBoardTests.swift
|
|
// bitchatTests
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
/// The board sync flag is the first bit outside the original single byte of
|
|
/// type flags. These tests pin down the wire compatibility contract: the
|
|
/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
|
|
/// since type-aware sync, so widening to two bytes must decode everywhere
|
|
/// and unknown bits must be ignored, not rejected.
|
|
struct SyncTypeFlagsBoardTests {
|
|
|
|
@Test func boardFlagEncodesIntoSecondByte() throws {
|
|
let data = try #require(SyncTypeFlags.board.toData())
|
|
// Little-endian: low byte first, board bit (bit 8) in byte 2.
|
|
#expect(data == Data([0x00, 0x01]))
|
|
}
|
|
|
|
@Test func boardFlagRoundTrips() throws {
|
|
let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
|
|
let data = try #require(flags.toData())
|
|
let decoded = try #require(SyncTypeFlags.decode(data))
|
|
#expect(decoded.contains(.message))
|
|
#expect(decoded.contains(.boardPost))
|
|
#expect(!decoded.contains(.fragment))
|
|
#expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
|
|
}
|
|
|
|
/// An old decoder is modeled by bits it has no mapping for: the shared
|
|
/// decode path accepts the bytes and simply maps unknown bits to no
|
|
/// message type, so a board-only request reads as "nothing I can serve".
|
|
@Test func unknownBitsDecodeToNoTypes() throws {
|
|
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
|
|
// must decode without error and yield no known types.
|
|
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
|
|
#expect(decoded.toMessageTypes().isEmpty)
|
|
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
|
#expect(!decoded.contains(type))
|
|
}
|
|
}
|
|
|
|
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
|
// Known low-byte flags survive alongside unknown high bits.
|
|
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
|
|
#expect(decoded.contains(.announce))
|
|
#expect(decoded.contains(.message))
|
|
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
|
}
|
|
|
|
@Test func requestSyncPacketRoundTripsBoardFlag() throws {
|
|
let request = RequestSyncPacket(
|
|
p: 4,
|
|
m: 128,
|
|
data: Data([0xAB, 0xCD]),
|
|
types: SyncTypeFlags(messageTypes: [.boardPost])
|
|
)
|
|
let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
|
|
let types = try #require(decoded.types)
|
|
#expect(types.contains(.boardPost))
|
|
#expect(!types.contains(.message))
|
|
}
|
|
|
|
@Test func singleByteLegacyEncodingStillDecodes() throws {
|
|
// Requests from old clients keep the one-byte bitfield.
|
|
let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
|
|
#expect(decoded.contains(.announce))
|
|
#expect(decoded.contains(.message))
|
|
#expect(!decoded.contains(.boardPost))
|
|
}
|
|
}
|