Browse Source
consolidating documentation double-checking NIP implementations getting rid of hard-coded kind numbers bug-fixingmain
49 changed files with 3602 additions and 189 deletions
@ -0,0 +1,178 @@ |
|||||||
|
NIP-01 |
||||||
|
====== |
||||||
|
|
||||||
|
Basic protocol flow description |
||||||
|
------------------------------- |
||||||
|
|
||||||
|
`draft` `mandatory` `relay` |
||||||
|
|
||||||
|
This NIP defines the basic protocol that should be implemented by everybody. New NIPs may add new optional (or mandatory) fields and messages and features to the structures and flows described here. |
||||||
|
|
||||||
|
## Events and signatures |
||||||
|
|
||||||
|
Each user has a keypair. Signatures, public key, and encodings are done according to the [Schnorr signatures standard for the curve `secp256k1`](https://bips.xyz/340). |
||||||
|
|
||||||
|
The only object type that exists is the `event`, which has the following format on the wire: |
||||||
|
|
||||||
|
```yaml |
||||||
|
{ |
||||||
|
"id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>, |
||||||
|
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, |
||||||
|
"created_at": <unix timestamp in seconds>, |
||||||
|
"kind": <integer between 0 and 65535>, |
||||||
|
"tags": [ |
||||||
|
[<arbitrary string>...], |
||||||
|
// ... |
||||||
|
], |
||||||
|
"content": <arbitrary string>, |
||||||
|
"sig": <64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field> |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
To obtain the `event.id`, we `sha256` the serialized event. The serialization is done over the UTF-8 JSON-serialized string (which is described below) of the following structure: |
||||||
|
|
||||||
|
``` |
||||||
|
[ |
||||||
|
0, |
||||||
|
<pubkey, as a lowercase hex string>, |
||||||
|
<created_at, as a number>, |
||||||
|
<kind, as a number>, |
||||||
|
<tags, as an array of arrays of non-null strings>, |
||||||
|
<content, as a string> |
||||||
|
] |
||||||
|
``` |
||||||
|
|
||||||
|
To prevent implementation differences from creating a different event ID for the same event, the following rules MUST be followed while serializing: |
||||||
|
- UTF-8 should be used for encoding. |
||||||
|
- Whitespace, line breaks or other unnecessary formatting should not be included in the output JSON. |
||||||
|
- The following characters in the content field must be escaped as shown, and all other characters must be included verbatim: |
||||||
|
- A line break (`0x0A`), use `\n` |
||||||
|
- A double quote (`0x22`), use `\"` |
||||||
|
- A backslash (`0x5C`), use `\\` |
||||||
|
- A carriage return (`0x0D`), use `\r` |
||||||
|
- A tab character (`0x09`), use `\t` |
||||||
|
- A backspace, (`0x08`), use `\b` |
||||||
|
- A form feed, (`0x0C`), use `\f` |
||||||
|
|
||||||
|
### Tags |
||||||
|
|
||||||
|
Each tag is an array of one or more strings, with some conventions around them. Take a look at the example below: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"tags": [ |
||||||
|
["e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com"], |
||||||
|
["p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca"], |
||||||
|
["a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com"], |
||||||
|
["alt", "reply"], |
||||||
|
// ... |
||||||
|
], |
||||||
|
// ... |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
The first element of the tag array is referred to as the tag _name_ or _key_ and the second as the tag _value_. So we can safely say that the event above has an `e` tag set to `"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"`, an `alt` tag set to `"reply"` and so on. All elements after the second do not have a conventional name. |
||||||
|
|
||||||
|
This NIP defines 3 standard tags that can be used across all event kinds with the same meaning. They are as follows: |
||||||
|
|
||||||
|
- The `e` tag, used to refer to an event: `["e", <32-bytes lowercase hex of the id of another event>, <recommended relay URL, optional>, <32-bytes lowercase hex of the author's pubkey, optional>]` |
||||||
|
- The `p` tag, used to refer to another user: `["p", <32-bytes lowercase hex of a pubkey>, <recommended relay URL, optional>]` |
||||||
|
- The `a` tag, used to refer to an addressable or replaceable event |
||||||
|
- for an addressable event: `["a", "<kind integer>:<32-bytes lowercase hex of a pubkey>:<d tag value>", <recommended relay URL, optional>]` |
||||||
|
- for a normal replaceable event: `["a", "<kind integer>:<32-bytes lowercase hex of a pubkey>:", <recommended relay URL, optional>]` (note: include the trailing colon) |
||||||
|
|
||||||
|
As a convention, all single-letter (only english alphabet letters: a-z, A-Z) key tags are expected to be indexed by relays, such that it is possible, for example, to query or subscribe to events that reference the event `"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"` by using the `{"#e": ["5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"]}` filter. Only the first value in any given tag is indexed. |
||||||
|
|
||||||
|
### Kinds |
||||||
|
|
||||||
|
Kinds specify how clients should interpret the meaning of each event and the other fields of each event (e.g. an `"r"` tag may have a meaning in an event of kind 1 and an entirely different meaning in an event of kind 10002). Each NIP may define the meaning of a set of kinds that weren't defined elsewhere. [NIP-10](10.md), for instance, specifies the `kind:1` text note for social media applications. |
||||||
|
|
||||||
|
This NIP defines one basic kind: |
||||||
|
|
||||||
|
- `0`: **user metadata**: the `content` is set to a stringified JSON object `{name: <nickname or full name>, about: <short bio>, picture: <url of the image>}` describing the user who created the event. [Extra metadata fields](24.md#kind-0) may be set. A relay may delete older events once it gets a new one for the same pubkey. |
||||||
|
|
||||||
|
And also a convention for kind ranges that allow for easier experimentation and flexibility of relay implementation: |
||||||
|
|
||||||
|
- for kind `n` such that `1000 <= n < 10000 || 4 <= n < 45 || n == 1 || n == 2`, events are **regular**, which means they're all expected to be stored by relays. |
||||||
|
- for kind `n` such that `10000 <= n < 20000 || n == 0 || n == 3`, events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event MUST be stored by relays, older versions MAY be discarded. |
||||||
|
- for kind `n` such that `20000 <= n < 30000`, events are **ephemeral**, which means they are not expected to be stored by relays. |
||||||
|
- for kind `n` such that `30000 <= n < 40000`, events are **addressable** by their `kind`, `pubkey` and `d` tag value -- which means that, for each combination of `kind`, `pubkey` and the `d` tag value, only the latest event MUST be stored by relays, older versions MAY be discarded. |
||||||
|
|
||||||
|
In case of replaceable events with the same timestamp, the event with the lowest id (first in lexical order) should be retained, and the other discarded. |
||||||
|
|
||||||
|
When answering to `REQ` messages for replaceable events such as `{"kinds":[0],"authors":[<hex-key>]}`, even if the relay has more than one version stored, it SHOULD return just the latest one. |
||||||
|
|
||||||
|
These are just conventions and relay implementations may differ. |
||||||
|
|
||||||
|
## Communication between clients and relays |
||||||
|
|
||||||
|
Relays expose a websocket endpoint to which clients can connect. Clients SHOULD open a single websocket connection to each relay and use it for all their subscriptions. Relays MAY limit number of connections from specific IP/client/etc. |
||||||
|
|
||||||
|
### From client to relay: sending events and creating subscriptions |
||||||
|
|
||||||
|
Clients can send 3 types of messages, which must be JSON arrays, according to the following patterns: |
||||||
|
|
||||||
|
* `["EVENT", <event JSON as defined above>]`, used to publish events. |
||||||
|
* `["REQ", <subscription_id>, <filters1>, <filters2>, ...]`, used to request events and subscribe to new updates. |
||||||
|
* `["CLOSE", <subscription_id>]`, used to stop previous subscriptions. |
||||||
|
|
||||||
|
`<subscription_id>` is an arbitrary, non-empty string of max length 64 chars. It represents a subscription per connection. Relays MUST manage `<subscription_id>`s independently for each WebSocket connection. `<subscription_id>`s are not guaranteed to be globally unique. |
||||||
|
|
||||||
|
`<filtersX>` is a JSON object that determines what events will be sent in that subscription, it can have the following attributes: |
||||||
|
|
||||||
|
```yaml |
||||||
|
{ |
||||||
|
"ids": <a list of event ids>, |
||||||
|
"authors": <a list of lowercase pubkeys, the pubkey of an event must be one of these>, |
||||||
|
"kinds": <a list of a kind numbers>, |
||||||
|
"#<single-letter (a-zA-Z)>": <a list of tag values, for #e — a list of event ids, for #p — a list of pubkeys, etc.>, |
||||||
|
"since": <an integer unix timestamp in seconds. Events must have a created_at >= to this to pass>, |
||||||
|
"until": <an integer unix timestamp in seconds. Events must have a created_at <= to this to pass>, |
||||||
|
"limit": <maximum number of events relays SHOULD return in the initial query> |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Upon receiving a `REQ` message, the relay SHOULD return events that match the filter. Any new events it receives SHOULD be sent to that same websocket until the connection is closed, a `CLOSE` event is received with the same `<subscription_id>`, or a new `REQ` is sent using the same `<subscription_id>` (in which case a new subscription is created, replacing the old one). |
||||||
|
|
||||||
|
Filter attributes containing lists (`ids`, `authors`, `kinds` and tag filters like `#e`) are JSON arrays with one or more values. At least one of the arrays' values must match the relevant field in an event for the condition to be considered a match. For scalar event attributes such as `authors` and `kind`, the attribute from the event must be contained in the filter list. In the case of tag attributes such as `#e`, for which an event may have multiple values, the event and filter condition values must have at least one item in common. |
||||||
|
|
||||||
|
The `ids`, `authors`, `#e` and `#p` filter lists MUST contain exact 64-character lowercase hex values. |
||||||
|
|
||||||
|
The `since` and `until` properties can be used to specify the time range of events returned in the subscription. If a filter includes the `since` property, events with `created_at` greater than or equal to `since` are considered to match the filter. The `until` property is similar except that `created_at` must be less than or equal to `until`. In short, an event matches a filter if `since <= created_at <= until` holds. |
||||||
|
|
||||||
|
All conditions of a filter that are specified must match for an event for it to pass the filter, i.e., multiple conditions are interpreted as `&&` conditions. |
||||||
|
|
||||||
|
A `REQ` message may contain multiple filters. In this case, events that match any of the filters are to be returned, i.e., multiple filters are to be interpreted as `||` conditions. |
||||||
|
|
||||||
|
The `limit` property of a filter is only valid for the initial query and MUST be ignored afterwards. When `limit: n` is present it is assumed that the events returned in the initial query will be the last `n` events ordered by the `created_at`. Newer events should appear first, and in the case of ties the event with the lowest id (first in lexical order) should be first. Relays SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable, but returning (much) more should be avoided to prevent overwhelming clients. |
||||||
|
|
||||||
|
### From relay to client: sending events and notices |
||||||
|
|
||||||
|
Relays can send 5 types of messages, which must also be JSON arrays, according to the following patterns: |
||||||
|
|
||||||
|
* `["EVENT", <subscription_id>, <event JSON as defined above>]`, used to send events requested by clients. |
||||||
|
* `["OK", <event_id>, <true|false>, <message>]`, used to indicate acceptance or denial of an `EVENT` message. |
||||||
|
* `["EOSE", <subscription_id>]`, used to indicate the _end of stored events_ and the beginning of events newly received in real-time. |
||||||
|
* `["CLOSED", <subscription_id>, <message>]`, used to indicate that a subscription was ended on the server side. |
||||||
|
* `["NOTICE", <message>]`, used to send human-readable error messages or other things to clients. |
||||||
|
|
||||||
|
This NIP defines no rules for how `NOTICE` messages should be sent or treated. |
||||||
|
|
||||||
|
- `EVENT` messages MUST be sent only with a subscription ID related to a subscription previously initiated by the client (using the `REQ` message above). |
||||||
|
- `OK` messages MUST be sent in response to `EVENT` messages received from clients, they must have the 3rd parameter set to `true` when an event has been accepted by the relay, `false` otherwise. The 4th parameter MUST always be present, but MAY be an empty string when the 3rd is `true`, otherwise it MUST be a string formed by a machine-readable single-word prefix followed by a `:` and then a human-readable message. Some examples: |
||||||
|
* `["OK", "b1a649ebe8...", true, ""]` |
||||||
|
* `["OK", "b1a649ebe8...", true, "pow: difficulty 25>=24"]` |
||||||
|
* `["OK", "b1a649ebe8...", true, "duplicate: already have this event"]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "blocked: you are banned from posting here"]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "blocked: please register your pubkey at https://my-expensive-relay.example.com"]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "rate-limited: slow down there chief"]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "invalid: event creation date is too far off from the current time"]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "pow: difficulty 26 is less than 30"]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "restricted: not allowed to write."]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "error: could not connect to the database"]` |
||||||
|
* `["OK", "b1a649ebe8...", false, "mute: no one was listening to your ephemeral event and it wasn't handled in any way, it was ignored"]` |
||||||
|
- `CLOSED` messages MUST be sent in response to a `REQ` when the relay refuses to fulfill it. It can also be sent when a relay decides to kill a subscription on its side before a client has disconnected or sent a `CLOSE`. This message uses the same pattern of `OK` messages with the machine-readable prefix and human-readable message. Some examples: |
||||||
|
* `["CLOSED", "sub1", "unsupported: filter contains unknown elements"]` |
||||||
|
* `["CLOSED", "sub1", "error: could not connect to the database"]` |
||||||
|
* `["CLOSED", "sub1", "error: shutting down idle subscription"]` |
||||||
|
- The standardized machine-readable prefixes for `OK` and `CLOSED` are: `duplicate`, `pow`, `blocked`, `rate-limited`, `invalid`, `restricted`, `mute` and `error` for when none of that fits. |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
NIP-02 |
||||||
|
====== |
||||||
|
|
||||||
|
Follow List |
||||||
|
----------- |
||||||
|
|
||||||
|
`final` `optional` |
||||||
|
|
||||||
|
A special event with kind `3`, meaning "follow list" is defined as having a list of `p` tags, one for each of the followed/known profiles one is following. |
||||||
|
|
||||||
|
Each tag entry should contain the key for the profile, a relay URL where events from that key can be found (can be set to an empty string if not needed), and a local name (or "petname") for that profile (can also be set to an empty string or not provided), i.e., `["p", <32-bytes hex key>, <main relay URL>, <petname>]`. |
||||||
|
|
||||||
|
The `.content` is not used. |
||||||
|
|
||||||
|
For example: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 3, |
||||||
|
"tags": [ |
||||||
|
["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], |
||||||
|
["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], |
||||||
|
["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"] |
||||||
|
], |
||||||
|
"content": "", |
||||||
|
// other fields... |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Every new following list that gets published overwrites the past ones, so it should contain all entries. Relays and clients SHOULD delete past following lists as soon as they receive a new one. |
||||||
|
|
||||||
|
Whenever new follows are added to an existing list, clients SHOULD append them to the end of the list, so they are stored in chronological order. |
||||||
|
|
||||||
|
## Uses |
||||||
|
|
||||||
|
### Follow list backup |
||||||
|
|
||||||
|
If one believes a relay will store their events for sufficient time, they can use this kind-3 event to backup their following list and recover on a different device. |
||||||
|
|
||||||
|
### Profile discovery and context augmentation |
||||||
|
|
||||||
|
A client may rely on the kind-3 event to display a list of followed people by profiles one is browsing; make lists of suggestions on who to follow based on the follow lists of other people one might be following or browsing; or show the data in other contexts. |
||||||
|
|
||||||
|
### Relay sharing |
||||||
|
|
||||||
|
A client may publish a follow list with good relays for each of their follows so other clients may use these to update their internal relay lists if needed, increasing censorship-resistance. |
||||||
|
|
||||||
|
### Petname scheme |
||||||
|
|
||||||
|
The data from these follow lists can be used by clients to construct local ["petname"](http://www.skyhunter.com/marcs/petnames/IntroPetNames.html) tables derived from other people's follow lists. This alleviates the need for global human-readable names. For example: |
||||||
|
|
||||||
|
A user has an internal follow list that says |
||||||
|
|
||||||
|
```json |
||||||
|
[ |
||||||
|
["p", "21df6d143fb96c2ec9d63726bf9edc71", "", "erin"] |
||||||
|
] |
||||||
|
``` |
||||||
|
|
||||||
|
And receives two follow lists, one from `21df6d143fb96c2ec9d63726bf9edc71` that says |
||||||
|
|
||||||
|
```json |
||||||
|
[ |
||||||
|
["p", "a8bb3d884d5d90b413d9891fe4c4e46d", "", "david"] |
||||||
|
] |
||||||
|
``` |
||||||
|
|
||||||
|
and another from `a8bb3d884d5d90b413d9891fe4c4e46d` that says |
||||||
|
|
||||||
|
```json |
||||||
|
[ |
||||||
|
["p", "f57f54057d2a7af0efecc8b0b66f5708", "", "frank"] |
||||||
|
] |
||||||
|
``` |
||||||
|
|
||||||
|
When the user sees `21df6d143fb96c2ec9d63726bf9edc71` the client can show _erin_ instead; |
||||||
|
When the user sees `a8bb3d884d5d90b413d9891fe4c4e46d` the client can show _david.erin_ instead; |
||||||
|
When the user sees `f57f54057d2a7af0efecc8b0b66f5708` the client can show _frank.david.erin_ instead. |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
NIP-07 |
||||||
|
====== |
||||||
|
|
||||||
|
`window.nostr` capability for web browsers |
||||||
|
------------------------------------------ |
||||||
|
|
||||||
|
`draft` `optional` |
||||||
|
|
||||||
|
The `window.nostr` object may be made available by web browsers or extensions and websites or web-apps may make use of it after checking its availability. |
||||||
|
|
||||||
|
That object must define the following methods: |
||||||
|
|
||||||
|
``` |
||||||
|
async window.nostr.getPublicKey(): string // returns a public key as hex |
||||||
|
async window.nostr.signEvent(event: { created_at: number, kind: number, tags: string[][], content: string }): Event // takes an event object, adds `id`, `pubkey` and `sig` and returns it |
||||||
|
``` |
||||||
|
|
||||||
|
Aside from these two basic above, the following functions can also be implemented optionally: |
||||||
|
``` |
||||||
|
async window.nostr.nip04.encrypt(pubkey, plaintext): string // returns ciphertext and iv as specified in nip-04 (deprecated) |
||||||
|
async window.nostr.nip04.decrypt(pubkey, ciphertext): string // takes ciphertext and iv as specified in nip-04 (deprecated) |
||||||
|
async window.nostr.nip44.encrypt(pubkey, plaintext): string // returns ciphertext as specified in nip-44 |
||||||
|
async window.nostr.nip44.decrypt(pubkey, ciphertext): string // takes ciphertext as specified in nip-44 |
||||||
|
``` |
||||||
|
|
||||||
|
### Recommendation to Extension Authors |
||||||
|
To make sure that the `window.nostr` is available to nostr clients on page load, the authors who create Chromium and Firefox extensions should load their scripts by specifying `"run_at": "document_end"` in the extension's manifest. |
||||||
|
|
||||||
|
|
||||||
|
### Implementation |
||||||
|
|
||||||
|
See https://github.com/aljazceru/awesome-nostr#nip-07-browser-extensions. |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
NIP-09 |
||||||
|
====== |
||||||
|
|
||||||
|
Event Deletion Request |
||||||
|
---------------------- |
||||||
|
|
||||||
|
`draft` `optional` `relay` |
||||||
|
|
||||||
|
A special event with kind `5`, meaning "deletion request" is defined as having a list of one or more `e` or `a` tags, each referencing an event the author is requesting to be deleted. Deletion requests SHOULD include a `k` tag for the kind of each event being requested for deletion. |
||||||
|
|
||||||
|
The event's `content` field MAY contain a text note describing the reason for the deletion request. |
||||||
|
|
||||||
|
For example: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 5, |
||||||
|
"pubkey": <32-bytes hex-encoded public key of the event creator>, |
||||||
|
"tags": [ |
||||||
|
["e", "dcd59..464a2"], |
||||||
|
["e", "968c5..ad7a4"], |
||||||
|
["a", "<kind>:<pubkey>:<d-identifier>"], |
||||||
|
["k", "1"], |
||||||
|
["k", "30023"] |
||||||
|
], |
||||||
|
"content": "these posts were published by accident", |
||||||
|
// other fields... |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Relays SHOULD delete or stop publishing any referenced events that have an identical `pubkey` as the deletion request. Clients SHOULD hide or otherwise indicate a deletion request status for referenced events. |
||||||
|
|
||||||
|
Relays SHOULD continue to publish/share the deletion request events indefinitely, as clients may already have the event that's intended to be deleted. Additionally, clients SHOULD broadcast deletion request events to other relays which don't have it. |
||||||
|
|
||||||
|
When an `a` tag is used, relays SHOULD delete all versions of the replaceable event up to the `created_at` timestamp of the deletion request event. |
||||||
|
|
||||||
|
## Client Usage |
||||||
|
|
||||||
|
Clients MAY choose to fully hide any events that are referenced by valid deletion request events. This includes text notes, direct messages, or other yet-to-be defined event kinds. Alternatively, they MAY show the event along with an icon or other indication that the author has "disowned" the event. The `content` field MAY also be used to replace the deleted events' own content, although a user interface should clearly indicate that this is a deletion request reason, not the original content. |
||||||
|
|
||||||
|
A client MUST validate that each event `pubkey` referenced in the `e` tag of the deletion request is identical to the deletion request `pubkey`, before hiding or deleting any event. Relays can not, in general, perform this validation and should not be treated as authoritative. |
||||||
|
|
||||||
|
Clients display the deletion request event itself in any way they choose, e.g., not at all, or with a prominent notice. |
||||||
|
|
||||||
|
Clients MAY choose to inform the user that their request for deletion does not guarantee deletion because it is impossible to delete events from all relays and clients. |
||||||
|
|
||||||
|
## Relay Usage |
||||||
|
|
||||||
|
Relays MAY validate that a deletion request event only references events that have the same `pubkey` as the deletion request itself, however this is not required since relays may not have knowledge of all referenced events. |
||||||
|
|
||||||
|
## Deletion Request of a Deletion Request |
||||||
|
|
||||||
|
Publishing a deletion request event against a deletion request has no effect. Clients and relays are not obliged to support "unrequest deletion" functionality. |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
NIP-10 |
||||||
|
====== |
||||||
|
|
||||||
|
Text Notes and Threads |
||||||
|
---------------------- |
||||||
|
|
||||||
|
`draft` `optional` |
||||||
|
|
||||||
|
This NIP defines `kind:1` as a simple plaintext note. |
||||||
|
|
||||||
|
## Abstract |
||||||
|
|
||||||
|
The `.content` property contains some human-readable text. |
||||||
|
|
||||||
|
`e` tags can be used to define note thread roots and replies. They SHOULD be sorted by the reply stack from root to the direct parent. |
||||||
|
|
||||||
|
`q` tags MAY be used when citing events in the `.content` with [NIP-21](21.md). |
||||||
|
|
||||||
|
```json |
||||||
|
["q", "<event-id> or <event-address>", "<relay-url>", "<pubkey-if-a-regular-event>"] |
||||||
|
``` |
||||||
|
|
||||||
|
Authors of the `e` and `q` tags SHOULD be added as `p` tags to notify of a new reply or quote. |
||||||
|
|
||||||
|
Markup languages such as markdown and HTML SHOULD NOT be used. |
||||||
|
|
||||||
|
## Marked "e" tags (PREFERRED) |
||||||
|
|
||||||
|
Kind 1 events with `e` tags are replies to other kind 1 events. Kind 1 replies MUST NOT be used to reply to other kinds, use [NIP-22](22.md) instead. |
||||||
|
|
||||||
|
`["e", <event-id>, <relay-url>, <marker>, <pubkey>]` |
||||||
|
|
||||||
|
Where: |
||||||
|
|
||||||
|
* `<event-id>` is the id of the event being referenced. |
||||||
|
* `<relay-url>` is the URL of a recommended relay associated with the reference. Clients SHOULD add a valid `<relay-url>` field, but may instead leave it as `""`. |
||||||
|
* `<marker>` is optional and if present is one of `"reply"`, `"root"`. |
||||||
|
* `<pubkey>` is optional, SHOULD be the pubkey of the author of the referenced event |
||||||
|
|
||||||
|
Those marked with `"reply"` denote the id of the reply event being responded to. Those marked with `"root"` denote the root id of the reply thread being responded to. For top level replies (those replying directly to the root event), only the `"root"` marker should be used. |
||||||
|
|
||||||
|
A direct reply to the root of a thread should have a single marked "e" tag of type "root". |
||||||
|
|
||||||
|
>This scheme is preferred because it allows events to mention others without confusing them with `<reply-id>` or `<root-id>`. |
||||||
|
|
||||||
|
`<pubkey>` SHOULD be the pubkey of the author of the `e` tagged event, this is used in the outbox model to search for that event from the authors write relays where relay hints did not resolve the event. |
||||||
|
|
||||||
|
## The "p" tag |
||||||
|
Used in a text event contains a list of pubkeys used to record who is involved in a reply thread. |
||||||
|
|
||||||
|
When replying to a text event E the reply event's "p" tags should contain all of E's "p" tags as well as the `"pubkey"` of the event being replied to. |
||||||
|
|
||||||
|
Example: Given a text event authored by `a1` with "p" tags [`p1`, `p2`, `p3`] then the "p" tags of the reply should be [`a1`, `p1`, `p2`, `p3`] |
||||||
|
in no particular order. |
||||||
|
|
||||||
|
## Deprecated Positional "e" tags |
||||||
|
|
||||||
|
This scheme is not in common use anymore and is here just to keep backward compatibility with older events on the network. |
||||||
|
|
||||||
|
Positional `e` tags are deprecated because they create ambiguities that are difficult, or impossible to resolve when an event references another but is not a reply. |
||||||
|
|
||||||
|
They use simple `e` tags without any marker. |
||||||
|
|
||||||
|
`["e", <event-id>, <relay-url>]` as per NIP-01. |
||||||
|
|
||||||
|
Where: |
||||||
|
|
||||||
|
* `<event-id>` is the id of the event being referenced. |
||||||
|
* `<relay-url>` is the URL of a recommended relay associated with the reference. Many clients treat this field as optional. |
||||||
|
|
||||||
|
**The positions of the "e" tags within the event denote specific meanings as follows**: |
||||||
|
|
||||||
|
* No "e" tag: <br> |
||||||
|
This event is not a reply to, nor does it refer to, any other event. |
||||||
|
|
||||||
|
* One "e" tag: <br> |
||||||
|
`["e", <id>]`: The id of the event to which this event is a reply. |
||||||
|
|
||||||
|
* Two "e" tags: `["e", <root-id>]`, `["e", <reply-id>]` <br> |
||||||
|
`<root-id>` is the id of the event at the root of the reply chain. `<reply-id>` is the id of the article to which this event is a reply. |
||||||
|
|
||||||
|
* Many "e" tags: `["e", <root-id>]` `["e", <mention-id>]`, ..., `["e", <reply-id>]`<br> |
||||||
|
There may be any number of `<mention-ids>`. These are the ids of events which may, or may not be in the reply chain. |
||||||
|
They are citing from this event. `root-id` and `reply-id` are as above. |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
NIP-19 |
||||||
|
====== |
||||||
|
|
||||||
|
bech32-encoded entities |
||||||
|
----------------------- |
||||||
|
|
||||||
|
`draft` `optional` |
||||||
|
|
||||||
|
This NIP standardizes bech32-formatted strings that can be used to display keys, ids and other information in clients. These formats are not meant to be used anywhere in the core protocol, they are only meant for displaying to users, copy-pasting, sharing, rendering QR codes and inputting data. |
||||||
|
|
||||||
|
It is recommended that ids and keys are stored in either hex or binary format, since these formats are closer to what must actually be used the core protocol. |
||||||
|
|
||||||
|
## Bare keys and ids |
||||||
|
|
||||||
|
To prevent confusion and mixing between private keys, public keys and event ids, which are all 32 byte strings. bech32-(not-m) encoding with different prefixes can be used for each of these entities. |
||||||
|
|
||||||
|
These are the possible bech32 prefixes: |
||||||
|
|
||||||
|
- `npub`: public keys |
||||||
|
- `nsec`: private keys |
||||||
|
- `note`: note ids |
||||||
|
|
||||||
|
Example: the hex public key `3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d` translates to `npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6`. |
||||||
|
|
||||||
|
The bech32 encodings of keys and ids are not meant to be used inside the standard NIP-01 event formats or inside the filters, they're meant for human-friendlier display and input only. Clients should still accept keys in both hex and npub format for now, and convert internally. |
||||||
|
|
||||||
|
## Shareable identifiers with extra metadata |
||||||
|
|
||||||
|
When sharing a profile or an event, an app may decide to include relay information and other metadata such that other apps can locate and display these entities more easily. |
||||||
|
|
||||||
|
For these events, the contents are a binary-encoded list of `TLV` (type-length-value), with `T` and `L` being 1 byte each (`uint8`, i.e. a number in the range of 0-255), and `V` being a sequence of bytes of the size indicated by `L`. |
||||||
|
|
||||||
|
These are the possible bech32 prefixes with `TLV`: |
||||||
|
|
||||||
|
- `nprofile`: a nostr profile |
||||||
|
- `nevent`: a nostr event |
||||||
|
- `naddr`: a nostr _addressable event_ coordinate |
||||||
|
- `nrelay`: a nostr relay (deprecated) |
||||||
|
|
||||||
|
These possible standardized `TLV` types are indicated here: |
||||||
|
|
||||||
|
- `0`: `special` |
||||||
|
- depends on the bech32 prefix: |
||||||
|
- for `nprofile` it will be the 32 bytes of the profile public key |
||||||
|
- for `nevent` it will be the 32 bytes of the event id |
||||||
|
- for `naddr`, it is the identifier (the `"d"` tag) of the event being referenced. For normal replaceable events use an empty string. |
||||||
|
- `1`: `relay` |
||||||
|
- for `nprofile`, `nevent` and `naddr`, _optionally_, a relay in which the entity (profile or event) is more likely to be found, encoded as ascii |
||||||
|
- this may be included multiple times |
||||||
|
- `2`: `author` |
||||||
|
- for `naddr`, the 32 bytes of the pubkey of the event |
||||||
|
- for `nevent`, _optionally_, the 32 bytes of the pubkey of the event |
||||||
|
- `3`: `kind` |
||||||
|
- for `naddr`, the 32-bit unsigned integer of the kind, big-endian |
||||||
|
- for `nevent`, _optionally_, the 32-bit unsigned integer of the kind, big-endian |
||||||
|
|
||||||
|
## Examples |
||||||
|
|
||||||
|
- `npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg` should decode into the public key hex `7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e` and vice-versa |
||||||
|
- `nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5` should decode into the private key hex `67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa` and vice-versa |
||||||
|
- `nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p` should decode into a profile with the following TLV items: |
||||||
|
- pubkey: `3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d` |
||||||
|
- relay: `wss://r.x.com` |
||||||
|
- relay: `wss://djbas.sadkb.com` |
||||||
|
|
||||||
|
## Notes |
||||||
|
|
||||||
|
- `npub` keys MUST NOT be used in NIP-01 events or in NIP-05 JSON responses, only the hex format is supported there. |
||||||
|
- When decoding a bech32-formatted string, TLVs that are not recognized or supported should be ignored, rather than causing an error. |
||||||
@ -0,0 +1,200 @@ |
|||||||
|
NIP-22 |
||||||
|
====== |
||||||
|
|
||||||
|
Comment |
||||||
|
------- |
||||||
|
|
||||||
|
`draft` `optional` |
||||||
|
|
||||||
|
A comment is a threading note always scoped to a root event or an [`I`-tag](73.md). |
||||||
|
|
||||||
|
It uses `kind:1111` with plaintext `.content` (no HTML, Markdown, or other formatting). |
||||||
|
|
||||||
|
Comments MUST point to the root scope using uppercase tag names (e.g. `K`, `E`, `A` or `I`) |
||||||
|
and MUST point to the parent item with lowercase ones (e.g. `k`, `e`, `a` or `i`). |
||||||
|
|
||||||
|
Comments MUST point to the authors when one is available (i.e. tagging a nostr event). `P` for the root scope |
||||||
|
and `p` for the author of the parent item. |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 1111, |
||||||
|
"content": "<comment>", |
||||||
|
"tags": [ |
||||||
|
// root scope: event addresses, event ids, or I-tags. |
||||||
|
["<A, E, I>", "<address, id or I-value>", "<relay or web page hint>", "<root event's pubkey, if an E tag>"], |
||||||
|
// the root item kind |
||||||
|
["K", "<root kind>"], |
||||||
|
|
||||||
|
// pubkey of the author of the root scope event |
||||||
|
["P", "<root-pubkey>", "relay-url-hint"], |
||||||
|
|
||||||
|
// parent item: event addresses, event ids, or i-tags. |
||||||
|
["<a, e, i>", "<address, id or i-value>", "<relay or web page hint>", "<parent event's pubkey, if an e tag>"], |
||||||
|
// parent item kind |
||||||
|
["k", "<parent comment kind>"], |
||||||
|
|
||||||
|
// parent item pubkey |
||||||
|
["p", "<parent-pubkey>", "relay-url-hint"] |
||||||
|
] |
||||||
|
// other fields |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Tags `K` and `k` MUST be present to define the event kind of the root and the parent items. |
||||||
|
|
||||||
|
`I` and `i` tags create scopes for hashtags, geohashes, URLs, and other external identifiers. |
||||||
|
|
||||||
|
The possible values for `i` tags – and `k` tags, when related to an external identity – are listed on [NIP-73](73.md). |
||||||
|
Their uppercase versions use the same type of values but relate to the root item instead of the parent one. |
||||||
|
|
||||||
|
`q` tags MAY be used when citing events in the `.content` with [NIP-21](21.md). |
||||||
|
|
||||||
|
```json |
||||||
|
["q", "<event-id> or <event-address>", "<relay-url>", "<pubkey-if-a-regular-event>"] |
||||||
|
``` |
||||||
|
|
||||||
|
`p` tags SHOULD be used when mentioning pubkeys in the `.content` with [NIP-21](21.md). |
||||||
|
|
||||||
|
Comments MUST NOT be used to reply to kind 1 notes. [NIP-10](10.md) should instead be followed. |
||||||
|
|
||||||
|
## Examples |
||||||
|
|
||||||
|
A comment on a blog post looks like this: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 1111, |
||||||
|
"content": "Great blog post!", |
||||||
|
"tags": [ |
||||||
|
// top-level comments scope to event addresses or ids |
||||||
|
["A", "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", "wss://example.relay"], |
||||||
|
// the root kind |
||||||
|
["K", "30023"], |
||||||
|
// author of root event |
||||||
|
["P", "3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289", "wss://example.relay"] |
||||||
|
|
||||||
|
// the parent event address (same as root for top-level comments) |
||||||
|
["a", "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", "wss://example.relay"], |
||||||
|
// when the parent event is replaceable or addressable, also include an `e` tag referencing its id |
||||||
|
["e", "5b4fc7fed15672fefe65d2426f67197b71ccc82aa0cc8a9e94f683eb78e07651", "wss://example.relay"], |
||||||
|
// the parent event kind |
||||||
|
["k", "30023"], |
||||||
|
// author of the parent event |
||||||
|
["p", "3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289", "wss://example.relay"] |
||||||
|
] |
||||||
|
// other fields |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
A comment on a [NIP-94](94.md) file looks like this: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 1111, |
||||||
|
"content": "Great file!", |
||||||
|
"tags": [ |
||||||
|
// top-level comments have the same scope and reply to addresses or ids |
||||||
|
["E", "768ac8720cdeb59227cf95e98b66560ef03d8bc9a90d721779e76e68fb42f5e6", "wss://example.relay", "3721e07b079525289877c366ccab47112bdff3d1b44758ca333feb2dbbbbe5bb"], |
||||||
|
// the root kind |
||||||
|
["K", "1063"], |
||||||
|
// author of the root event |
||||||
|
["P", "3721e07b079525289877c366ccab47112bdff3d1b44758ca333feb2dbbbbe5bb"], |
||||||
|
|
||||||
|
// the parent event id (same as root for top-level comments) |
||||||
|
["e", "768ac8720cdeb59227cf95e98b66560ef03d8bc9a90d721779e76e68fb42f5e6", "wss://example.relay", "3721e07b079525289877c366ccab47112bdff3d1b44758ca333feb2dbbbbe5bb"], |
||||||
|
// the parent kind |
||||||
|
["k", "1063"], |
||||||
|
["p", "3721e07b079525289877c366ccab47112bdff3d1b44758ca333feb2dbbbbe5bb"] |
||||||
|
] |
||||||
|
// other fields |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
A reply to a comment looks like this: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 1111, |
||||||
|
"content": "This is a reply to \"Great file!\"", |
||||||
|
"tags": [ |
||||||
|
// nip-94 file event id |
||||||
|
["E", "768ac8720cdeb59227cf95e98b66560ef03d8bc9a90d721779e76e68fb42f5e6", "wss://example.relay", "fd913cd6fa9edb8405750cd02a8bbe16e158b8676c0e69fdc27436cc4a54cc9a"], |
||||||
|
// the root kind |
||||||
|
["K", "1063"], |
||||||
|
["P", "fd913cd6fa9edb8405750cd02a8bbe16e158b8676c0e69fdc27436cc4a54cc9a"], |
||||||
|
|
||||||
|
// the parent event |
||||||
|
["e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://example.relay", "93ef2ebaaf9554661f33e79949007900bbc535d239a4c801c33a4d67d3e7f546"], |
||||||
|
// the parent kind |
||||||
|
["k", "1111"], |
||||||
|
["p", "93ef2ebaaf9554661f33e79949007900bbc535d239a4c801c33a4d67d3e7f546"] |
||||||
|
] |
||||||
|
// other fields |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
A comment on a website's url looks like this: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 1111, |
||||||
|
"content": "Nice article!", |
||||||
|
"tags": [ |
||||||
|
// referencing the root url |
||||||
|
["I", "https://abc.com/articles/1"], |
||||||
|
// the root "kind": for an url |
||||||
|
["K", "web"], |
||||||
|
|
||||||
|
// the parent reference (same as root for top-level comments) |
||||||
|
["i", "https://abc.com/articles/1"], |
||||||
|
// the parent "kind": for an url |
||||||
|
["k", "web"] |
||||||
|
] |
||||||
|
// other fields |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
A podcast comment example: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"id": "80c48d992a38f9c445b943a9c9f1010b396676013443765750431a9004bdac05", |
||||||
|
"pubkey": "252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111", |
||||||
|
"kind": 1111, |
||||||
|
"content": "This was a great episode!", |
||||||
|
"tags": [ |
||||||
|
// podcast episode reference |
||||||
|
["I", "podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f", "https://fountain.fm/episode/z1y9TMQRuqXl2awyrQxg"], |
||||||
|
// podcast episode type |
||||||
|
["K", "podcast:item:guid"], |
||||||
|
|
||||||
|
// same value as "I" tag above, because it is a top-level comment (not a reply to a comment) |
||||||
|
["i", "podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f", "https://fountain.fm/episode/z1y9TMQRuqXl2awyrQxg"], |
||||||
|
["k", "podcast:item:guid"] |
||||||
|
] |
||||||
|
// other fields |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
A reply to a podcast comment: |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 1111, |
||||||
|
"content": "I'm replying to the above comment.", |
||||||
|
"tags": [ |
||||||
|
// podcast episode reference |
||||||
|
["I", "podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f", "https://fountain.fm/episode/z1y9TMQRuqXl2awyrQxg"], |
||||||
|
// podcast episode type |
||||||
|
["K", "podcast:item:guid"], |
||||||
|
|
||||||
|
// this is a reference to the above comment |
||||||
|
["e", "80c48d992a38f9c445b943a9c9f1010b396676013443765750431a9004bdac05", "wss://example.relay", "252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111"], |
||||||
|
// the parent comment kind |
||||||
|
["k", "1111"] |
||||||
|
["p", "252f10c83610ebca1a059c0bae8255eba2f95be4d1d7bcfa89d7248a82d9f111"] |
||||||
|
] |
||||||
|
// other fields |
||||||
|
} |
||||||
|
``` |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
NIP-65 |
||||||
|
====== |
||||||
|
|
||||||
|
Relay List Metadata |
||||||
|
------------------- |
||||||
|
|
||||||
|
`draft` `optional` |
||||||
|
|
||||||
|
Defines a replaceable event using `kind:10002` to advertise relays where the user generally **writes** to and relays where the user generally **reads** mentions. |
||||||
|
|
||||||
|
The event MUST include a list of `r` tags with relay URLs as value and an optional `read` or `write` marker. If the marker is omitted, the relay is both **read** and **write**. |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"kind": 10002, |
||||||
|
"tags": [ |
||||||
|
["r", "wss://alicerelay.example.com"], |
||||||
|
["r", "wss://brando-relay.com"], |
||||||
|
["r", "wss://expensive-relay.example2.com", "write"], |
||||||
|
["r", "wss://nostr-relay.example.com", "read"] |
||||||
|
], |
||||||
|
"content": "", |
||||||
|
// other fields... |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
When downloading events **from** a user, clients SHOULD use the **write** relays of that user. |
||||||
|
|
||||||
|
When downloading events **about** a user, where the user was tagged (mentioned), clients SHOULD use the user's **read** relays. |
||||||
|
|
||||||
|
When publishing an event, clients SHOULD: |
||||||
|
|
||||||
|
- Send the event to the **write** relays of the author |
||||||
|
- Send the event to all **read** relays of each tagged user |
||||||
|
- Send the author's `kind:10002` event to all relays the event was published to |
||||||
|
|
||||||
|
### Size |
||||||
|
|
||||||
|
Clients SHOULD guide users to keep `kind:10002` lists small (2-4 relays of each category). |
||||||
|
|
||||||
|
### Discoverability |
||||||
|
|
||||||
|
Clients SHOULD spread an author's `kind:10002` event to as many relays as viable, paying attention to relays that, at any moment, serve naturally as well-known public indexers for these relay lists (where most other clients and users are connecting to in order to publish and fetch those). |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
NIP-84 |
||||||
|
====== |
||||||
|
|
||||||
|
Highlights |
||||||
|
---------- |
||||||
|
|
||||||
|
`draft` `optional` |
||||||
|
|
||||||
|
This NIP defines `kind:9802`, a "highlight" event, to signal content a user finds valuable. |
||||||
|
|
||||||
|
## Format |
||||||
|
The `.content` of these events is the highlighted portion of the text. |
||||||
|
|
||||||
|
`.content` might be empty for highlights of non-text based media (e.g. NIP-94 audio/video). |
||||||
|
|
||||||
|
### References |
||||||
|
Events SHOULD tag the source of the highlight, whether nostr-native or not. |
||||||
|
`a` or `e` tags should be used for nostr events and `r` tags for URLs. |
||||||
|
|
||||||
|
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers |
||||||
|
or obvious non-useful information from the query string. |
||||||
|
|
||||||
|
### Attribution |
||||||
|
Clients MAY include one or more `p` tags, tagging the original authors of the material being highlighted; this is particularly |
||||||
|
useful when highlighting non-nostr content for which the client might be able to get a nostr pubkey somehow |
||||||
|
(e.g. prompting the user or reading a `<link rel="me" href="nostr:nprofile1..." />` tag on the document). A role MAY be included as the |
||||||
|
last value of the tag. |
||||||
|
|
||||||
|
```jsonc |
||||||
|
{ |
||||||
|
"tags": [ |
||||||
|
["p", "<pubkey-hex>", "<relay-url>", "author"], |
||||||
|
["p", "<pubkey-hex>", "<relay-url>", "author"], |
||||||
|
["p", "<pubkey-hex>", "<relay-url>", "editor"] |
||||||
|
], |
||||||
|
// other fields... |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Context |
||||||
|
Clients MAY include a `context` tag, useful when the highlight is a subset of a paragraph and displaying the |
||||||
|
surrounding content might be beneficial to give context to the highlight. |
||||||
|
|
||||||
|
## Quote Highlights |
||||||
|
A `comment` tag may be added to create a quote highlight. This MUST be rendered like a quote repost with the highlight as the quoted note. |
||||||
|
|
||||||
|
This is to prevent the creation and multiple notes (highlight + kind 1) for a single highlight action, which looks bad in micro-blogging clients where these notes may appear in succession. |
||||||
|
|
||||||
|
p-tag mentions MUST have a `mention` attribute to distinguish it from authors and editors. |
||||||
|
|
||||||
|
r-tag urls from the comment MUST have a `mention` attribute to distinguish from the highlighted source url. The source url MUST have the `source` attribute. |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
NIP-98 |
||||||
|
====== |
||||||
|
|
||||||
|
HTTP Auth |
||||||
|
--------- |
||||||
|
|
||||||
|
`draft` `optional` |
||||||
|
|
||||||
|
This NIP defines an ephemeral event used to authorize requests to HTTP servers using nostr events. |
||||||
|
|
||||||
|
This is useful for HTTP services which are built for Nostr and deal with Nostr user accounts. |
||||||
|
|
||||||
|
## Nostr event |
||||||
|
|
||||||
|
A `kind 27235` (In reference to [RFC 7235](https://www.rfc-editor.org/rfc/rfc7235)) event is used. |
||||||
|
|
||||||
|
The `content` SHOULD be empty. |
||||||
|
|
||||||
|
The following tags MUST be included. |
||||||
|
|
||||||
|
* `u` - absolute URL |
||||||
|
* `method` - HTTP Request Method |
||||||
|
|
||||||
|
Example event: |
||||||
|
```json |
||||||
|
{ |
||||||
|
"id": "fe964e758903360f28d8424d092da8494ed207cba823110be3a57dfe4b578734", |
||||||
|
"pubkey": "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", |
||||||
|
"content": "", |
||||||
|
"kind": 27235, |
||||||
|
"created_at": 1682327852, |
||||||
|
"tags": [ |
||||||
|
["u", "https://api.snort.social/api/v1/n5sp/list"], |
||||||
|
["method", "GET"] |
||||||
|
], |
||||||
|
"sig": "5ed9d8ec958bc854f997bdc24ac337d005af372324747efe4a00e24f4c30437ff4dd8308684bed467d9d6be3e5a517bb43b1732cc7d33949a3aaf86705c22184" |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Servers MUST perform the following checks in order to validate the event: |
||||||
|
1. The `kind` MUST be `27235`. |
||||||
|
2. The `created_at` timestamp MUST be within a reasonable time window (suggestion 60 seconds). |
||||||
|
3. The `u` tag MUST be exactly the same as the absolute request URL (including query parameters). |
||||||
|
4. The `method` tag MUST be the same HTTP method used for the requested resource. |
||||||
|
|
||||||
|
When the request contains a body (as in POST/PUT/PATCH methods) clients SHOULD include a SHA256 hash of the request body in a `payload` tag as hex (`["payload", "<sha256-hex>"]`), servers MAY check this to validate that the requested payload is authorized. |
||||||
|
|
||||||
|
If one of the checks was to fail the server SHOULD respond with a 401 Unauthorized response code. |
||||||
|
|
||||||
|
Servers MAY perform additional implementation-specific validation checks. |
||||||
|
|
||||||
|
## Request Flow |
||||||
|
|
||||||
|
Using the `Authorization` HTTP header, the `kind 27235` event MUST be `base64` encoded and use the Authorization scheme `Nostr` |
||||||
|
|
||||||
|
Example HTTP Authorization header: |
||||||
|
``` |
||||||
|
Authorization: Nostr |
||||||
|
eyJpZCI6ImZlOTY0ZTc1ODkwMzM2MGYyOGQ4NDI0ZDA5MmRhODQ5NGVkMjA3Y2JhODIzMTEwYmUzYTU3ZGZlNGI1Nzg3MzQiLCJwdWJrZXkiOiI2M2ZlNjMxOGRjNTg1ODNjZmUxNjgxMGY4NmRkMDllMThiZmQ3NmFhYmMyNGEwMDgxY2UyODU2ZjMzMDUwNGVkIiwiY29udGVudCI6IiIsImtpbmQiOjI3MjM1LCJjcmVhdGVkX2F0IjoxNjgyMzI3ODUyLCJ0YWdzIjpbWyJ1IiwiaHR0cHM6Ly9hcGkuc25vcnQuc29jaWFsL2FwaS92MS9uNXNwL2xpc3QiXSxbIm1ldGhvZCIsIkdFVCJdXSwic2lnIjoiNWVkOWQ4ZWM5NThiYzg1NGY5OTdiZGMyNGFjMzM3ZDAwNWFmMzcyMzI0NzQ3ZWZlNGEwMGUyNGY0YzMwNDM3ZmY0ZGQ4MzA4Njg0YmVkNDY3ZDlkNmJlM2U1YTUxN2JiNDNiMTczMmNjN2QzMzk0OWEzYWFmODY3MDVjMjIxODQifQ |
||||||
|
``` |
||||||
|
|
||||||
|
## Reference Implementations |
||||||
|
- C# ASP.NET `AuthenticationHandler` [NostrAuth.cs](https://gist.github.com/v0l/74346ae530896115bfe2504c8cd018d3) |
||||||
@ -0,0 +1,352 @@ |
|||||||
|
# Architecture FAQ |
||||||
|
|
||||||
|
Answers to common questions about gitrepublic-web's architecture and design decisions. |
||||||
|
|
||||||
|
## 1. Session State |
||||||
|
|
||||||
|
### Where does session state live? |
||||||
|
|
||||||
|
**Answer**: Session state lives entirely on the client (browser). There is **no server-side session storage**. |
||||||
|
|
||||||
|
- **Client-side**: User's public key (`userPubkey`) is stored in Svelte component state (`$state`) |
||||||
|
- **No server storage**: The server does not maintain session cookies, tokens, or any session database |
||||||
|
- **Stateless authentication**: Each request is authenticated independently using: |
||||||
|
- **NIP-07**: Browser extension (Alby, nos2x) for web UI operations |
||||||
|
- **NIP-98**: HTTP authentication events for git operations |
||||||
|
|
||||||
|
### Implementation Details |
||||||
|
|
||||||
|
```typescript |
||||||
|
// Client-side state (src/routes/+page.svelte) |
||||||
|
let userPubkey = $state<string | null>(null); |
||||||
|
|
||||||
|
// Login: Get pubkey from NIP-07 extension |
||||||
|
async function login() { |
||||||
|
userPubkey = await getPublicKeyWithNIP07(); |
||||||
|
} |
||||||
|
|
||||||
|
// Logout: Clear client state |
||||||
|
function logout() { |
||||||
|
userPubkey = null; |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
**Why stateless?** |
||||||
|
- Decentralized design: No central session authority |
||||||
|
- Scalability: No session database to manage |
||||||
|
- Privacy: Server doesn't track user sessions |
||||||
|
- Nostr-native: Uses Nostr's cryptographic authentication |
||||||
|
|
||||||
|
## 2. Session Scope |
||||||
|
|
||||||
|
### When does a session begin and end? |
||||||
|
|
||||||
|
**Answer**: Since there's no server-side session, the "session" is really just client-side authentication state: |
||||||
|
|
||||||
|
- **Begins**: When user connects their NIP-07 extension and calls `login()` |
||||||
|
- The extension provides the user's public key |
||||||
|
- This is stored in component state for the current page load |
||||||
|
|
||||||
|
- **Ends**: |
||||||
|
- When user calls `logout()` (sets `userPubkey = null`) |
||||||
|
- When browser tab/window is closed (state is lost) |
||||||
|
- When page is refreshed (state is lost unless persisted) |
||||||
|
|
||||||
|
**Note**: There's currently **no persistence** of login state across page refreshes. Users need to reconnect their NIP-07 extension on each page load. |
||||||
|
|
||||||
|
**Potential Enhancement**: Could add localStorage to persist `userPubkey` across sessions, but this is a design decision - some prefer explicit re-authentication for security. |
||||||
|
|
||||||
|
## 3. Repository Settings Storage |
||||||
|
|
||||||
|
### Where are repo settings stored? |
||||||
|
|
||||||
|
**Answer**: Repository settings are stored **entirely in Nostr events** (kind 30617, NIP-34 repo announcements). **No database is required**. |
||||||
|
|
||||||
|
### Storage Location |
||||||
|
|
||||||
|
- **Nostr Events**: All settings are stored as tags in the repository announcement event: |
||||||
|
- `name`: Repository name |
||||||
|
- `description`: Repository description |
||||||
|
- `clone`: Clone URLs (array) |
||||||
|
- `maintainers`: List of maintainer pubkeys |
||||||
|
- `private`: Privacy flag (`true`/`false`) |
||||||
|
- `relays`: Nostr relays to publish to |
||||||
|
|
||||||
|
### How It Works |
||||||
|
|
||||||
|
1. **Reading Settings**: |
||||||
|
```typescript |
||||||
|
// Fetch from Nostr relays |
||||||
|
const events = await nostrClient.fetchEvents([{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [ownerPubkey], |
||||||
|
'#d': [repoName], |
||||||
|
limit: 1 |
||||||
|
}]); |
||||||
|
|
||||||
|
// Extract settings from event tags |
||||||
|
const name = event.tags.find(t => t[0] === 'name')?.[1]; |
||||||
|
const maintainers = event.tags.filter(t => t[0] === 'maintainers').map(t => t[1]); |
||||||
|
``` |
||||||
|
|
||||||
|
2. **Updating Settings**: |
||||||
|
```typescript |
||||||
|
// Create new announcement event with updated tags |
||||||
|
const updatedEvent = { |
||||||
|
kind: KIND.REPO_ANNOUNCEMENT, |
||||||
|
pubkey: ownerPubkey, |
||||||
|
tags: [ |
||||||
|
['d', repoName], |
||||||
|
['name', newName], |
||||||
|
['maintainers', maintainer1], |
||||||
|
['maintainers', maintainer2], |
||||||
|
['private', 'true'] |
||||||
|
] |
||||||
|
}; |
||||||
|
|
||||||
|
// Sign with NIP-07 and publish to relays |
||||||
|
const signed = await signEventWithNIP07(updatedEvent); |
||||||
|
await nostrClient.publishEvent(signed, relays); |
||||||
|
``` |
||||||
|
|
||||||
|
### Benefits |
||||||
|
|
||||||
|
- **Decentralized**: Settings live on Nostr relays, not a central database |
||||||
|
- **Verifiable**: Cryptographically signed by repository owner |
||||||
|
- **Resilient**: Multiple relays store copies |
||||||
|
- **No database needed**: Simplifies deployment |
||||||
|
|
||||||
|
### Limitations |
||||||
|
|
||||||
|
- **Event replaceability**: NIP-34 announcements are replaceable (same `d` tag), so latest event wins |
||||||
|
- **Relay dependency**: Settings are only as available as the relays |
||||||
|
- **No complex queries**: Can't do complex database-style queries |
||||||
|
|
||||||
|
## 4. NIP-98 Authorization Requirements |
||||||
|
|
||||||
|
### What actions require NIP-98 authorization? |
||||||
|
|
||||||
|
**Answer**: NIP-98 is required for **git operations** (clone, push, pull) and **optional for web UI file operations**. |
||||||
|
|
||||||
|
### Required NIP-98 Operations |
||||||
|
|
||||||
|
1. **Git Push Operations** (`POST /api/git/{npub}/{repo}.git/git-receive-pack`) |
||||||
|
- **Always required** for push operations |
||||||
|
- Verifies user is repository owner or maintainer |
||||||
|
- Validates event signature, timestamp, URL, and method |
||||||
|
|
||||||
|
2. **Private Repository Clone/Fetch** (`GET /api/git/{npub}/{repo}.git/info/refs?service=git-upload-pack`) |
||||||
|
- **Required** if repository is marked as private |
||||||
|
- Verifies user has view access (owner or maintainer) |
||||||
|
- Public repos don't require authentication |
||||||
|
|
||||||
|
3. **Private Repository Fetch** (`POST /api/git/{npub}/{repo}.git/git-upload-pack`) |
||||||
|
- **Required** if repository is private |
||||||
|
- Same authentication as clone |
||||||
|
|
||||||
|
### Optional NIP-98 Operations |
||||||
|
|
||||||
|
4. **File Write Operations** (`POST /api/repos/{npub}/{repo}/file`) |
||||||
|
- **Optional**: Can use NIP-07 (browser extension) or NIP-98 |
||||||
|
- NIP-98 is useful for automated scripts or git operations |
||||||
|
- NIP-07 is more convenient for web UI |
||||||
|
|
||||||
|
### NIP-98 Verification Process |
||||||
|
|
||||||
|
```typescript |
||||||
|
// Server verifies: |
||||||
|
1. Event signature (cryptographic verification) |
||||||
|
2. Event timestamp (within 60 seconds) |
||||||
|
3. URL matches request URL exactly |
||||||
|
4. HTTP method matches |
||||||
|
5. Payload hash matches request body (for POST) |
||||||
|
6. Pubkey is repository owner or maintainer |
||||||
|
``` |
||||||
|
|
||||||
|
### API Endpoints Summary |
||||||
|
|
||||||
|
| Endpoint | NIP-98 Required? | Notes | |
||||||
|
|----------|------------------|-------| |
||||||
|
| `GET /api/git/.../info/refs` | Only for private repos | Public repos: no auth needed | |
||||||
|
| `POST /api/git/.../git-upload-pack` | Only for private repos | Public repos: no auth needed | |
||||||
|
| `POST /api/git/.../git-receive-pack` | **Always required** | All push operations | |
||||||
|
| `POST /api/repos/.../file` | Optional | Can use NIP-07 instead | |
||||||
|
| `GET /api/repos/.../file` | No | Uses query param `userPubkey` | |
||||||
|
| `POST /api/repos/.../settings` | No | Uses NIP-07 (browser extension) | |
||||||
|
|
||||||
|
## 5. Repository Announcement Polling |
||||||
|
|
||||||
|
### Why is the server polling instead of using subscriptions? |
||||||
|
|
||||||
|
**Answer**: The server uses **polling** (every 60 seconds) instead of persistent WebSocket subscriptions for simplicity and reliability. |
||||||
|
|
||||||
|
### Current Implementation |
||||||
|
|
||||||
|
```typescript |
||||||
|
// src/lib/services/nostr/repo-polling.ts |
||||||
|
constructor( |
||||||
|
pollingInterval: number = 60000 // 1 minute default |
||||||
|
) { |
||||||
|
// Poll immediately, then every interval |
||||||
|
this.intervalId = setInterval(() => { |
||||||
|
this.poll(); |
||||||
|
}, this.pollingInterval); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
**Polling Schedule**: |
||||||
|
- **Frequency**: Every 60 seconds (1 minute) |
||||||
|
- **Type**: Long-running background process |
||||||
|
- **Location**: Started in `hooks.server.ts` when server starts |
||||||
|
- **Not a cron job**: Runs continuously in the Node.js process |
||||||
|
|
||||||
|
### Why Polling Instead of Subscriptions? |
||||||
|
|
||||||
|
**Advantages of Polling**: |
||||||
|
1. **Simplicity**: No need to maintain persistent WebSocket connections |
||||||
|
2. **Reliability**: If a connection drops, polling automatically retries |
||||||
|
3. **Resource efficiency**: Only connects when fetching, not maintaining long-lived connections |
||||||
|
4. **Easier error handling**: Each poll is independent |
||||||
|
|
||||||
|
**Disadvantages of Polling**: |
||||||
|
1. **Latency**: Up to 60 seconds delay before new repos are discovered |
||||||
|
2. **Relay load**: More frequent queries to relays |
||||||
|
3. **Less real-time**: Not immediate notification of new repos |
||||||
|
|
||||||
|
### Could We Use Subscriptions? |
||||||
|
|
||||||
|
**Yes, but with trade-offs**: |
||||||
|
|
||||||
|
```typescript |
||||||
|
// Potential subscription implementation |
||||||
|
const ws = new WebSocket(relay); |
||||||
|
ws.send(JSON.stringify(['REQ', 'sub-id', { |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
'#clone': [domain] |
||||||
|
}])); |
||||||
|
|
||||||
|
ws.on('message', (event) => { |
||||||
|
// Handle new repo announcement immediately |
||||||
|
}); |
||||||
|
``` |
||||||
|
|
||||||
|
**Challenges**: |
||||||
|
- Need to maintain WebSocket connections to multiple relays |
||||||
|
- Handle connection drops and reconnections |
||||||
|
- More complex error handling |
||||||
|
- Higher memory usage for long-lived connections |
||||||
|
|
||||||
|
### Recommendation |
||||||
|
|
||||||
|
For most use cases, **60-second polling is acceptable**: |
||||||
|
- New repos don't need to be discovered instantly |
||||||
|
- Reduces complexity |
||||||
|
- More reliable for production |
||||||
|
|
||||||
|
For real-time requirements, subscriptions could be added as an enhancement, but polling is a solid default. |
||||||
|
|
||||||
|
## 6. Branch Protection |
||||||
|
|
||||||
|
### What is the scheme for branch protection? |
||||||
|
|
||||||
|
**Answer**: **Branch protection is not currently implemented**. This is a missing feature. |
||||||
|
|
||||||
|
### Current State |
||||||
|
|
||||||
|
**What Exists**: |
||||||
|
- Maintainers can create branches (`POST /api/repos/{npub}/{repo}/branches`) |
||||||
|
- Only maintainers can create branches (not regular users) |
||||||
|
- No protection for `main`/`master` branch |
||||||
|
|
||||||
|
**What's Missing**: |
||||||
|
- ❌ No branch protection rules |
||||||
|
- ❌ No restriction on pushing to `main`/`master` |
||||||
|
- ❌ No required pull request reviews |
||||||
|
- ❌ No required status checks |
||||||
|
- ❌ No force push restrictions |
||||||
|
|
||||||
|
### Current Authorization |
||||||
|
|
||||||
|
```typescript |
||||||
|
// src/routes/api/repos/[npub]/[repo]/branches/+server.ts |
||||||
|
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo); |
||||||
|
if (!isMaintainer) { |
||||||
|
return error(403, 'Only repository maintainers can create branches'); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
**Authorized Users**: |
||||||
|
- **Repository Owner**: Can do everything |
||||||
|
- **Maintainers**: Listed in repo announcement `maintainers` tags |
||||||
|
- Can create branches |
||||||
|
- Can push to any branch (including main) |
||||||
|
- Can write files |
||||||
|
|
||||||
|
### Proposed Branch Protection Implementation |
||||||
|
|
||||||
|
**Option 1: Nostr Events (Recommended)** |
||||||
|
- Create new event kind (e.g., 30620) for branch protection rules |
||||||
|
- Store rules in Nostr events: |
||||||
|
```json |
||||||
|
{ |
||||||
|
"kind": 30620, |
||||||
|
"tags": [ |
||||||
|
["d", "repo-name"], |
||||||
|
["branch", "main", "protected"], |
||||||
|
["branch", "main", "require-pr"], |
||||||
|
["branch", "main", "require-reviewers", "pubkey1", "pubkey2"] |
||||||
|
] |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
**Option 2: In-Repo Configuration** |
||||||
|
- Store `.gitrepublic/branch-protection.json` in repository |
||||||
|
- Git-based, version-controlled |
||||||
|
- Requires pull request to change rules |
||||||
|
|
||||||
|
**Option 3: Server Configuration** |
||||||
|
- Store in server database (conflicts with decentralized design) |
||||||
|
- Not recommended for this architecture |
||||||
|
|
||||||
|
### Recommended Approach |
||||||
|
|
||||||
|
**Hybrid: Nostr Events + In-Repo Config** |
||||||
|
|
||||||
|
1. **Default rules**: Stored in Nostr events (kind 30620) |
||||||
|
2. **Override rules**: Can be stored in `.gitrepublic/branch-protection.json` in repo |
||||||
|
3. **Enforcement**: Server checks rules before allowing push to protected branches |
||||||
|
|
||||||
|
**Example Rules**: |
||||||
|
```json |
||||||
|
{ |
||||||
|
"protectedBranches": ["main", "master"], |
||||||
|
"requirePullRequest": true, |
||||||
|
"requireReviewers": ["pubkey1", "pubkey2"], |
||||||
|
"allowForcePush": false, |
||||||
|
"requireStatusChecks": ["ci", "lint"] |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Implementation Priority |
||||||
|
|
||||||
|
This is a **medium-priority feature** that would enhance security and workflow, but the current system works for basic use cases where: |
||||||
|
- Owners trust their maintainers |
||||||
|
- Repositories are small teams |
||||||
|
- Formal review processes aren't needed |
||||||
|
|
||||||
|
For enterprise use cases, branch protection would be highly recommended. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Summary |
||||||
|
|
||||||
|
| Question | Answer | |
||||||
|
|----------|--------| |
||||||
|
| **Session State** | Client-side only, no server storage | |
||||||
|
| **Session Scope** | Begins on NIP-07 login, ends on logout or page close | |
||||||
|
| **Repo Settings** | Stored in Nostr events (kind 30617), no database needed | |
||||||
|
| **NIP-98 Required** | Git push (always), private repo clone/fetch (conditional) | |
||||||
|
| **Polling Schedule** | Every 60 seconds, long-running background process | |
||||||
|
| **Branch Protection** | ✅ **Implemented** - Stored in Nostr events (kind 30620) | |
||||||
|
|
||||||
|
--- |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
# Logging Strategy |
||||||
|
|
||||||
|
## Current State |
||||||
|
|
||||||
|
The application currently uses `console.log`, `console.error`, and `console.warn` for logging, with: |
||||||
|
- Context prefixes (e.g., `[Fork] [repo1 → repo2]`) |
||||||
|
- Security sanitization (truncated pubkeys, redacted private keys) |
||||||
|
- Structured audit logging via `AuditLogger` service |
||||||
|
|
||||||
|
## Recommendation: Use Pino for Production |
||||||
|
|
||||||
|
### Why Pino? |
||||||
|
|
||||||
|
1. **Performance**: Extremely fast (async logging, minimal overhead) |
||||||
|
2. **Structured Logging**: JSON output perfect for ELK/Kibana/Logstash |
||||||
|
3. **Log Levels**: Built-in severity levels (trace, debug, info, warn, error, fatal) |
||||||
|
4. **Child Loggers**: Context propagation (request IDs, user IDs, etc.) |
||||||
|
5. **Ecosystem**: Excellent Kubernetes/Docker support |
||||||
|
6. **Small Bundle**: ~4KB minified |
||||||
|
|
||||||
|
### Implementation Plan |
||||||
|
|
||||||
|
#### 1. Install Pino |
||||||
|
|
||||||
|
```bash |
||||||
|
npm install pino pino-pretty |
||||||
|
``` |
||||||
|
|
||||||
|
#### 2. Create Logger Service |
||||||
|
|
||||||
|
```typescript |
||||||
|
// src/lib/services/logger.ts |
||||||
|
import pino from 'pino'; |
||||||
|
|
||||||
|
const logger = pino({ |
||||||
|
level: process.env.LOG_LEVEL || 'info', |
||||||
|
...(process.env.NODE_ENV === 'development' && { |
||||||
|
transport: { |
||||||
|
target: 'pino-pretty', |
||||||
|
options: { |
||||||
|
colorize: true, |
||||||
|
translateTime: 'HH:MM:ss Z', |
||||||
|
ignore: 'pid,hostname' |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
export default logger; |
||||||
|
``` |
||||||
|
|
||||||
|
#### 3. Replace Console Logs |
||||||
|
|
||||||
|
```typescript |
||||||
|
// Before |
||||||
|
console.log(`[Fork] ${context} Starting fork process`); |
||||||
|
|
||||||
|
// After |
||||||
|
import logger from '$lib/services/logger.js'; |
||||||
|
logger.info({ context, repo: `${npub}/${repo}` }, 'Starting fork process'); |
||||||
|
``` |
||||||
|
|
||||||
|
#### 4. Structured Context |
||||||
|
|
||||||
|
```typescript |
||||||
|
// Create child logger with context |
||||||
|
const forkLogger = logger.child({ |
||||||
|
operation: 'fork', |
||||||
|
originalRepo: `${npub}/${repo}`, |
||||||
|
forkRepo: `${userNpub}/${forkRepoName}` |
||||||
|
}); |
||||||
|
|
||||||
|
forkLogger.info('Starting fork process'); |
||||||
|
forkLogger.info({ relayCount: combinedRelays.length }, 'Using relays'); |
||||||
|
forkLogger.error({ error: sanitizedError }, 'Fork failed'); |
||||||
|
``` |
||||||
|
|
||||||
|
#### 5. ELK/Kibana Integration |
||||||
|
|
||||||
|
Pino outputs JSON by default, which works perfectly with: |
||||||
|
- **Filebeat**: Collect logs from files |
||||||
|
- **Logstash**: Parse and enrich logs |
||||||
|
- **Elasticsearch**: Store and index logs |
||||||
|
- **Kibana**: Visualize and search logs |
||||||
|
|
||||||
|
Example log output: |
||||||
|
```json |
||||||
|
{ |
||||||
|
"level": 30, |
||||||
|
"time": 1703123456789, |
||||||
|
"pid": 12345, |
||||||
|
"hostname": "gitrepublic-1", |
||||||
|
"operation": "fork", |
||||||
|
"originalRepo": "npub1.../repo1", |
||||||
|
"forkRepo": "npub2.../repo2", |
||||||
|
"msg": "Starting fork process" |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Migration Strategy |
||||||
|
|
||||||
|
1. **Phase 1**: Install Pino, create logger service |
||||||
|
2. **Phase 2**: Replace console logs in critical paths (fork, file operations, git operations) |
||||||
|
3. **Phase 3**: Replace remaining console logs |
||||||
|
4. **Phase 4**: Add request ID middleware for request tracing |
||||||
|
5. **Phase 5**: Configure log aggregation (Filebeat → ELK) |
||||||
|
|
||||||
|
### Benefits |
||||||
|
|
||||||
|
- **Searchability**: Query logs by operation, user, repo, etc. |
||||||
|
- **Alerting**: Set up alerts for error rates, failed operations |
||||||
|
- **Performance Monitoring**: Track operation durations |
||||||
|
- **Security Auditing**: Enhanced audit trail with structured data |
||||||
|
- **Debugging**: Easier to trace requests across services |
||||||
|
|
||||||
|
### Alternative: Winston |
||||||
|
|
||||||
|
Winston is also popular but: |
||||||
|
- Slower than Pino |
||||||
|
- More configuration overhead |
||||||
|
- Better for complex transports (multiple outputs) |
||||||
|
|
||||||
|
**Recommendation**: Use Pino for this project. |
||||||
@ -0,0 +1,204 @@ |
|||||||
|
/** |
||||||
|
* Service for managing branch protection rules |
||||||
|
* Stores rules in Nostr events (kind 30620) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from './nostr-client.js'; |
||||||
|
import { KIND } from '../../types/nostr.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface BranchProtectionRule { |
||||||
|
branch: string; |
||||||
|
requirePullRequest: boolean; |
||||||
|
requireReviewers?: string[]; // Array of pubkeys
|
||||||
|
allowForcePush: boolean; |
||||||
|
requireStatusChecks?: string[]; // Array of status check names
|
||||||
|
allowedMaintainers?: string[]; // Override: specific maintainers who can push directly
|
||||||
|
} |
||||||
|
|
||||||
|
export interface BranchProtectionConfig { |
||||||
|
repoTag: string; // Format: "{KIND.REPO_ANNOUNCEMENT}:{owner}:{repo}"
|
||||||
|
rules: BranchProtectionRule[]; |
||||||
|
} |
||||||
|
|
||||||
|
export class BranchProtectionService { |
||||||
|
private nostrClient: NostrClient; |
||||||
|
|
||||||
|
constructor(relays: string[]) { |
||||||
|
this.nostrClient = new NostrClient(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get branch protection rules for a repository |
||||||
|
*/ |
||||||
|
async getBranchProtection(ownerPubkey: string, repoName: string): Promise<BranchProtectionConfig | null> { |
||||||
|
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${ownerPubkey}:${repoName}`; |
||||||
|
|
||||||
|
try { |
||||||
|
const events = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.BRANCH_PROTECTION], |
||||||
|
authors: [ownerPubkey], |
||||||
|
'#a': [repoTag], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length === 0) { |
||||||
|
return null; // No protection rules
|
||||||
|
} |
||||||
|
|
||||||
|
const event = events[0]; |
||||||
|
return this.parseProtectionEvent(event); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching branch protection:', error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a branch is protected |
||||||
|
*/ |
||||||
|
async isBranchProtected(ownerPubkey: string, repoName: string, branchName: string): Promise<boolean> { |
||||||
|
const config = await this.getBranchProtection(ownerPubkey, repoName); |
||||||
|
if (!config) return false; |
||||||
|
|
||||||
|
return config.rules.some(rule => rule.branch === branchName); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a user can push to a protected branch |
||||||
|
*/ |
||||||
|
async canPushToBranch( |
||||||
|
userPubkey: string, |
||||||
|
ownerPubkey: string, |
||||||
|
repoName: string, |
||||||
|
branchName: string, |
||||||
|
isMaintainer: boolean |
||||||
|
): Promise<{ allowed: boolean; reason?: string }> { |
||||||
|
const config = await this.getBranchProtection(ownerPubkey, repoName); |
||||||
|
if (!config) { |
||||||
|
// No protection rules, allow if maintainer
|
||||||
|
return { allowed: isMaintainer || userPubkey === ownerPubkey }; |
||||||
|
} |
||||||
|
|
||||||
|
const rule = config.rules.find(r => r.branch === branchName); |
||||||
|
if (!rule) { |
||||||
|
// Branch not protected, allow if maintainer
|
||||||
|
return { allowed: isMaintainer || userPubkey === ownerPubkey }; |
||||||
|
} |
||||||
|
|
||||||
|
// Owner can always push (bypass protection)
|
||||||
|
if (userPubkey === ownerPubkey) { |
||||||
|
return { allowed: true }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user is in allowed maintainers list
|
||||||
|
if (rule.allowedMaintainers && rule.allowedMaintainers.includes(userPubkey)) { |
||||||
|
return { allowed: true }; |
||||||
|
} |
||||||
|
|
||||||
|
// Protected branch requires pull request
|
||||||
|
if (rule.requirePullRequest) { |
||||||
|
return { |
||||||
|
allowed: false, |
||||||
|
reason: `Branch "${branchName}" is protected. Please create a pull request instead of pushing directly.` |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// If no PR requirement, allow maintainers
|
||||||
|
return { allowed: isMaintainer }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a branch protection event |
||||||
|
*/ |
||||||
|
createProtectionEvent( |
||||||
|
ownerPubkey: string, |
||||||
|
repoName: string, |
||||||
|
rules: BranchProtectionRule[] |
||||||
|
): Omit<NostrEvent, 'sig' | 'id'> { |
||||||
|
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${ownerPubkey}:${repoName}`; |
||||||
|
const tags: string[][] = [ |
||||||
|
['d', repoName], |
||||||
|
['a', repoTag] |
||||||
|
]; |
||||||
|
|
||||||
|
// Add rules as tags
|
||||||
|
for (const rule of rules) { |
||||||
|
tags.push(['branch', rule.branch]); |
||||||
|
if (rule.requirePullRequest) { |
||||||
|
tags.push(['branch', rule.branch, 'require-pr']); |
||||||
|
} |
||||||
|
if (rule.allowForcePush) { |
||||||
|
tags.push(['branch', rule.branch, 'allow-force-push']); |
||||||
|
} |
||||||
|
if (rule.requireReviewers && rule.requireReviewers.length > 0) { |
||||||
|
tags.push(['branch', rule.branch, 'require-reviewers', ...rule.requireReviewers]); |
||||||
|
} |
||||||
|
if (rule.requireStatusChecks && rule.requireStatusChecks.length > 0) { |
||||||
|
tags.push(['branch', rule.branch, 'require-status', ...rule.requireStatusChecks]); |
||||||
|
} |
||||||
|
if (rule.allowedMaintainers && rule.allowedMaintainers.length > 0) { |
||||||
|
tags.push(['branch', rule.branch, 'allowed-maintainers', ...rule.allowedMaintainers]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
kind: KIND.BRANCH_PROTECTION, |
||||||
|
pubkey: ownerPubkey, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: JSON.stringify(rules), // Also store as JSON for easy parsing
|
||||||
|
tags |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a branch protection event into a config |
||||||
|
*/ |
||||||
|
private parseProtectionEvent(event: NostrEvent): BranchProtectionConfig { |
||||||
|
const repoTag = event.tags.find(t => t[0] === 'a')?.[1] || ''; |
||||||
|
const rules: BranchProtectionRule[] = []; |
||||||
|
|
||||||
|
// Try to parse from content first (JSON)
|
||||||
|
try { |
||||||
|
const parsed = JSON.parse(event.content); |
||||||
|
if (Array.isArray(parsed)) { |
||||||
|
return { repoTag, rules: parsed }; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Fall back to parsing tags
|
||||||
|
} |
||||||
|
|
||||||
|
// Parse from tags
|
||||||
|
const branchTags = event.tags.filter(t => t[0] === 'branch'); |
||||||
|
const branches = new Set(branchTags.map(t => t[1])); |
||||||
|
|
||||||
|
for (const branch of branches) { |
||||||
|
const branchTags = event.tags.filter(t => t[0] === 'branch' && t[1] === branch); |
||||||
|
|
||||||
|
const rule: BranchProtectionRule = { |
||||||
|
branch, |
||||||
|
requirePullRequest: branchTags.some(t => t[2] === 'require-pr'), |
||||||
|
allowForcePush: branchTags.some(t => t[2] === 'allow-force-push'), |
||||||
|
requireReviewers: [], |
||||||
|
requireStatusChecks: [], |
||||||
|
allowedMaintainers: [] |
||||||
|
}; |
||||||
|
|
||||||
|
for (const tag of branchTags) { |
||||||
|
if (tag[2] === 'require-reviewers') { |
||||||
|
rule.requireReviewers = tag.slice(3).filter(p => p && typeof p === 'string') as string[]; |
||||||
|
} else if (tag[2] === 'require-status') { |
||||||
|
rule.requireStatusChecks = tag.slice(3).filter(s => s && typeof s === 'string') as string[]; |
||||||
|
} else if (tag[2] === 'allowed-maintainers') { |
||||||
|
rule.allowedMaintainers = tag.slice(3).filter(m => m && typeof m === 'string') as string[]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
rules.push(rule); |
||||||
|
} |
||||||
|
|
||||||
|
return { repoTag, rules }; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
/** |
||||||
|
* Service for counting forks of a repository |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from './nostr-client.js'; |
||||||
|
import { KIND } from '../../types/nostr.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export class ForkCountService { |
||||||
|
private nostrClient: NostrClient; |
||||||
|
private cache: Map<string, { count: number; timestamp: number }> = new Map(); |
||||||
|
private cacheTTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
constructor(relays: string[]) { |
||||||
|
this.nostrClient = new NostrClient(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Count forks of a repository |
||||||
|
* Forks are identified by having an 'a' tag pointing to the original repo |
||||||
|
* Format: ['a', '{KIND.REPO_ANNOUNCEMENT}:{originalOwnerPubkey}:{originalRepoName}'] |
||||||
|
*/ |
||||||
|
async getForkCount(originalOwnerPubkey: string, originalRepoName: string): Promise<number> { |
||||||
|
const cacheKey = `${originalOwnerPubkey}:${originalRepoName}`; |
||||||
|
const cached = this.cache.get(cacheKey); |
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < this.cacheTTL) { |
||||||
|
return cached.count; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Find all repo announcements that reference this repo as a fork
|
||||||
|
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${originalRepoName}`; |
||||||
|
const forkEvents = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
'#a': [repoTag], |
||||||
|
limit: 1000 // Reasonable limit for fork count
|
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Filter for actual forks (have 'a' tag matching the original repo)
|
||||||
|
const forks = forkEvents.filter(event => { |
||||||
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1] === repoTag); |
||||||
|
return aTag !== undefined; |
||||||
|
}); |
||||||
|
|
||||||
|
const count = forks.length; |
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.cache.set(cacheKey, { count, timestamp: Date.now() }); |
||||||
|
|
||||||
|
return count; |
||||||
|
} catch (error) { |
||||||
|
console.error(`[ForkCount] Error counting forks for ${originalOwnerPubkey}/${originalRepoName}:`, error); |
||||||
|
// Return cached value if available, otherwise 0
|
||||||
|
return cached?.count || 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Invalidate cache for a repository (call after fork is created) |
||||||
|
*/ |
||||||
|
invalidateCache(originalOwnerPubkey: string, originalRepoName: string): void { |
||||||
|
const cacheKey = `${originalOwnerPubkey}:${originalRepoName}`; |
||||||
|
this.cache.delete(cacheKey); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
/** |
||||||
|
* Security utilities for safe logging and data handling |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Truncate a pubkey/npub for safe logging |
||||||
|
* Shows first 8 and last 4 characters: abc12345...xyz9 |
||||||
|
*/ |
||||||
|
export function truncatePubkey(pubkey: string | null | undefined): string { |
||||||
|
if (!pubkey) return 'unknown'; |
||||||
|
if (pubkey.length <= 16) return pubkey; // Already short, return as-is
|
||||||
|
|
||||||
|
// For hex pubkeys (64 chars) or npubs (longer), truncate
|
||||||
|
if (pubkey.length > 16) { |
||||||
|
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; |
||||||
|
} |
||||||
|
|
||||||
|
return pubkey; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Truncate an npub for display |
||||||
|
* Shows first 12 characters: npub1abc123... |
||||||
|
*/ |
||||||
|
export function truncateNpub(npub: string | null | undefined): string { |
||||||
|
if (!npub) return 'unknown'; |
||||||
|
if (npub.length <= 16) return npub; |
||||||
|
return `${npub.slice(0, 12)}...`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sanitize error messages to prevent leaking sensitive data |
||||||
|
*/ |
||||||
|
export function sanitizeError(error: unknown): string { |
||||||
|
if (error instanceof Error) { |
||||||
|
let message = error.message; |
||||||
|
|
||||||
|
// Remove potential private key patterns
|
||||||
|
message = message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]'); |
||||||
|
message = message.replace(/[0-9a-f]{64}/g, '[REDACTED]'); // 64-char hex keys
|
||||||
|
|
||||||
|
// Truncate long pubkeys in error messages
|
||||||
|
message = message.replace(/(npub[a-z0-9]{50,})/gi, (match) => truncateNpub(match)); |
||||||
|
message = message.replace(/([0-9a-f]{50,})/g, (match) => truncatePubkey(match)); |
||||||
|
|
||||||
|
return message; |
||||||
|
} |
||||||
|
return String(error); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a string might contain a private key |
||||||
|
*/ |
||||||
|
export function mightContainPrivateKey(str: string): boolean { |
||||||
|
// Check for nsec pattern
|
||||||
|
if (/^nsec[0-9a-z]+$/i.test(str)) return true; |
||||||
|
|
||||||
|
// Check for 64-char hex (potential private key)
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(str)) return true; |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Redact sensitive data from objects before logging |
||||||
|
*/ |
||||||
|
export function redactSensitiveData(obj: Record<string, any>): Record<string, any> { |
||||||
|
const redacted = { ...obj }; |
||||||
|
const sensitiveKeys = ['nsec', 'nsecKey', 'secret', 'privateKey', 'key', 'password', 'token', 'auth']; |
||||||
|
|
||||||
|
for (const key of Object.keys(redacted)) { |
||||||
|
const lowerKey = key.toLowerCase(); |
||||||
|
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) { |
||||||
|
redacted[key] = '[REDACTED]'; |
||||||
|
} else if (typeof redacted[key] === 'string' && mightContainPrivateKey(redacted[key])) { |
||||||
|
redacted[key] = '[REDACTED]'; |
||||||
|
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) { |
||||||
|
redacted[key] = redactSensitiveData(redacted[key]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return redacted; |
||||||
|
} |
||||||
@ -0,0 +1,148 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for branch protection rules |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from '@sveltejs/kit'; |
||||||
|
import { BranchProtectionService } from '$lib/services/nostr/branch-protection-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; |
||||||
|
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||||
|
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; |
||||||
|
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import type { BranchProtectionRule } from '$lib/services/nostr/branch-protection-service.js'; |
||||||
|
|
||||||
|
const branchProtectionService = new BranchProtectionService(DEFAULT_NOSTR_RELAYS); |
||||||
|
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
/** |
||||||
|
* GET - Get branch protection rules |
||||||
|
*/ |
||||||
|
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode npub to get pubkey
|
||||||
|
let ownerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub' && typeof decoded.data === 'string') { |
||||||
|
ownerPubkey = decoded.data; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
const config = await branchProtectionService.getBranchProtection(ownerPubkey, repo); |
||||||
|
|
||||||
|
if (!config) { |
||||||
|
return json({ rules: [] }); |
||||||
|
} |
||||||
|
|
||||||
|
return json(config); |
||||||
|
} catch (err) { |
||||||
|
// Security: Sanitize error messages
|
||||||
|
const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to get branch protection'; |
||||||
|
console.error('Error getting branch protection:', sanitizedError); |
||||||
|
return error(500, sanitizedError); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* POST - Update branch protection rules |
||||||
|
*/ |
||||||
|
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const body = await request.json(); |
||||||
|
const { userPubkey, rules } = body; |
||||||
|
|
||||||
|
if (!userPubkey) { |
||||||
|
return error(401, 'Authentication required'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!Array.isArray(rules)) { |
||||||
|
return error(400, 'Rules must be an array'); |
||||||
|
} |
||||||
|
|
||||||
|
// Decode npub to get pubkey
|
||||||
|
let ownerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub' && typeof decoded.data === 'string') { |
||||||
|
ownerPubkey = decoded.data; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
let userPubkeyHex: string = userPubkey; |
||||||
|
try { |
||||||
|
const userDecoded = nip19.decode(userPubkey) as { type: string; data: unknown }; |
||||||
|
// Type guard: check if it's an npub
|
||||||
|
if (userDecoded.type === 'npub' && typeof userDecoded.data === 'string') { |
||||||
|
userPubkeyHex = userDecoded.data; |
||||||
|
} |
||||||
|
// If not npub, assume it's already hex
|
||||||
|
} catch { |
||||||
|
// Assume it's already hex
|
||||||
|
} |
||||||
|
|
||||||
|
// Check if user is owner
|
||||||
|
const currentOwner = await ownershipTransferService.getCurrentOwner(ownerPubkey, repo); |
||||||
|
if (userPubkeyHex !== currentOwner) { |
||||||
|
return error(403, 'Only the repository owner can update branch protection'); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate rules
|
||||||
|
const validatedRules: BranchProtectionRule[] = rules.map((rule: any) => ({ |
||||||
|
branch: rule.branch, |
||||||
|
requirePullRequest: rule.requirePullRequest || false, |
||||||
|
requireReviewers: rule.requireReviewers || [], |
||||||
|
allowForcePush: rule.allowForcePush || false, |
||||||
|
requireStatusChecks: rule.requireStatusChecks || [], |
||||||
|
allowedMaintainers: rule.allowedMaintainers || [] |
||||||
|
})); |
||||||
|
|
||||||
|
// Create protection event
|
||||||
|
const protectionEvent = branchProtectionService.createProtectionEvent( |
||||||
|
currentOwner, |
||||||
|
repo, |
||||||
|
validatedRules |
||||||
|
); |
||||||
|
|
||||||
|
// Sign and publish
|
||||||
|
const signedEvent = await signEventWithNIP07(protectionEvent); |
||||||
|
|
||||||
|
const { outbox } = await getUserRelays(currentOwner, nostrClient); |
||||||
|
const combinedRelays = combineRelays(outbox); |
||||||
|
|
||||||
|
const result = await nostrClient.publishEvent(signedEvent, combinedRelays); |
||||||
|
|
||||||
|
if (result.success.length === 0) { |
||||||
|
return error(500, 'Failed to publish branch protection rules to relays'); |
||||||
|
} |
||||||
|
|
||||||
|
return json({ success: true, event: signedEvent, rules: validatedRules }); |
||||||
|
} catch (err) { |
||||||
|
// Security: Sanitize error messages
|
||||||
|
const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to update branch protection'; |
||||||
|
console.error('Error updating branch protection:', sanitizedError); |
||||||
|
return error(500, sanitizedError); |
||||||
|
} |
||||||
|
}; |
||||||
Loading…
Reference in new issue