Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ Site hosting and mirroring are on by default; Marmot and GRASP are off until ena
- [NIP-AD web addresses](docs/23-nip-ad-web-addresses.md): resolve relay, article and site URLs to their Nostr counterparts.
- [GRASP-01 Git hosting](docs/22-grasp-01-git-hosting.md): host bounded NIP-34 repositories through Git Smart HTTP.
- [NIP-86 membership claims](docs/24-nip86-claims.md): create, list and revoke invitation codes through the standard management methods.
- [NIP-9a relay push](docs/25-nip-9a-relay-push.md): opt-in callback delivery, privacy, bounds and operator setup.
- [NIP-11 identifier compatibility](docs/26-nip11-compatibility.md): lettered capabilities and concrete client parser behavior.

### Protocol guides

Expand Down
10 changes: 10 additions & 0 deletions docs/01-relay-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,13 @@ The relay as a thing you hold.
### If I lose my key

Name a member as your heir and a delay of 90, 180 or 365 days of silence. Past it the relay warns you for 30 days, then hands itself to the heir. See [People and groups](03-people-and-groups.md#if-you-lose-your-key).

## Relay push

Relay push is off by default. Under Features, enable it and enter callback
origins approved by your host operator. Members register their own callback
paths using signed kind 30390 events; callback work costs fuel. The
`features.push`, `pushCallbacks` and `letteredNips` settings travel with relay
configurations. Push requires mixed numeric and lettered NIP advertisements,
which some older clients reject. See [NIP-9a relay push](25-nip-9a-relay-push.md)
and [NIP-11 identifier compatibility](26-nip11-compatibility.md).
11 changes: 6 additions & 5 deletions docs/20-nip-5a-static-websites.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,12 @@ site paths to the live manifest under the same site authentication and read
rules, with the hosting relay as its hint. Custom site domains retain their
site origin; discovery does not open the hosting relay's other HTTP doors.

bind.ws does not advertise NIP-5A in `supported_nips`: NIP-11 still defines
that field as integer identifiers, while 5A is a lettered draft and does not
ask relays to advertise it. The relay advertises `nsites` instead. Follow
upstream [pull request 1621](https://github.com/nostr-protocol/nips/pull/1621)
for the eventual representation.
bind.ws advertises `5A` in `supported_nips` when sites are enabled and the
owner opts into lettered NIP identifiers (or enables relay push). Numeric
entries keep their JSON number type. `nsites` remains available while sites
are enabled, including in the default numeric-only mode. Some clients reject
lettered entries; see [NIP-11 identifier compatibility](26-nip11-compatibility.md).
`5A` is a literal identifier, never decimal 90.

The implementation tracks upstream changes to the label grammar, aggregate
hash, snapshot and copy tags, and the status of kind 34128. If those change,
Expand Down
161 changes: 161 additions & 0 deletions docs/25-nip-9a-relay-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
---
title: NIP-9a relay push
audience: developer
---

# NIP-9a relay push

bind.ws implements opt-in relay-to-callback delivery for community and inbox
notifications, Git activity and automation hooks. A callback can bridge to a
mobile notification service. bind.ws does not provide device registration,
FCM, APNs or Web Push infrastructure.

The implementation pins [proposal 2194](https://github.com/nostr-protocol/nips/pull/2194)
to [5a908b8dc5190a46eac47992cba5ba6d8e7fc094](https://github.com/coracle-social/nips/blob/5a908b8dc5190a46eac47992cba5ba6d8e7fc094/9a.md),
checked 2026-09-04. This is an unmerged draft; clients should periodically
resync their registrations and follow proposal changes.

## Enable delivery

The host operator sets `PUSH_CALLBACK_ORIGINS` to a JSON array of trusted,
public HTTPS origins in the selected Wrangler or celld environment. The
shipped value is `"[]"`, so no endpoint receives requests by default:

```json
"PUSH_CALLBACK_ORIGINS": "[\"https://push.example.com\"]"
```

The relay owner enables push and selects origins using the console's
Features settings, `setpolicy`, or a relay configuration:

```json
{
"format": "bind.ws/relay-config/2",
"policy": {
"features": { "push": true },
"pushCallbacks": ["https://push.example.com"]
}
}
```

Both origin lists must authorize a callback. Owners cannot expand the host's
list. Entries are exact HTTPS origins without paths, credentials or wildcard
hosts; callback registrations supply the path and query. Only public DNS
names on port 443 are accepted. IP literals, local names, fragments and
redirects are refused. The host operator must trust the endpoint's operator
and DNS control and keep it publicly routed: the origin allowlist is the
SSRF boundary, not a DNS preflight that could race a later resolution. A host
must not approve arbitrary tenant-controlled origins or callback services
that proxy arbitrary destinations. No credentials or cookies are attached.

The existing templates keep push off. Templates with kind allowlists must
also allow kind `30390` before members can register. Outgoing traffic and
background work use the relay's fuel budget; queue storage is part of its
SQLite storage.

## Register and receive

The owner or a current member authenticates with NIP-42 and publishes a
signed addressable event. The HTTP bridge can instead authenticate the same
author using NIP-98. Ordinary relay write and kind restrictions still apply.

```json
{
"kind": 30390,
"content": "",
"tags": [
["d", "community-inbox"],
["relay", "wss://community.bind.ws/"],
["filter", "{\"kinds\":[1,1111],\"#p\":[\"<recipient pubkey>\"]}"],
["ignore", "{\"#t\":[\"muted-topic\"]}"],
["callback", "https://push.example.com/opaque-secret-token"],
["include_event"]
]
}
```

For Git activity, filters can select accepted repository state or
collaboration events, for example `{"kinds":[30618,1617,1621]}`. Events held
pending Git admission do not trigger delivery until accepted. Filters match
new events only; registration does not backfill history. Multiple `filter`
tags are ORed, and any matching `ignore` suppresses delivery.

The callback receives `POST`, `Content-Type: application/json`:

```json
{
"id": "<event id>",
"relay": "wss://community.bind.ws/",
"event": { "id": "<event id>", "pubkey": "...", "kind": 1 }
}
```

The actual `event` is the complete signed Nostr event and is present only
when the registration contains `include_event`. A receiver should verify
included signatures, treat URL tokens as secrets, and deduplicate by relay,
registration destination and event ID. The payload itself has no separate
relay signature. An ID-only notification still reveals an event's existence,
so it uses the same read authorization as full-event delivery.

## Privacy and revocation

Kind 30390 is author-only regardless of feature state. Authenticated authors
can read their own registrations through REQ, COUNT, NIP-77 and the HTTP
bridge, subject to the relay's current read rule. A `p` tag grants no access.
The relay owner has no special read override for another author's callbacks.
Registrations are excluded from shared dashboard views, dumps, forks and
rebroadcast jobs. Config exports contain origin policy, never registration
callback paths. Clients must export or resync their own registrations.

Every callback attempt rechecks the current registration, membership, bans,
write/read rules, callback approvals and target event visibility. Removing a
member deletes their registrations. Changing callback approval or read/write
policy cancels queued work. Hiding/deleting an event, replacing or
deleting a registration, or disabling push prevents later delivery. An
already dispatched HTTP request cannot be recalled. A `404` removes the
registration. NIP-09 deletions and NIP-62 vanish requests remove it through the
normal event store. The queue holds event and registration IDs, not copies
that could outlive deletion.

## Bounds and retries

Delivery runs from the existing Durable Object alarm, after publication has
returned. Queue persistence precedes external I/O. Delivery is best effort:
finite queues and retries can lose notifications, and ambiguous responses
can produce duplicates. Receivers must not assume exactly-once delivery.

| Limit | Value |
| --- | --- |
| Registrations | 32 per relay, 4 per author, 8 KiB UTF-8 each |
| Filters | 8 matching and 8 ignore filters; standard event fields/tag filters |
| Pending deliveries | 256 references, 24-hour expiry |
| Alarm batch | 4 attempts, 5 seconds per HTTP request |
| Retries | 4 total attempts; 30, 120 and 600 seconds between failures |
| Deduplication | 2,048 terminal outcomes, at most 7 days |
| Event input | Stored JSON up to 1 MiB characters; larger imported/HTTP events are skipped |
| Payload | 4 MiB plus 4 KiB envelope allowance; complete event when included |

Timeouts, network failures, 429 and 5xx responses retry; other non-2xx
responses end that delivery. Redirects are never followed. Attempts are
reserved durably before HTTP starts, so a crash also consumes an attempt.
A `404` deletes the registration. Response bodies are canceled unread.
Queue saturation drops new work instead of delaying event acceptance.
No successful registration promises unlimited delivery or an indefinitely
retained event payload. `include_event` always carries the full event for
accepted deliveries; ID-only callbacks can reduce traffic. `search`, `limit`
and unknown filter fields are rejected rather than silently broadened.

NIP-11 keeps existing identifiers numeric and includes literal `9a` while
push is enabled. The same mode includes enabled `5A` and implemented `AD`.
Some clients reject mixed arrays; see
[NIP-11 identifier compatibility](26-nip11-compatibility.md). Disable push
and `letteredNips` to restore numeric-only advertisement.

## Validation

The workerd object tests use a controlled callback receiver and injected
fetch boundary; they do not POST to public notification services. They cover
registration, asynchronous delivery, authorization changes, privacy across
read/export paths, callback failures, deletion and queue bounds. This proves
the relay payload and lifecycle contract, not interoperability with every
mobile push service. The callback service remains application infrastructure.
73 changes: 73 additions & 0 deletions docs/26-nip11-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: NIP-11 identifier compatibility
audience: developer
---

# NIP-11 identifier compatibility

## Wire contract

bind.ws preserves the existing numeric `supported_nips` array by default.
`policy.letteredNips: true` opts into mixed numbers and lettered strings.
`features.push: true` also activates mixed output because NIP-9a requires
literal `9a` advertisement. Disabling both restores numeric-only output.
Existing numbers never become strings or hexadecimal conversions.

| Capability | Numeric-only mode | Mixed mode |
| --- | --- | --- |
| Existing numeric NIPs | Existing numbers and feature gates | Same numbers and gates |
| NIP-43 | `43` when relay identity exists | Same |
| NIP-5A | `nsites` while sites are enabled | Also `"5A"` while sites are enabled |
| NIP-9a | Push is off | `"9a"` while push is enabled |
| NIP-AD | Discovery endpoint remains available | `"AD"`; homepage discovery has no feature toggle |

Turning off sites removes `5A` and `nsites`, not `AD`: AD's page and site
mappings follow their feature/read gates, while its relay homepage mapping
remains implemented. NIP-AD is present in the main-branch baseline for this advertisement change.
GRASP keeps `supported_grasps`; Marmot and Blossom are separate protocol
families. None becomes an invented decimal NIP. In particular `5A` is not 90.

The [current NIP-11](https://github.com/nostr-protocol/nips/blob/656cecc7c0a815b6a2b218d3b5d6f078b3f4dbab/11.md#supported-nips)
still specifies integers. [PR 2218](https://github.com/nostr-protocol/nips/pull/2218)
is open at the checked revision
[`b4bad62ad44a900d6800e83c1f83549f2f705c33`](https://github.com/coracle-social/nips/blob/b4bad62ad44a900d6800e83c1f83549f2f705c33/11.md#supported-nips)
(2026-09-04). It proposes string identifiers and normalization of historical
integers. Mixed output is a deliberate transitional choice, not a claim that
the current integer-only specification has already changed.

## Concrete parser evidence

The following source revisions were inspected on 2026-09-04. Source behavior
is distinguished from testing a complete released application.

| Client/library | Evidence | Mixed vs all strings |
| --- | --- | --- |
| Installed nostr-tools 2.25.1 | [fetchRelayInformation](https://github.com/nbd-wtf/nostr-tools/blob/v2.25.1/nip11.ts) returns `response.json()` without validating or normalizing the array; its TypeScript declaration says `number[]`. An injected-response run of the installed implementation accepted both arrays. | Mixed `[1,11,77,"9a"]` preserves `.includes(77)`. All strings makes that numeric lookup false. Neither array is rejected by this fetch helper itself. |
| Amethyst | [Model at a22bc0d](https://github.com/vitorpamplona/amethyst/blob/a22bc0db14364a3192a69581d08631bfd4f82e04/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt) uses a custom [FlexibleIntListSerializer](https://github.com/vitorpamplona/amethyst/blob/a22bc0db14364a3192a69581d08631bfd4f82e04/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt): it reads each JSON primitive as text. Its serializer emits numeric identifiers as numbers and nonnumeric identifiers as strings. | Explicit parser support for numbers, strings and mixed arrays. This is stronger evidence than the model's `List<String>` declaration alone. |
| Damus | [RelayMetadata at 2ad6f02](https://github.com/damus-io/damus/blob/2ad6f02372b842def26ed193e027eebea0d5f111/damus/Core/Nostr/Relay.swift#L126) has `supported_nips: [Int]?`. [fetch_relay_metadata](https://github.com/damus-io/damus/blob/2ad6f02372b842def26ed193e027eebea0d5f111/damus/Features/Timeline/Models/HomeModel.swift#L1183) uses `JSONDecoder().decode(RelayMetadata.self, from: data)`. | Both mixed and all-string arrays fail this metadata decode. The integer-only Swift decoder shape was reproduced locally. This does not establish that WebSocket connectivity fails; the [negentropy path](https://github.com/damus-io/damus/blob/2ad6f02372b842def26ed193e027eebea0d5f111/damus/Core/Nostr/RelayConnection.swift#L306) tolerates a metadata fetch failure with `try?`. |
| Coracle / Welshman | [Coracle manifest at 544fe55](https://github.com/coracle-social/coracle/blob/544fe559e234942fec0e889cd6edcd8b7b1cf6e2/package.json) and [Welshman net 0.8.15 declaration](https://unpkg.com/@welshman/net@0.8.15/dist/util/src/Relay.d.ts) use string identifiers; the PR author reports support. | A declaration and maintainer report establish intent, not proof that every numeric lookup normalizes mixed input. No full-client run was performed. |
| Flotilla | [Fixtures at 801568d](https://gitea.coracle.social/coracle/flotilla/src/commit/801568d59091c05c87c2a49ff6ffa195bac1a0be/e2e/specs/settings.spec.ts) use string NIP arrays; the PR author reports support. | Evidence for string-array use, not an independent full-client mixed-array test. |
| Primal web | [Package at c96ee21](https://github.com/PrimalHQ/primal-web-app/blob/c96ee211043c6fee8a8b7c431746aab06392f765/package.json) uses nostr-tools 2.23.1. The inspected direct NIP-11 use reads a premium server version. | General relay capability handling remains unverified. A dependency version alone does not prove rejection or compatibility. |
| Rust nostr crate | [Pinned struct and decoder test](https://github.com/nostrdevkit/nostr/blob/0c6fad2ac8ce934747096953f6dba355e3532614/nostr/src/nips/nip11.rs) derives serde `Deserialize` with `Option<Vec<u16>>`. Its test rejects an array containing a string. | Both arrays containing `"9a"` and all-string arrays fail the integer decoder. This is a specific parser, not a claim about every Rust client. |
| Go fork inspected | [frnandu/go-nostr at fc34d8e](https://github.com/frnandu/go-nostr/blob/fc34d8e7a8c21647abe83c34a1bc731dae0a734e/nip11/types.go) uses `SupportedNIPs []any`. | The array field permits mixed values. Older `[]int` examples must not be used as evidence about this revision. Semantic numeric comparison still depends on the caller. |

The PR author also reports Zooid support; its parser was not independently
verified. These are representative libraries and clients, including clients
linked by bind.ws, not a universal compatibility certification. TypeScript
`number[]`, fixtures, or an `.includes(number)` test alone are not evidence
that a JSON document will be rejected at runtime.

## Why mixed output

All-string output changes every existing numeric capability lookup and still
fails strict integer decoders. Mixed output keeps numeric consumers working
where they accept arbitrary JSON values, including the installed nostr-tools
fetch helper. It cannot protect Damus metadata or strict serde decoding from
a lettered entry. The explicit numeric-only default prevents the existing,
default-on sites feature from unexpectedly changing every relay's wire type.

Operators who need strict-client metadata compatibility leave both push and
`letteredNips` off. Owners who need callback delivery accept the draft
advertisement tradeoff; other relay protocols remain usable according to each
client's handling of metadata failures. The console and
[NIP-9a relay push](25-nip-9a-relay-push.md) document this choice.
5 changes: 4 additions & 1 deletion relay-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
"properties": { "depth": { "type": "integer", "minimum": 0 }, "quota": { "type": "integer", "minimum": 0 } }
},
"views": { "type": "object", "additionalProperties": { "anyOf": [{ "type": "boolean" }, { "enum": ["off", "write", "hourly", "daily"] }] }, "description": "a view named here runs on that trigger, or is off; the rest run on their own trigger. true and false still mean the default and off" },
"pushCallbacks": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 2048 }, "description": "Exact HTTPS callback origins, also approved by the host operator" },
"letteredNips": { "type": "boolean", "description": "Mixed numeric and lettered NIP-11 identifiers. Push also activates this representation." },
"features": {
"type": "object",
"additionalProperties": false,
Expand All @@ -84,7 +86,8 @@
} }
]
},
"grasp": { "type": "boolean" }
"grasp": { "type": "boolean" },
"push": { "type": "boolean" }
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/console/console.html
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ <h3>Kinds</h3>
<h3>Features</h3>
<p class="note">Each is a door or a cost. Off leaves the NIP-11 list, answers 404 at its door and is refused at the socket.</p>
<div id="features" class="limits"></div>
<form id="push-policy-form" class="limits">
<label><span>Push callback origins <small>One HTTPS origin per line, also approved by your host operator. Relay push costs fuel.</small></span><textarea class="txt" name="origins" rows="3" placeholder="https://push.example.com"></textarea></label>
<label><span>Lettered NIP advertisements <small>Some older clients reject mixed NIP identifiers. Relay push requires them.</small></span><input type="checkbox" name="lettered"></label>
<div class="actions"><button class="btn">Save callback policy</button></div>
</form>
</div>
<div class="block">
<h3>Blocked words</h3>
Expand Down
Loading