Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
- name: Tauri system libraries
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libwebkit2gtk-4.1-dev build-essential libssl-dev librsvg2-dev libayatana-appindicator3-dev patchelf
sudo apt-get install --no-install-recommends -y dbus-daemon libwebkit2gtk-4.1-dev build-essential libssl-dev librsvg2-dev libayatana-appindicator3-dev patchelf
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: native
Expand All @@ -61,6 +61,34 @@ jobs:
- name: All Node integration tests
run: node --test tests/integration/*.test.mjs

windows-native:
name: Windows native notifications
runs-on: windows-2025
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
# Hermit does not run on Windows. Use the same repository Rust pin with
# the hosted runner's standard rustup, not a second floating toolchain.
- name: Select pinned Rust
shell: pwsh
run: |
$pins = @(Get-ChildItem bin/.rust-*.pkg)
if ($pins.Count -ne 1) { throw "Expected exactly one repository Rust pin" }
$version = $pins[0].Name -replace '^\.rust-(.*)\.pkg$', '$1'
rustup toolchain install $version --profile minimal --component clippy
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
"RUSTUP_TOOLCHAIN=$version" >> $env:GITHUB_ENV
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-native
save-if: ${{ github.event_name == 'push' }}
- name: Native lint including Windows backend
run: cargo clippy -p buzz-foundation --locked --all-targets -- -D warnings
- name: All native package tests
run: cargo test -p buzz-foundation --locked

measurements:
name: Browser measurements
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -127,17 +155,18 @@ jobs:
required:
name: CI required
if: always()
needs: [javascript, native, measurements, browser]
needs: [javascript, native, windows-native, measurements, browser]
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
- name: Require every lane and every shard
env:
JAVASCRIPT: ${{ needs.javascript.result }}
NATIVE: ${{ needs.native.result }}
WINDOWS_NATIVE: ${{ needs.windows-native.result }}
MEASUREMENTS: ${{ needs.measurements.result }}
BROWSER: ${{ needs.browser.result }}
run: |
for result in "$JAVASCRIPT" "$NATIVE" "$MEASUREMENTS" "$BROWSER"; do
for result in "$JAVASCRIPT" "$NATIVE" "$WINDOWS_NATIVE" "$MEASUREMENTS" "$BROWSER"; do
test "$result" = success || exit 1
done
30 changes: 30 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 116 additions & 1 deletion dev/read-state-broker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip44,
verifyEvent,
} from "nostr-tools";
import { relayBrokerPlugin } from "./relay-broker.mjs";
import { connectBrokerTransport } from "../src/features/relay/transport.ts";
import { createRelayReader } from "../src/features/relay/reader.ts";
import { createReadState } from "../src/features/relay/read-state.ts";
import { readJournal } from "../src/features/relay/read-state-storage.ts";
import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts";

const disposals = [];
Expand Down Expand Up @@ -75,7 +78,9 @@ async function harness(discovered = true) {
events: published ? [published] : [],
},
);
return Response.json(published ? [published] : []);
return Response.json(
Array.isArray(response) ? response : published ? [published] : [],
);
},
});
await plugin.configureServer({
Expand Down Expand Up @@ -150,6 +155,116 @@ it("real broker discovery -> reader snapshot and scoped encrypted signing/public
{ kinds: [30078], authors: [h.viewer], read_state_snapshot: 1 },
]);
});
it.each([false, true])(
"reconciles legacy maximum-size NIP-44 records without widening writes (snapshot: %s)",
async (discovered) => {
const h = await harness(discovered);
const blob = {
v: 1,
client_id: "legacy",
contexts: Object.fromEntries(
Array.from({ length: 650 }, (_, i) => [
`msg:${i.toString(16).padStart(64, "0")}`,
1786662839,
]),
),
};
const key = nip44.v2.utils.getConversationKey(h.key, h.viewer);
const expected = { ...blob.contexts };
const events = Array.from({ length: 5 }, (_, i) => {
const contexts = { ...blob.contexts, [`slot-${i}`]: 1786662839 + i };
Object.assign(expected, contexts);
// Pad valid JSON to the original NIP-44 maximum, with unique evidence
// per slot so dropping a record or the second batch cannot pass.
const plaintext = JSON.stringify({ ...blob, contexts }).padEnd(
65535,
" ",
);
expect(Buffer.byteLength(plaintext)).toBe(65535);
return finalizeEvent(
{
kind: 30078,
created_at: 1786662839,
tags: [
["d", `read-state:${i.toString(16).padStart(32, "0")}`],
["t", "read-state"],
],
content: nip44.v2.encrypt(plaintext, key),
},
h.key,
);
});
key.fill(0);
expect(events[0].content.length).toBe(87472);
expect(Buffer.byteLength(JSON.stringify(events[0]))).toBe(87888);
h.reply(discovered ? h.envelope(events) : events);
let journal;
const owner = createReadState({
viewer: h.viewer,
reader: h.reader,
host: h.transport.readState,
storage: {
async update(change) {
journal = readJournal(change(journal), h.viewer);
return journal;
},
close() {},
},
});
disposals.push(() => owner.dispose());
await owner.refresh();
expect(owner.snapshot()).toMatchObject({
status: "reconciled",
completeness: discovered ? "snapshot" : "bounded",
});
expect(journal.state.frontiers).toEqual(expected);
expect(owner.state().frontiers).toEqual(expected);
// The receive exception is not permission to sign or republish large records.
expect(
(
await h.post("read-state-sign", {
slot: "a".repeat(32),
createdAt: Math.floor(Date.now() / 1000),
blob,
})
).status,
).toBe(400);
expect((await h.post("read-state-publish", events[0])).status).toBe(413);
// A Unicode envelope can fit the HTTP character cap yet exceed the strict
// publication byte cap. Exercise the actual publish validator, not only HTTP.
const small = await h.transport.readState.sign(
{
slot: "a".repeat(32),
createdAt: Math.floor(Date.now() / 1000),
blob: { v: 1, client_id: "fixture", contexts: { room: 12 } },
},
new AbortController().signal,
);
const unicodeEnvelope = finalizeEvent(
{
...small,
tags: [
...small.tags,
["padding", "界".repeat(10000) + "x".repeat(40000)],
],
},
h.key,
);
expect(JSON.stringify(unicodeEnvelope).length).toBeLessThan(65536);
expect(Buffer.byteLength(JSON.stringify(unicodeEnvelope))).toBeGreaterThan(
65536,
);
expect((await h.post("read-state-decode", [unicodeEnvelope])).status).toBe(
200,
);
expect((await h.post("read-state-publish", unicodeEnvelope)).status).toBe(
400,
);
expect(h.calls.map((call) => new URL(call.url).pathname)).toEqual([
"/query",
]);
},
);
it("absent discovery does not grant complete enumeration and the broker rejects malformed extension filters before upstream", async () => {
const old = await harness(false);
expect(old.transport.readStateSnapshot).toBeUndefined();
Expand Down
21 changes: 17 additions & 4 deletions dev/read-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,38 @@ import {
} from "../src/features/relay/read-state-model.ts";

export const READ_STATE_DECODE_BYTES = 512 * 1024;
// Original NIP-44 can expand 65,535 plaintext bytes to 87,472 base64 characters.
// Receive old client records without raising our 64 KiB publication event cap.
const READ_STATE_RECEIVE_EVENT_BYTES = 96 * 1024;
/** Validate and copy wire bytes: never trust nostr-tools' cached verification symbol. */
export function validReadStateEvent(raw, secret) {
export function validReadStateEvent(
raw,
secret,
maxBytes = READ_STATE_EVENT_BYTES,
) {
const event = eventDto(raw);
if (
event.pubkey !== getPublicKey(secret) ||
!readCoordinate(event) ||
Buffer.byteLength(JSON.stringify(event)) > READ_STATE_EVENT_BYTES
Buffer.byteLength(JSON.stringify(event)) > maxBytes
)
throw new Error("Invalid read-state event");
return event;
}
export function decodeReadState(events, secret) {
export function decodeReadState(
events,
secret,
maxEventBytes = READ_STATE_RECEIVE_EVENT_BYTES,
) {
if (
!Array.isArray(events) ||
events.length > 16 ||
Buffer.byteLength(JSON.stringify(events)) > READ_STATE_DECODE_BYTES
)
throw new Error("Read-state decode capacity exceeded");
const verified = events.map((event) => validReadStateEvent(event, secret));
const verified = events.map((event) =>
validReadStateEvent(event, secret, maxEventBytes),
);
const key = nip44.v2.utils.getConversationKey(secret, getPublicKey(secret));
try {
return verified.map((event) => {
Expand Down
27 changes: 27 additions & 0 deletions dev/read-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@ describe("host-only read-state codec", () => {
}
key.fill(0);
});
it("keeps explicit receive-event and aggregate bounds while publication validation stays smaller", () => {
const small = signReadState(intent, secret, 100);
const base = { ...small, tags: [...small.tags, ["padding", ""]] };
const remaining = 96 * 1024 - Buffer.byteLength(JSON.stringify(base));
const padded = (length) =>
finalizeEvent(
{ ...small, tags: [...small.tags, ["padding", "x".repeat(length)]] },
secret,
);
const event = padded(remaining);
expect(Buffer.byteLength(JSON.stringify(event))).toBe(96 * 1024);
expect(decodeReadState(Array(4).fill(event), secret)).toEqual(
Array(4).fill({ eventId: event.id, blob }),
);
expect(() => decodeReadState([padded(remaining + 1)], secret)).toThrow(
"Invalid read-state event",
);
expect(() => decodeReadState(Array(6).fill(event), secret)).toThrow(
"capacity",
);
expect(() => validReadStateEvent(event, secret)).toThrow(
"Invalid read-state event",
);
expect(() =>
decodeReadState([{ ...event, content: "changed" }], secret),
).toThrow();
});
it("bounds work before decrypting/signing", () => {
expect(() => decodeReadState(Array(17).fill({}), secret)).toThrow(
"capacity",
Expand Down
9 changes: 6 additions & 3 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
signReadState,
READ_STATE_DECODE_BYTES,
} from "./read-state.mjs";
import { READ_STATE_EVENT_BYTES } from "../src/features/relay/read-state-model.ts";
import {
isReadSnapshotFilter,
readSnapshotText,
Expand Down Expand Up @@ -612,8 +613,9 @@ export function relayBrokerPlugin({
async (event) => finalizeEvent(event, key),
viewer,
{
receive: (events) => {
for (const event of events) write("", event);
receive: (events, provenance) => {
for (const event of events)
write("traffic", { event, provenance });
},
telemetry: (event, generation) => {
if (res.destroyed) return;
Expand Down Expand Up @@ -801,7 +803,8 @@ export function relayBrokerPlugin({
if (readSigning)
return json(res, 200, signReadState(filters, key));
// A valid own signature alone is not permission to publish arbitrary kind-30078 data.
decodeReadState([filters], key);
// Receive-only compatibility must not widen publication admission.
decodeReadState([filters], key, READ_STATE_EVENT_BYTES);
} catch {
return json(res, 400, {
error: "Read-state operation rejected",
Expand Down
Loading
Loading