Browse Source

improving logging

consolidating documentation
double-checking NIP implementations
getting rid of hard-coded kind numbers
bug-fixing
main
Silberengel 4 weeks ago
parent
commit
f9e302cd4b
  1. 73
      README.md
  2. 178
      docs/01.md
  3. 78
      docs/02.md
  4. 32
      docs/07.md
  5. 53
      docs/09.md
  6. 84
      docs/10.md
  7. 69
      docs/19.md
  8. 200
      docs/22.md
  9. 2
      docs/34.md
  10. 43
      docs/65.md
  11. 51
      docs/84.md
  12. 63
      docs/98.md
  13. 352
      docs/ARCHITECTURE_FAQ.md
  14. 0
      docs/IMPLEMENTATION.md
  15. 123
      docs/LOGGING.md
  16. 1073
      docs/NIP_COMPLIANCE.md
  17. 0
      docs/SECURITY.md
  18. 0
      docs/SECURITY_IMPLEMENTATION.md
  19. 4
      k8s/README.md
  20. 249
      package-lock.json
  21. 2
      package.json
  22. 37
      src/lib/components/PRDetail.svelte
  23. 8
      src/lib/services/git/file-manager.ts
  24. 8
      src/lib/services/git/repo-manager.ts
  25. 204
      src/lib/services/nostr/branch-protection-service.ts
  26. 68
      src/lib/services/nostr/fork-count-service.ts
  27. 26
      src/lib/services/nostr/highlights-service.ts
  28. 20
      src/lib/services/nostr/issues-service.ts
  29. 7
      src/lib/services/nostr/nip98-auth.ts
  30. 8
      src/lib/services/nostr/ownership-transfer-service.ts
  31. 13
      src/lib/services/nostr/prs-service.ts
  32. 5
      src/lib/services/nostr/relay-write-proof.ts
  33. 6
      src/lib/services/nostr/repo-polling.ts
  34. 8
      src/lib/services/nostr/user-relays.ts
  35. 12
      src/lib/services/security/audit-logger.ts
  36. 32
      src/lib/types/nostr.ts
  37. 83
      src/lib/utils/security.ts
  38. 42
      src/routes/+page.svelte
  39. 50
      src/routes/api/git/[...path]/+server.ts
  40. 148
      src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts
  41. 26
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  42. 131
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  43. 27
      src/routes/api/repos/[npub]/[repo]/settings/+server.ts
  44. 4
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
  45. 24
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  46. 23
      src/routes/repos/[npub]/[repo]/+page.svelte
  47. 27
      src/routes/search/+page.svelte
  48. 2
      src/routes/signup/+page.svelte
  49. 13
      src/routes/users/[npub]/+page.svelte

73
README.md

@ -1,6 +1,8 @@
# gitrepublic-web # gitrepublic-web
A decentralized, Nostr-based git server that enables git repository hosting and collaboration using Nostr events. Repositories are announced via NIP-34, and all operations (clone, push, pull) are authenticated using NIP-98 HTTP authentication.ne A decentralized, Nostr-based git server that enables git repository hosting and collaboration using Nostr events. Repositories are announced via NIP-34, and all operations (clone, push, pull) are authenticated using NIP-98 HTTP authentication.
See [ARCHITECTURE_FAQ.md](./docs/ARCHITECTURE_FAQ.md) for answers to common architecture questions.
## Features ## Features
@ -19,6 +21,7 @@ A decentralized, Nostr-based git server that enables git repository hosting and
- **Maintainer Management**: Add/remove maintainers who can push to repositories - **Maintainer Management**: Add/remove maintainers who can push to repositories
- **Forking**: Fork repositories with automatic announcement creation and ownership setup - **Forking**: Fork repositories with automatic announcement creation and ownership setup
- **Repository Settings**: Manage privacy, maintainers, and description via web UI - **Repository Settings**: Manage privacy, maintainers, and description via web UI
- **Branch Protection**: Protect branches from direct pushes, require pull requests and reviews
### Collaboration Features ### Collaboration Features
- **Issues**: Create and manage issues (kind 1621) with status tracking - **Issues**: Create and manage issues (kind 1621) with status tracking
@ -55,43 +58,47 @@ A decentralized, Nostr-based git server that enables git repository hosting and
## Nostr Event Kinds Used ## Nostr Event Kinds Used
This project uses the following Nostr event kinds: This project uses the following Nostr event kinds. For complete JSON examples and tag documentation, see [docs/NIP_COMPLIANCE.md](./docs/NIP_COMPLIANCE.md#complete-event-kind-reference).
### Repository Management ### Standard NIP Event Kinds
- **30617** (`REPO_ANNOUNCEMENT`): Repository announcements (NIP-34) - **30617** (`REPO_ANNOUNCEMENT`): Repository announcements (NIP-34)
- Tags: `d` (repo name), `name`, `description`, `clone`, `web`, `relays`, `maintainers`, `image`, `banner`, `private` - **30618** (`REPO_STATE`): Repository state announcements (NIP-34, optional)
- **30618** (`REPO_STATE`): Repository state announcements (optional) - **1617** (`PATCH`): Git patches (NIP-34)
- **1618** (`PULL_REQUEST`): Pull request events (NIP-34)
- **1619** (`PULL_REQUEST_UPDATE`): Pull request updates (NIP-34)
- **1621** (`ISSUE`): Issue events (NIP-34)
- **1630** (`STATUS_OPEN`): Open status (NIP-34)
- **1631** (`STATUS_APPLIED`): Applied/merged status (NIP-34)
- **1632** (`STATUS_CLOSED`): Closed status (NIP-34)
- **1633** (`STATUS_DRAFT`): Draft status (NIP-34)
- **9802** (`HIGHLIGHT`): NIP-84 highlight events for code selections
- **1111** (`COMMENT`): NIP-22 comment events for threaded discussions
- **27235** (`NIP98_AUTH`): NIP-98 HTTP authentication events
- **3**: Contact list (NIP-02, for relay discovery)
- **10002**: Relay list metadata (NIP-65, for relay discovery)
- **1**: Text note (NIP-01, for relay write proof, fallback)
- **5**: Event deletion request (NIP-09)
### Custom Event Kinds
These are not part of any NIP but are used by this application:
- **1640** (`COMMIT_SIGNATURE`): Git commit signature events
- Used to cryptographically sign git commits using Nostr keys
- Tags: `commit` (hash), `author` (name, email), `message` (commit message), `e` (NIP-98 auth event reference, optional)
- See [docs/NIP_COMPLIANCE.md](./docs/NIP_COMPLIANCE.md#1640---commit_signature) for complete example
- **1641** (`OWNERSHIP_TRANSFER`): Repository ownership transfer events (non-replaceable) - **1641** (`OWNERSHIP_TRANSFER`): Repository ownership transfer events (non-replaceable)
- Transfers ownership from one pubkey to another - Transfers ownership from one pubkey to another
- Self-transfers (owner → owner) used for initial ownership proof - Self-transfers (owner → owner) used for initial ownership proof
- Non-replaceable to maintain immutable chain of ownership - Non-replaceable to maintain immutable chain of ownership
- Tags: `a` (repo identifier), `p` (new owner), `d` (repo name), `t` (self-transfer marker, optional)
- See [docs/NIP_COMPLIANCE.md](./docs/NIP_COMPLIANCE.md#1641---ownership_transfer) for complete example
### Collaboration - **30620** (`BRANCH_PROTECTION`): Branch protection rules (replaceable)
- **1617** (`PATCH`): Git patches - Allows requiring pull requests, reviewers, status checks for protected branches
- **1618** (`PULL_REQUEST`): Pull request events - Tags: `d` (repo name), `a` (repo identifier), `branch` (branch name and protection settings)
- **1619** (`PULL_REQUEST_UPDATE`): Pull request updates - See [docs/NIP_COMPLIANCE.md](./docs/NIP_COMPLIANCE.md#30620---branch_protection) for complete example
- **1621** (`ISSUE`): Issue events
- **1630** (`STATUS_OPEN`): Open status
- **1631** (`STATUS_APPLIED`): Applied/merged status
- **1632** (`STATUS_CLOSED`): Closed status
- **1633** (`STATUS_DRAFT`): Draft status
- **1640** (`COMMIT_SIGNATURE`): Git commit signature events
- Tags: `author` (name, email), `message` (commit message), `commit` (commit hash), `e` (NIP-98 auth event reference, optional)
### Highlights & Comments
- **9802** (`HIGHLIGHT`): NIP-84 highlight events for code selections
- Tags: `a` (anchor), `r` (range), `p` (position), `context`, `file`, `start_line`, `end_line`, `start_pos`, `end_pos`
- **1111** (`COMMENT`): NIP-22 comment events for threaded discussions
- Tags: `A` (root event), `K` (root kind), `P` (parent event), `a`, `k`, `p` (for replies)
### Authentication
- **27235** (`NIP98_AUTH`): NIP-98 HTTP authentication events
- Tags: `u` (URL), `method` (HTTP method), `payload` (SHA256 hash of request body)
### Relay Discovery
- **3**: Contact list (for relay discovery)
- **10002**: Relay list metadata (for relay discovery)
- **1**: Text note (for relay write proof, fallback)
## How It Works ## How It Works
@ -333,7 +340,7 @@ npm run dev
- **Resource Quotas**: Per-tenant CPU, memory, and storage limits - **Resource Quotas**: Per-tenant CPU, memory, and storage limits
- **Separate Volumes**: Each tenant has their own PersistentVolume - **Separate Volumes**: Each tenant has their own PersistentVolume
See `SECURITY.md` and `SECURITY_IMPLEMENTATION.md` for detailed information. See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed information.
## Environment Variables ## Environment Variables
@ -433,7 +440,7 @@ Requires NIP-98 authentication. Your git client needs to support NIP-98 or you c
- **Resource Quotas**: Per-tenant CPU, memory, and storage limits - **Resource Quotas**: Per-tenant CPU, memory, and storage limits
- **Separate Volumes**: Each tenant has their own PersistentVolume - **Separate Volumes**: Each tenant has their own PersistentVolume
See `SECURITY.md` and `SECURITY_IMPLEMENTATION.md` for detailed information. See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed information.
## Security Considerations ## Security Considerations

178
docs/01.md

@ -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.

78
docs/02.md

@ -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.

32
docs/07.md

@ -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.

53
docs/09.md

@ -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.

84
docs/10.md

@ -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.

69
docs/19.md

@ -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.

200
docs/22.md

@ -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
}
```

2
NIP-34.md → docs/34.md

@ -245,4 +245,4 @@ The event SHOULD include a list of `g` tags with grasp service websocket URLs in
## Possible things to be added later ## Possible things to be added later
- inline file comments kind (we probably need one for patches and a different one for merged files) - inline file comments kind (we probably need one for patches and a different one for merged files)

43
docs/65.md

@ -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).

51
docs/84.md

@ -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.

63
docs/98.md

@ -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)

352
docs/ARCHITECTURE_FAQ.md

@ -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
IMPLEMENTATION.md → docs/IMPLEMENTATION.md

123
docs/LOGGING.md

@ -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.

1073
docs/NIP_COMPLIANCE.md

File diff suppressed because it is too large Load Diff

0
SECURITY.md → docs/SECURITY.md

0
SECURITY_IMPLEMENTATION.md → docs/SECURITY_IMPLEMENTATION.md

4
k8s/README.md

@ -14,7 +14,7 @@ This directory contains Kubernetes manifests for enterprise-grade multi-tenant d
### Lightweight Mode (Single Container) ### Lightweight Mode (Single Container)
- Application-level security controls - Application-level security controls
- Works with current Docker setup - Works with current Docker setup
- See `SECURITY_IMPLEMENTATION.md` for details - See `../docs/SECURITY_IMPLEMENTATION.md` for details
## Directory Structure ## Directory Structure
@ -51,7 +51,7 @@ export GIT_DOMAIN="git.example.com"
export NOSTR_RELAYS="wss://relay1.com,wss://relay2.com" export NOSTR_RELAYS="wss://relay1.com,wss://relay2.com"
export STORAGE_CLASS="fast-ssd" export STORAGE_CLASS="fast-ssd"
# Replace variables in templates # Replace variables in templatesa
envsubst < k8s/base/namespace.yaml | kubectl apply -f - envsubst < k8s/base/namespace.yaml | kubectl apply -f -
envsubst < k8s/base/resource-quota.yaml | kubectl apply -f - envsubst < k8s/base/resource-quota.yaml | kubectl apply -f -
envsubst < k8s/base/limit-range.yaml | kubectl apply -f - envsubst < k8s/base/limit-range.yaml | kubectl apply -f -

249
package-lock.json generated

@ -20,6 +20,8 @@
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"simple-git": "^3.31.1", "simple-git": "^3.31.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"ws": "^8.19.0" "ws": "^8.19.0"
@ -1227,6 +1229,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -2204,6 +2212,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -2433,6 +2450,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@ -2477,6 +2500,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2552,6 +2584,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -2819,6 +2860,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
"integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2870,6 +2917,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -3127,6 +3180,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/highlight.js": { "node_modules/highlight.js": {
"version": "11.11.1", "version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
@ -3288,6 +3347,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@ -3492,7 +3560,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@ -3599,11 +3666,19 @@
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
@ -3738,6 +3813,79 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-pretty": {
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
"fast-copy": "^4.0.0",
"fast-safe-stringify": "^2.1.1",
"help-me": "^5.0.0",
"joycon": "^3.1.1",
"minimist": "^1.2.6",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pump": "^3.0.0",
"secure-json-parse": "^4.0.0",
"sonic-boom": "^4.0.1",
"strip-json-comments": "^5.0.2"
},
"bin": {
"pino-pretty": "bin.js"
}
},
"node_modules/pino-pretty/node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -3792,6 +3940,32 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3832,6 +4006,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -3858,6 +4038,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -3997,6 +4186,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/sander": { "node_modules/sander": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz",
@ -4024,6 +4222,22 @@
"rimraf": "bin.js" "rimraf": "bin.js"
} }
}, },
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -4105,6 +4319,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/sorcery": { "node_modules/sorcery": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz",
@ -4130,6 +4353,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/strip-ansi": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -4328,6 +4560,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4540,7 +4784,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {

2
package.json

@ -24,6 +24,8 @@
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"simple-git": "^3.31.1", "simple-git": "^3.31.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"ws": "^8.19.0" "ws": "^8.19.0"

37
src/lib/components/PRDetail.svelte

@ -393,8 +393,22 @@
<!-- Highlight Dialog --> <!-- Highlight Dialog -->
{#if showHighlightDialog} {#if showHighlightDialog}
<div class="modal-overlay" onclick={() => showHighlightDialog = false}> <div
<div class="modal" onclick={(e) => e.stopPropagation()}> class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Create highlight"
tabindex="-1"
onclick={() => showHighlightDialog = false}
onkeydown={(e) => {
if (e.key === 'Escape') {
showHighlightDialog = false;
}
}}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<h3>Create Highlight</h3> <h3>Create Highlight</h3>
<div class="selected-code"> <div class="selected-code">
<pre><code>{selectedText}</code></pre> <pre><code>{selectedText}</code></pre>
@ -415,8 +429,23 @@
<!-- Comment Dialog --> <!-- Comment Dialog -->
{#if showCommentDialog} {#if showCommentDialog}
<div class="modal-overlay" onclick={() => { showCommentDialog = false; replyingTo = null; }}> <div
<div class="modal" onclick={(e) => e.stopPropagation()}> class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label={replyingTo ? 'Reply to comment' : 'Add comment'}
tabindex="-1"
onclick={() => { showCommentDialog = false; replyingTo = null; }}
onkeydown={(e) => {
if (e.key === 'Escape') {
showCommentDialog = false;
replyingTo = null;
}
}}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<h3>{replyingTo ? 'Reply to Comment' : 'Add Comment'}</h3> <h3>{replyingTo ? 'Reply to Comment' : 'Add Comment'}</h3>
<label> <label>
Comment: Comment:

8
src/lib/services/git/file-manager.ts

@ -412,7 +412,9 @@ export class FileManager {
); );
finalCommitMessage = signedMessage; finalCommitMessage = signedMessage;
} catch (err) { } catch (err) {
console.warn('Failed to sign commit:', err); // Security: Sanitize error messages (never log private keys)
const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err);
console.warn('Failed to sign commit:', sanitizedErr);
// Continue without signature if signing fails // Continue without signature if signing fails
} }
} }
@ -581,7 +583,9 @@ export class FileManager {
); );
finalCommitMessage = signedMessage; finalCommitMessage = signedMessage;
} catch (err) { } catch (err) {
console.warn('Failed to sign commit:', err); // Security: Sanitize error messages (never log private keys)
const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err);
console.warn('Failed to sign commit:', sanitizedErr);
// Continue without signature if signing fails // Continue without signature if signing fails
} }
} }

8
src/lib/services/git/repo-manager.ts

@ -300,12 +300,14 @@ export class RepoManager {
if (nsecKey) { if (nsecKey) {
try { try {
const { createGitCommitSignature } = await import('./commit-signer.js'); const { createGitCommitSignature } = await import('./commit-signer.js');
const { signedMessage } = createGitCommitSignature( const { signedMessage } = await createGitCommitSignature(
nsecKey,
commitMessage, commitMessage,
'Nostr', 'Nostr',
`${event.pubkey}@nostr`, `${event.pubkey}@nostr`,
event.created_at {
nsecKey,
timestamp: event.created_at
}
); );
commitMessage = signedMessage; commitMessage = signedMessage;
} catch (err) { } catch (err) {

204
src/lib/services/nostr/branch-protection-service.ts

@ -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 };
}
}

68
src/lib/services/nostr/fork-count-service.ts

@ -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);
}
}

26
src/lib/services/nostr/highlights-service.ts

@ -51,14 +51,14 @@ export class HighlightsService {
* Get repository announcement address (a tag format) * Get repository announcement address (a tag format)
*/ */
private getRepoAddress(repoOwnerPubkey: string, repoId: string): string { private getRepoAddress(repoOwnerPubkey: string, repoId: string): string {
return `30617:${repoOwnerPubkey}:${repoId}`; return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`;
} }
/** /**
* Get PR address (a tag format for PR) * Get PR address (a tag format for PR)
*/ */
private getPRAddress(prId: string, prAuthor: string, repoOwnerPubkey: string, repoId: string): string { private getPRAddress(prId: string, prAuthor: string, repoOwnerPubkey: string, repoId: string): string {
return `1618:${prAuthor}:${repoId}`; return `${KIND.PULL_REQUEST}:${prAuthor}:${repoId}`;
} }
/** /**
@ -231,7 +231,7 @@ export class HighlightsService {
const comments = await this.nostrClient.fetchEvents([ const comments = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.COMMENT], kinds: [KIND.COMMENT],
'#E': [prId], // Root event (uppercase E) '#e': [prId], // Root event (lowercase e for filter)
limit: 100 limit: 100
} }
]) as NostrEvent[]; ]) as NostrEvent[];
@ -287,7 +287,7 @@ export class HighlightsService {
context?: string, context?: string,
comment?: string comment?: string
): Omit<NostrEvent, 'sig' | 'id'> { ): Omit<NostrEvent, 'sig' | 'id'> {
const prAddress = `1618:${prAuthor}:${repoId}`; const prAddress = `${KIND.PULL_REQUEST}:${prAuthor}:${repoId}`;
const tags: string[][] = [ const tags: string[][] = [
['a', prAddress], // Reference to PR ['a', prAddress], // Reference to PR
@ -346,31 +346,33 @@ export class HighlightsService {
parentEventId?: string, parentEventId?: string,
parentEventKind?: number, parentEventKind?: number,
parentPubkey?: string, parentPubkey?: string,
rootEventAddress?: string rootEventAddress?: string,
relayHint?: string
): Omit<NostrEvent, 'sig' | 'id'> { ): Omit<NostrEvent, 'sig' | 'id'> {
const relay = relayHint || '';
const tags: string[][] = [ const tags: string[][] = [
['E', rootEventId, '', rootPubkey], // Root event ['E', rootEventId, relay, rootPubkey], // Root event (NIP-22: id, relay hint, pubkey)
['K', rootEventKind.toString()], // Root kind ['K', rootEventKind.toString()], // Root kind
['P', rootPubkey], // Root author ['P', rootPubkey, relay], // Root author (with relay hint)
]; ];
// Add root event address if provided (for replaceable events) // Add root event address if provided (for replaceable events)
if (rootEventAddress) { if (rootEventAddress) {
tags.push(['A', rootEventAddress]); tags.push(['A', rootEventAddress, relay]);
} }
// Add parent references (for replies) // Add parent references (for replies)
if (parentEventId) { if (parentEventId) {
tags.push(['e', parentEventId, '', parentPubkey || rootPubkey]); tags.push(['e', parentEventId, relay, parentPubkey || rootPubkey]);
tags.push(['k', (parentEventKind || rootEventKind).toString()]); tags.push(['k', (parentEventKind || rootEventKind).toString()]);
if (parentPubkey) { if (parentPubkey) {
tags.push(['p', parentPubkey]); tags.push(['p', parentPubkey, relay]);
} }
} else { } else {
// Top-level comment - parent is same as root // Top-level comment - parent is same as root
tags.push(['e', rootEventId, '', rootPubkey]); tags.push(['e', rootEventId, relay, rootPubkey]);
tags.push(['k', rootEventKind.toString()]); tags.push(['k', rootEventKind.toString()]);
tags.push(['p', rootPubkey]); tags.push(['p', rootPubkey, relay]);
} }
return { return {

20
src/lib/services/nostr/issues-service.ts

@ -25,7 +25,15 @@ export class IssuesService {
* Get repository announcement address (a tag format) * Get repository announcement address (a tag format)
*/ */
private getRepoAddress(repoOwnerPubkey: string, repoId: string): string { private getRepoAddress(repoOwnerPubkey: string, repoId: string): string {
return `30617:${repoOwnerPubkey}:${repoId}`; return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`;
}
/**
* Get earliest unique commit ID from repo announcement
*/
private getEarliestUniqueCommit(announcement: NostrEvent): string | null {
const eucTag = announcement.tags.find(t => t[0] === 'r' && t[2] === 'euc');
return eucTag?.[1] || null;
} }
/** /**
@ -36,7 +44,7 @@ export class IssuesService {
if (!aTag || !aTag[1]) return null; if (!aTag || !aTag[1]) return null;
const parts = aTag[1].split(':'); const parts = aTag[1].split(':');
if (parts.length !== 3 || parts[0] !== '30617') return null; if (parts.length !== 3 || parts[0] !== KIND.REPO_ANNOUNCEMENT.toString()) return null;
return { owner: parts[1], id: parts[2] }; return { owner: parts[1], id: parts[2] };
} }
@ -106,7 +114,8 @@ export class IssuesService {
repoId: string, repoId: string,
subject: string, subject: string,
content: string, content: string,
labels: string[] = [] labels: string[] = [],
earliestUniqueCommit?: string | null
): Promise<Issue> { ): Promise<Issue> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
@ -116,6 +125,11 @@ export class IssuesService {
['subject', subject] ['subject', subject]
]; ];
// Add earliest unique commit if provided (NIP-34 compliance)
if (earliestUniqueCommit) {
tags.push(['r', earliestUniqueCommit]);
}
// Add labels // Add labels
for (const label of labels) { for (const label of labels) {
tags.push(['t', label]); tags.push(['t', label]);

7
src/lib/services/nostr/nip98-auth.ts

@ -5,6 +5,7 @@
import { verifyEvent } from 'nostr-tools'; import { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/nostr.js';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
export interface NIP98AuthResult { export interface NIP98AuthResult {
@ -44,10 +45,10 @@ export function verifyNIP98Auth(
const nostrEvent: NostrEvent = JSON.parse(eventJson); const nostrEvent: NostrEvent = JSON.parse(eventJson);
// Validate kind (must be 27235) // Validate kind (must be 27235)
if (nostrEvent.kind !== 27235) { if (nostrEvent.kind !== KIND.NIP98_AUTH) {
return { return {
valid: false, valid: false,
error: `Invalid event kind. Expected 27235, got ${nostrEvent.kind}` error: `Invalid event kind. Expected ${KIND.NIP98_AUTH}, got ${nostrEvent.kind}`
}; };
} }
@ -185,7 +186,7 @@ export function createNIP98AuthEvent(
} }
return { return {
kind: 27235, kind: KIND.NIP98_AUTH,
pubkey, pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
content: '', content: '',

8
src/lib/services/nostr/ownership-transfer-service.ts

@ -49,7 +49,7 @@ export class OwnershipTransferService {
try { try {
// Fetch all ownership transfer events for this repo // Fetch all ownership transfer events for this repo
// We use the 'a' tag to reference the repo announcement // We use the 'a' tag to reference the repo announcement
const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repoId}`;
const transferEvents = await this.nostrClient.fetchEvents([ const transferEvents = await this.nostrClient.fetchEvents([
{ {
@ -131,7 +131,7 @@ export class OwnershipTransferService {
} }
// Verify 'a' tag matches this repo // Verify 'a' tag matches this repo
const expectedRepoTag = `30617:${originalOwnerPubkey}:${repoId}`; const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repoId}`;
if (aTag[1] !== expectedRepoTag) { if (aTag[1] !== expectedRepoTag) {
return null; return null;
} }
@ -224,7 +224,7 @@ export class OwnershipTransferService {
// Assume it's already a hex pubkey // Assume it's already a hex pubkey
} }
const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repoId}`;
const isSelfTransfer = fromPubkey === toPubkeyHex; const isSelfTransfer = fromPubkey === toPubkeyHex;
const content = isSelfTransfer const content = isSelfTransfer
? `Initial ownership proof for repository ${repoId}` ? `Initial ownership proof for repository ${repoId}`
@ -280,7 +280,7 @@ export class OwnershipTransferService {
*/ */
async getTransferHistory(originalOwnerPubkey: string, repoId: string): Promise<OwnershipTransfer[]> { async getTransferHistory(originalOwnerPubkey: string, repoId: string): Promise<OwnershipTransfer[]> {
try { try {
const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repoId}`;
const transferEvents = await this.nostrClient.fetchEvents([ const transferEvents = await this.nostrClient.fetchEvents([
{ {

13
src/lib/services/nostr/prs-service.ts

@ -25,7 +25,15 @@ export class PRsService {
* Get repository announcement address (a tag format) * Get repository announcement address (a tag format)
*/ */
private getRepoAddress(repoOwnerPubkey: string, repoId: string): string { private getRepoAddress(repoOwnerPubkey: string, repoId: string): string {
return `30617:${repoOwnerPubkey}:${repoId}`; return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`;
}
/**
* Get earliest unique commit ID from repo announcement
*/
private getEarliestUniqueCommit(announcement: NostrEvent): string | null {
const eucTag = announcement.tags.find(t => t[0] === 'r' && t[2] === 'euc');
return eucTag?.[1] || null;
} }
/** /**
@ -169,6 +177,9 @@ export class PRsService {
['a', repoAddress] ['a', repoAddress]
]; ];
// Note: earliest unique commit should be added by caller if available
// We don't have access to repo announcement here, so it's optional
if (status === 'merged' && mergeCommitId) { if (status === 'merged' && mergeCommitId) {
tags.push(['merge-commit', mergeCommitId]); tags.push(['merge-commit', mergeCommitId]);
tags.push(['r', mergeCommitId]); tags.push(['r', mergeCommitId]);

5
src/lib/services/nostr/relay-write-proof.ts

@ -8,6 +8,7 @@
import { verifyEvent } from 'nostr-tools'; import { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/nostr.js';
import { NostrClient } from './nostr-client.js'; import { NostrClient } from './nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '../../config.js'; import { DEFAULT_NOSTR_RELAYS } from '../../config.js';
@ -45,7 +46,7 @@ export async function verifyRelayWriteProof(
// Determine time window based on event kind // Determine time window based on event kind
// NIP-98 events (27235) should be within 60 seconds per spec // NIP-98 events (27235) should be within 60 seconds per spec
// Other events (like kind 1) can be within 5 minutes // Other events (like kind 1) can be within 5 minutes
const isNIP98Event = proofEvent.kind === 27235; const isNIP98Event = proofEvent.kind === KIND.NIP98_AUTH;
const maxAge = isNIP98Event ? 60 : 300; // 60 seconds for NIP-98, 5 minutes for others const maxAge = isNIP98Event ? 60 : 300; // 60 seconds for NIP-98, 5 minutes for others
// Verify the event is recent // Verify the event is recent
@ -120,7 +121,7 @@ export async function verifyRelayWriteProof(
*/ */
export function createProofEvent(userPubkey: string, content: string = 'gitrepublic-write-proof'): Omit<NostrEvent, 'sig' | 'id'> { export function createProofEvent(userPubkey: string, content: string = 'gitrepublic-write-proof'): Omit<NostrEvent, 'sig' | 'id'> {
return { return {
kind: 1, kind: KIND.TEXT_NOTE,
pubkey: userPubkey, pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
content: content, content: content,

6
src/lib/services/nostr/repo-polling.ts

@ -97,7 +97,7 @@ export class RepoPollingService {
// Fetch self-transfer event for this repo // Fetch self-transfer event for this repo
const ownershipService = new OwnershipTransferService(this.relays); const ownershipService = new OwnershipTransferService(this.relays);
const repoTag = `30617:${event.pubkey}:${dTag}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${event.pubkey}:${dTag}`;
const selfTransferEvents = await this.nostrClient.fetchEvents([ const selfTransferEvents = await this.nostrClient.fetchEvents([
{ {
@ -134,7 +134,9 @@ export class RepoPollingService {
// For existing repos without self-transfer, create one retroactively // For existing repos without self-transfer, create one retroactively
if (isExistingRepo && !selfTransferEvent) { if (isExistingRepo && !selfTransferEvent) {
console.log(`Existing repo ${dTag} from ${event.pubkey} has no self-transfer event. Creating template for owner to sign and publish.`); // Security: Truncate pubkey in logs
const truncatedPubkey = event.pubkey.length > 16 ? `${event.pubkey.slice(0, 8)}...${event.pubkey.slice(-4)}` : event.pubkey;
console.log(`Existing repo ${dTag} from ${truncatedPubkey} has no self-transfer event. Creating template for owner to sign and publish.`);
try { try {
// Create a self-transfer event template for the existing repo // Create a self-transfer event template for the existing repo

8
src/lib/services/nostr/user-relays.ts

@ -4,9 +4,7 @@
import { NostrClient } from './nostr-client.js'; import { NostrClient } from './nostr-client.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/nostr.js';
const KIND_RELAY_LIST = 10002;
const KIND_CONTACTS = 3;
export async function getUserRelays( export async function getUserRelays(
pubkey: string, pubkey: string,
@ -19,7 +17,7 @@ export async function getUserRelays(
// Fetch kind 10002 (relay list) // Fetch kind 10002 (relay list)
const relayListEvents = await nostrClient.fetchEvents([ const relayListEvents = await nostrClient.fetchEvents([
{ {
kinds: [KIND_RELAY_LIST], kinds: [KIND.RELAY_LIST],
authors: [pubkey], authors: [pubkey],
limit: 1 limit: 1
} }
@ -43,7 +41,7 @@ export async function getUserRelays(
if (inbox.length === 0 && outbox.length === 0) { if (inbox.length === 0 && outbox.length === 0) {
const contactEvents = await nostrClient.fetchEvents([ const contactEvents = await nostrClient.fetchEvents([
{ {
kinds: [KIND_CONTACTS], kinds: [KIND.CONTACT_LIST],
authors: [pubkey], authors: [pubkey],
limit: 1 limit: 1
} }

12
src/lib/services/security/audit-logger.ts

@ -14,6 +14,7 @@
import { appendFile, mkdir, readdir, unlink, stat } from 'fs/promises'; import { appendFile, mkdir, readdir, unlink, stat } from 'fs/promises';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { truncatePubkey, sanitizeError, redactSensitiveData } from '../../utils/security.js';
export interface AuditLogEntry { export interface AuditLogEntry {
timestamp: string; timestamp: string;
@ -176,23 +177,28 @@ export class AuditLogger {
/** /**
* Log an audit event * Log an audit event
* Automatically truncates pubkeys and redacts sensitive data
*/ */
log(entry: Omit<AuditLogEntry, 'timestamp'>): void { log(entry: Omit<AuditLogEntry, 'timestamp'>): void {
if (!this.enabled) return; if (!this.enabled) return;
const fullEntry: AuditLogEntry = { // Sanitize entry: truncate pubkeys, redact sensitive data
const sanitizedEntry: AuditLogEntry = {
...entry, ...entry,
user: entry.user ? truncatePubkey(entry.user) : undefined,
error: entry.error ? sanitizeError(entry.error) : undefined,
metadata: entry.metadata ? redactSensitiveData(entry.metadata) : undefined,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
// Log to console (structured JSON) // Log to console (structured JSON)
const logLine = JSON.stringify(fullEntry); const logLine = JSON.stringify(sanitizedEntry);
console.log(`[AUDIT] ${logLine}`); console.log(`[AUDIT] ${logLine}`);
// Write to file if configured (async, non-blocking) // Write to file if configured (async, non-blocking)
if (this.logFile) { if (this.logFile) {
this.writeToFile(logLine).catch(err => { this.writeToFile(logLine).catch(err => {
console.error('[AUDIT] Failed to write log entry:', err); console.error('[AUDIT] Failed to write log entry:', sanitizeError(err));
}); });
} }
} }

32
src/lib/types/nostr.ts

@ -26,20 +26,26 @@ export interface NostrFilter {
} }
export const KIND = { export const KIND = {
REPO_ANNOUNCEMENT: 30617, TEXT_NOTE: 1, // NIP-01: Text note (used for relay write proof fallback)
REPO_STATE: 30618, CONTACT_LIST: 3, // NIP-02: Contact list
PATCH: 1617, DELETION_REQUEST: 5, // NIP-09: Event deletion request
PULL_REQUEST: 1618, REPO_ANNOUNCEMENT: 30617, // NIP-34: Repository announcement
PULL_REQUEST_UPDATE: 1619, REPO_STATE: 30618, // NIP-34: Repository state
ISSUE: 1621, PATCH: 1617, // NIP-34: Git patch
STATUS_OPEN: 1630, PULL_REQUEST: 1618, // NIP-34: Pull request
STATUS_APPLIED: 1631, PULL_REQUEST_UPDATE: 1619, // NIP-34: Pull request update
STATUS_CLOSED: 1632, ISSUE: 1621, // NIP-34: Issue
STATUS_DRAFT: 1633, STATUS_OPEN: 1630, // NIP-34: Status open
COMMIT_SIGNATURE: 1640, // Git commit signature event STATUS_APPLIED: 1631, // NIP-34: Status applied/merged
OWNERSHIP_TRANSFER: 1641, // Repository ownership transfer event (non-replaceable for chain integrity) STATUS_CLOSED: 1632, // NIP-34: Status closed
HIGHLIGHT: 9802, // NIP-84: Highlight event STATUS_DRAFT: 1633, // NIP-34: Status draft
COMMIT_SIGNATURE: 1640, // Custom: Git commit signature event
OWNERSHIP_TRANSFER: 1641, // Custom: Repository ownership transfer event (non-replaceable for chain integrity)
COMMENT: 1111, // NIP-22: Comment event COMMENT: 1111, // NIP-22: Comment event
BRANCH_PROTECTION: 30620, // Custom: Branch protection rules
RELAY_LIST: 10002, // NIP-65: Relay list metadata
NIP98_AUTH: 27235, // NIP-98: HTTP authentication event
HIGHLIGHT: 9802, // NIP-84: Highlight event
} as const; } as const;
export interface Issue extends NostrEvent { export interface Issue extends NostrEvent {

83
src/lib/utils/security.ts

@ -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;
}

42
src/routes/+page.svelte

@ -7,13 +7,16 @@
import type { NostrEvent } from '../lib/types/nostr.js'; import type { NostrEvent } from '../lib/types/nostr.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js';
import { ForkCountService } from '../lib/services/nostr/fork-count-service.js';
let repos = $state<NostrEvent[]>([]); let repos = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let forkCounts = $state<Map<string, number>>(new Map());
import { DEFAULT_NOSTR_RELAYS } from '../lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '../lib/config.js';
const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
@ -83,14 +86,47 @@
// Sort by created_at descending // Sort by created_at descending
repos.sort((a, b) => b.created_at - a.created_at); repos.sort((a, b) => b.created_at - a.created_at);
// Load fork counts for all repos (in parallel, but don't block)
loadForkCounts(repos).catch(err => {
console.warn('[RepoList] Failed to load some fork counts:', err);
});
} catch (e) { } catch (e) {
error = String(e); error = String(e);
console.error('Failed to load repos:', e); console.error('[RepoList] Failed to load repos:', e);
} finally { } finally {
loading = false; loading = false;
} }
} }
async function loadForkCounts(repoEvents: NostrEvent[]) {
const counts = new Map<string, number>();
// Extract owner pubkey and repo name for each repo
const forkCountPromises = repoEvents.map(async (event) => {
try {
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) return;
const repoKey = `${event.pubkey}:${dTag}`;
const count = await forkCountService.getForkCount(event.pubkey, dTag);
counts.set(repoKey, count);
} catch (err) {
// Ignore individual failures
}
});
await Promise.all(forkCountPromises);
forkCounts = counts;
}
function getForkCount(event: NostrEvent): number {
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) return 0;
const repoKey = `${event.pubkey}:${dTag}`;
return forkCounts.get(repoKey) || 0;
}
function goToSearch() { function goToSearch() {
goto('/search'); goto('/search');
} }
@ -256,6 +292,10 @@
</div> </div>
<div class="repo-meta"> <div class="repo-meta">
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> <span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
{#if getForkCount(repo) > 0}
{@const forkCount = getForkCount(repo)}
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span>
{/if}
</div> </div>
</div> </div>
</div> </div>

50
src/routes/api/git/[...path]/+server.ts

@ -17,6 +17,7 @@ import type { NostrEvent } from '$lib/types/nostr.js';
import { verifyNIP98Auth } from '$lib/services/nostr/nip98-auth.js'; import { verifyNIP98Auth } from '$lib/services/nostr/nip98-auth.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { BranchProtectionService } from '$lib/services/nostr/branch-protection-service.js';
import { auditLogger } from '$lib/services/security/audit-logger.js'; import { auditLogger } from '$lib/services/security/audit-logger.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
@ -24,6 +25,7 @@ const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
const branchProtectionService = new BranchProtectionService(DEFAULT_NOSTR_RELAYS);
// Path to git-http-backend (common locations) // Path to git-http-backend (common locations)
// Alpine Linux: /usr/lib/git-core/git-http-backend // Alpine Linux: /usr/lib/git-core/git-http-backend
@ -395,9 +397,43 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
return error(401, authResult.error || 'Authentication required'); return error(401, authResult.error || 'Authentication required');
} }
// Verify pubkey matches current repo owner (may have been transferred) // Verify pubkey is current repo owner or maintainer
if (authResult.pubkey !== currentOwnerPubkey) { const isMaintainer = await maintainerService.isMaintainer(
return error(403, 'Event pubkey does not match repository owner'); authResult.pubkey || '',
currentOwnerPubkey,
repoName
);
if (authResult.pubkey !== currentOwnerPubkey && !isMaintainer) {
return error(403, 'Event pubkey does not match repository owner or maintainer');
}
// Check branch protection rules
// Note: We need to extract the target branch from the git push request
// This is a simplified check - in production, you'd parse the git protocol
// to determine the exact branch being pushed
try {
// Try to extract branch from request body (git protocol)
const bodyText = bodyBuffer.toString('utf-8', 0, Math.min(bodyBuffer.length, 1000));
const branchMatch = bodyText.match(/refs\/heads\/([^\s\n]+)/);
const targetBranch = branchMatch ? branchMatch[1] : 'main'; // Default to main if can't determine
const protectionCheck = await branchProtectionService.canPushToBranch(
authResult.pubkey || '',
currentOwnerPubkey,
repoName,
targetBranch,
isMaintainer
);
if (!protectionCheck.allowed) {
return error(403, protectionCheck.reason || 'Branch is protected');
}
} catch (error) {
// If we can't check protection, log but don't block (fail open for now)
// Security: Sanitize error messages
const sanitizedError = error instanceof Error ? error.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(error);
console.warn('Failed to check branch protection:', sanitizedError);
} }
} }
@ -507,12 +543,16 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
if (otherUrls.length > 0) { if (otherUrls.length > 0) {
// Sync in background (don't wait for it) // Sync in background (don't wait for it)
repoManager.syncToRemotes(repoPath, otherUrls).catch(err => { repoManager.syncToRemotes(repoPath, otherUrls).catch(err => {
console.error('Failed to sync to remotes after push:', err); // Security: Sanitize error messages
const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err);
console.error('Failed to sync to remotes after push:', sanitizedErr);
}); });
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to sync to remotes:', err); // Security: Sanitize error messages
const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err);
console.error('Failed to sync to remotes:', sanitizedErr);
// Don't fail the request if sync fails // Don't fail the request if sync fails
} }
} }

148
src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts

@ -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);
}
};

26
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -84,8 +84,10 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
throw err; throw err;
} }
} catch (err) { } catch (err) {
console.error('Error reading file:', err); // Security: Sanitize error messages to prevent leaking sensitive data
return error(500, err instanceof Error ? err.message : 'Failed to read file'); const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to read file';
console.error('Error reading file:', sanitizedError);
return error(500, sanitizedError);
} }
}; };
@ -170,6 +172,20 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
} }
// Explicitly ignore nsecKey from client requests - it's a security risk // Explicitly ignore nsecKey from client requests - it's a security risk
// Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead // Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead
if (nsecKey) {
// Security: Log warning but never log the actual key value
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
console.warn(`[SECURITY] Client attempted to send nsecKey in request from IP ${clientIp}. This is not allowed for security reasons.`);
auditLogger.log({
user: userPubkeyHex || undefined,
ip: clientIp,
action: 'auth_attempt',
resource: 'file_operation',
result: 'failure',
error: 'Client attempted to send private key in request body',
metadata: { reason: 'security_violation' }
});
}
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
@ -247,7 +263,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
return error(400, 'Invalid action or missing content'); return error(400, 'Invalid action or missing content');
} }
} catch (err) { } catch (err) {
console.error('Error writing file:', err); // Security: Sanitize error messages to prevent leaking sensitive data
return error(500, err instanceof Error ? err.message : 'Failed to write file'); const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to write file';
console.error('Error writing file:', sanitizedError);
return error(500, sanitizedError);
} }
}; };

131
src/routes/api/repos/[npub]/[repo]/fork/+server.ts

@ -18,12 +18,14 @@ import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { ResourceLimits } from '$lib/services/security/resource-limits.js'; import { ResourceLimits } from '$lib/services/security/resource-limits.js';
import { auditLogger } from '$lib/services/security/audit-logger.js'; import { auditLogger } from '$lib/services/security/audit-logger.js';
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoManager = new RepoManager(repoRoot); const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const resourceLimits = new ResourceLimits(repoRoot); const resourceLimits = new ResourceLimits(repoRoot);
const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS);
/** /**
* Retry publishing an event with exponential backoff * Retry publishing an event with exponential backoff
@ -33,34 +35,39 @@ async function publishEventWithRetry(
event: NostrEvent, event: NostrEvent,
relays: string[], relays: string[],
eventName: string, eventName: string,
maxAttempts: number = 3 maxAttempts: number = 3,
context?: string
): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { ): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> {
let lastResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null; let lastResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null;
// Extract context from event if available (for better logging)
const eventId = event.id.slice(0, 8);
const logContext = context || `[event:${eventId}]`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
console.log(`[Fork] Publishing ${eventName} - Attempt ${attempt}/${maxAttempts}...`); console.log(`[Fork] ${logContext} Publishing ${eventName} - Attempt ${attempt}/${maxAttempts}...`);
lastResult = await nostrClient.publishEvent(event, relays); lastResult = await nostrClient.publishEvent(event, relays);
if (lastResult.success.length > 0) { if (lastResult.success.length > 0) {
console.log(`[Fork] ✓ ${eventName} published successfully to ${lastResult.success.length} relay(s): ${lastResult.success.join(', ')}`); console.log(`[Fork] ${logContext} ${eventName} published successfully to ${lastResult.success.length} relay(s): ${lastResult.success.join(', ')}`);
if (lastResult.failed.length > 0) { if (lastResult.failed.length > 0) {
console.warn(`[Fork] ⚠ Some relays failed: ${lastResult.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`); console.warn(`[Fork] ${logContext} ⚠ Some relays failed: ${lastResult.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`);
} }
return lastResult; return lastResult;
} }
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
const delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s const delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
console.warn(`[Fork] ✗ ${eventName} failed on attempt ${attempt}. Retrying in ${delayMs}ms...`); console.warn(`[Fork] ${logContext} ${eventName} failed on attempt ${attempt}. Retrying in ${delayMs}ms...`);
console.warn(`[Fork] Failed relays: ${lastResult.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`); console.warn(`[Fork] ${logContext} Failed relays: ${lastResult.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`);
await new Promise(resolve => setTimeout(resolve, delayMs)); await new Promise(resolve => setTimeout(resolve, delayMs));
} }
} }
// All attempts failed // All attempts failed
console.error(`[Fork] ✗ ${eventName} failed after ${maxAttempts} attempts`); console.error(`[Fork] ${logContext} ${eventName} failed after ${maxAttempts} attempts`);
console.error(`[Fork] All relay failures: ${lastResult?.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`); console.error(`[Fork] ${logContext} All relay failures: ${lastResult?.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`);
return lastResult!; return lastResult!;
} }
@ -96,6 +103,25 @@ export const POST: RequestHandler = async ({ params, request }) => {
return error(400, 'Invalid npub format'); return error(400, 'Invalid npub format');
} }
// Decode user pubkey if needed (must be done before using it)
let userPubkeyHex = 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
}
// Convert to npub for resource check and path construction
const userNpub = nip19.npubEncode(userPubkeyHex);
// Determine fork name (use original name if not specified)
const forkRepoName = forkName || repo;
// Check resource limits before forking // Check resource limits before forking
const resourceCheck = await resourceLimits.canCreateRepo(userNpub); const resourceCheck = await resourceLimits.canCreateRepo(userNpub);
if (!resourceCheck.allowed) { if (!resourceCheck.allowed) {
@ -104,25 +130,12 @@ export const POST: RequestHandler = async ({ params, request }) => {
userPubkeyHex, userPubkeyHex,
`${npub}/${repo}`, `${npub}/${repo}`,
`${userNpub}/${forkRepoName}`, `${userNpub}/${forkRepoName}`,
'denied', 'failure',
resourceCheck.reason resourceCheck.reason
); );
return error(403, resourceCheck.reason || 'Resource limit exceeded'); return error(403, resourceCheck.reason || 'Resource limit exceeded');
} }
// Decode user pubkey if needed
let userPubkeyHex = 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 original repo exists // Check if original repo exists
const originalRepoPath = join(repoRoot, npub, `${repo}.git`); const originalRepoPath = join(repoRoot, npub, `${repo}.git`);
if (!existsSync(originalRepoPath)) { if (!existsSync(originalRepoPath)) {
@ -145,10 +158,6 @@ export const POST: RequestHandler = async ({ params, request }) => {
const originalAnnouncement = originalAnnouncements[0]; const originalAnnouncement = originalAnnouncements[0];
// Determine fork name (use original name if not specified)
const forkRepoName = forkName || repo;
const userNpub = nip19.npubEncode(userPubkeyHex);
// Check if fork already exists // Check if fork already exists
const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`); const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`);
if (existsSync(forkRepoPath)) { if (existsSync(forkRepoPath)) {
@ -195,7 +204,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
['clone', forkGitUrl, ...originalCloneUrls.filter(url => !url.includes(gitDomain))], ['clone', forkGitUrl, ...originalCloneUrls.filter(url => !url.includes(gitDomain))],
['relays', ...DEFAULT_NOSTR_RELAYS], ['relays', ...DEFAULT_NOSTR_RELAYS],
['t', 'fork'], // Mark as fork ['t', 'fork'], // Mark as fork
['a', `30617:${originalOwnerPubkey}:${repo}`], // Reference to original repo ['a', `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`], // Reference to original repo
['p', originalOwnerPubkey], // Original owner ['p', originalOwnerPubkey], // Original owner
]; ];
@ -219,19 +228,25 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { outbox } = await getUserRelays(userPubkeyHex, nostrClient); const { outbox } = await getUserRelays(userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox); const combinedRelays = combineRelays(outbox);
console.log(`[Fork] Starting fork process for ${forkRepoName} by ${userNpub}`); // Security: Truncate npub in logs and create context (must be before use)
console.log(`[Fork] Using ${combinedRelays.length} relay(s): ${combinedRelays.join(', ')}`); const truncatedNpub = userNpub.length > 16 ? `${userNpub.slice(0, 12)}...` : userNpub;
const truncatedOriginalNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub;
const context = `[${truncatedOriginalNpub}/${repo}${truncatedNpub}/${forkRepoName}]`;
console.log(`[Fork] ${context} Starting fork process`);
console.log(`[Fork] ${context} Using ${combinedRelays.length} relay(s): ${combinedRelays.join(', ')}`);
const publishResult = await publishEventWithRetry( const publishResult = await publishEventWithRetry(
signedForkAnnouncement, signedForkAnnouncement,
combinedRelays, combinedRelays,
'fork announcement', 'fork announcement',
3 3,
context
); );
if (publishResult.success.length === 0) { if (publishResult.success.length === 0) {
// Clean up repo if announcement failed // Clean up repo if announcement failed
console.error(`[Fork] ✗ Fork announcement failed after all retries. Cleaning up repository.`); console.error(`[Fork] ${context} ✗ Fork announcement failed after all retries. Cleaning up repository.`);
await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {}); await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {});
const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`; const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`;
return json({ return json({
@ -252,23 +267,24 @@ export const POST: RequestHandler = async ({ params, request }) => {
signedOwnershipEvent, signedOwnershipEvent,
combinedRelays, combinedRelays,
'ownership transfer event', 'ownership transfer event',
3 3,
context
); );
if (ownershipPublishResult.success.length === 0) { if (ownershipPublishResult.success.length === 0) {
// Clean up repo if ownership proof failed // Clean up repo if ownership proof failed
console.error(`[Fork] ✗ Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.`); console.error(`[Fork] ${context} ✗ Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.`);
await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {}); await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {});
// Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof // Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof
console.log(`[Fork] Publishing deletion request for invalid fork announcement...`); console.log(`[Fork] ${context} Publishing deletion request for invalid fork announcement...`);
const deletionRequest = { const deletionRequest = {
kind: 5, // NIP-09: Event Deletion Request kind: KIND.DELETION_REQUEST, // NIP-09: Event Deletion Request
pubkey: userPubkeyHex, pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
content: 'Fork failed: ownership transfer event could not be published after 3 attempts. This announcement is invalid.', content: 'Fork failed: ownership transfer event could not be published after 3 attempts. This announcement is invalid.',
tags: [ tags: [
['a', `30617:${userPubkeyHex}:${forkRepoName}`], // Reference to the repo announcement ['a', `${KIND.REPO_ANNOUNCEMENT}:${userPubkeyHex}:${forkRepoName}`], // Reference to the repo announcement
['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted ['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted
] ]
}; };
@ -278,13 +294,14 @@ export const POST: RequestHandler = async ({ params, request }) => {
signedDeletionRequest, signedDeletionRequest,
combinedRelays, combinedRelays,
'deletion request', 'deletion request',
3 3,
context
); );
if (deletionResult.success.length > 0) { if (deletionResult.success.length > 0) {
console.log(`[Fork] ✓ Deletion request published successfully`); console.log(`[Fork] ${context} ✓ Deletion request published successfully`);
} else { } else {
console.error(`[Fork] ✗ Failed to publish deletion request: ${deletionResult.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`); console.error(`[Fork] ${context} ✗ Failed to publish deletion request: ${deletionResult.failed.map(f => `${f.relay}: ${f.error}`).join(', ')}`);
} }
const errorDetails = `Fork is invalid without ownership proof. All relays failed: ${ownershipPublishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}. Deletion request ${deletionResult.success.length > 0 ? 'published' : 'failed to publish'}.`; const errorDetails = `Fork is invalid without ownership proof. All relays failed: ${ownershipPublishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}. Deletion request ${deletionResult.success.length > 0 ? 'published' : 'failed to publish'}.`;
@ -301,7 +318,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false); await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false);
console.log(`[Fork] ✓ Fork completed successfully!`); console.log(`[Fork] ✓ Fork completed successfully!`);
console.log(`[Fork] - Repository: ${userNpub}/${forkRepoName}`); // Security: Truncate npub in logs
const truncatedNpub2 = userNpub.length > 16 ? `${userNpub.slice(0, 12)}...` : userNpub;
console.log(`[Fork] - Repository: ${truncatedNpub2}/${forkRepoName}`);
console.log(`[Fork] - Announcement ID: ${signedForkAnnouncement.id}`); console.log(`[Fork] - Announcement ID: ${signedForkAnnouncement.id}`);
console.log(`[Fork] - Ownership transfer ID: ${signedOwnershipEvent.id}`); console.log(`[Fork] - Ownership transfer ID: ${signedOwnershipEvent.id}`);
console.log(`[Fork] - Published to ${publishResult.success.length} relay(s) for announcement`); console.log(`[Fork] - Published to ${publishResult.success.length} relay(s) for announcement`);
@ -323,8 +342,11 @@ export const POST: RequestHandler = async ({ params, request }) => {
message: `Repository forked successfully! Published to ${publishResult.success.length} relay(s) for announcement and ${ownershipPublishResult.success.length} relay(s) for ownership proof.` message: `Repository forked successfully! Published to ${publishResult.success.length} relay(s) for announcement and ${ownershipPublishResult.success.length} relay(s) for ownership proof.`
}); });
} catch (err) { } catch (err) {
console.error('Error forking repository:', err); // Security: Sanitize error messages to prevent leaking sensitive data
return error(500, err instanceof Error ? err.message : 'Failed to fork repository'); const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to fork repository';
const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]';
console.error(`[Fork] ${context} Error forking repository:`, sanitizedError);
return error(500, sanitizedError);
} }
}; };
@ -371,12 +393,12 @@ export const GET: RequestHandler = async ({ params }) => {
const isFork = announcement.tags.some(t => t[0] === 't' && t[1] === 'fork'); const isFork = announcement.tags.some(t => t[0] === 't' && t[1] === 'fork');
// Get original repo reference // Get original repo reference
const originalRepoTag = announcement.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); const originalRepoTag = announcement.tags.find(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`));
const originalOwnerTag = announcement.tags.find(t => t[0] === 'p' && t[1] !== ownerPubkey); const originalOwnerTag = announcement.tags.find(t => t[0] === 'p' && t[1] !== ownerPubkey);
let originalRepo: { npub: string; repo: string } | null = null; let originalRepo: { npub: string; repo: string } | null = null;
if (originalRepoTag && originalRepoTag[1]) { if (originalRepoTag && originalRepoTag[1]) {
const match = originalRepoTag[1].match(/^30617:([a-f0-9]{64}):(.+)$/); const match = originalRepoTag[1].match(new RegExp(`^${KIND.REPO_ANNOUNCEMENT}:([a-f0-9]{64}):(.+)$`));
if (match) { if (match) {
const [, originalOwnerPubkey, originalRepoName] = match; const [, originalOwnerPubkey, originalRepoName] = match;
try { try {
@ -388,13 +410,28 @@ export const GET: RequestHandler = async ({ params }) => {
} }
} }
// Get fork count for this repo
let forkCount = 0;
if (!isFork && ownerPubkey && repo) {
try {
forkCount = await forkCountService.getForkCount(ownerPubkey, repo);
} catch (err) {
// Log but don't fail the request
const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]';
console.warn(`[Fork] ${context} Failed to get fork count:`, err instanceof Error ? err.message : String(err));
}
}
return json({ return json({
isFork, isFork,
originalRepo, originalRepo,
forkCount: 0 // TODO: Count forks of this repo forkCount
}); });
} catch (err) { } catch (err) {
console.error('Error getting fork information:', err); // Security: Sanitize error messages
return error(500, err instanceof Error ? err.message : 'Failed to get fork information'); const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to get fork information';
const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]';
console.error(`[Fork] ${context} Error getting fork information:`, sanitizedError);
return error(500, sanitizedError);
} }
}; };

27
src/routes/api/repos/[npub]/[repo]/settings/+server.ts

@ -32,9 +32,9 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
// Decode npub to get pubkey // Decode npub to get pubkey
let repoOwnerPubkey: string; let repoOwnerPubkey: string;
try { try {
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub) as { type: string; data: unknown };
if (decoded.type === 'npub') { if (decoded.type === 'npub' && typeof decoded.data === 'string') {
repoOwnerPubkey = decoded.data as string; repoOwnerPubkey = decoded.data;
} else { } else {
return error(400, 'Invalid npub format'); return error(400, 'Invalid npub format');
} }
@ -49,9 +49,9 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
let userPubkeyHex = userPubkey; let userPubkeyHex = userPubkey;
try { try {
const userDecoded = nip19.decode(userPubkey); const userDecoded = nip19.decode(userPubkey) as { type: string; data: unknown };
if (userDecoded.type === 'npub') { if (userDecoded.type === 'npub' && typeof userDecoded.data === 'string') {
userPubkeyHex = userDecoded.data as string; userPubkeyHex = userDecoded.data;
} }
} catch { } catch {
// Assume it's already hex // Assume it's already hex
@ -87,7 +87,8 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
.filter(t => t[0] === 'maintainers') .filter(t => t[0] === 'maintainers')
.flatMap(t => t.slice(1)) .flatMap(t => t.slice(1))
.filter(m => m && typeof m === 'string') as string[]; .filter(m => m && typeof m === 'string') as string[];
const isPrivate = await maintainerService.isRepoPrivate(currentOwner, repo); const privacyInfo = await maintainerService.getPrivacyInfo(currentOwner, repo);
const isPrivate = privacyInfo.isPrivate;
return json({ return json({
name, name,
@ -125,9 +126,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Decode npub to get pubkey // Decode npub to get pubkey
let repoOwnerPubkey: string; let repoOwnerPubkey: string;
try { try {
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub) as { type: string; data: unknown };
if (decoded.type === 'npub') { if (decoded.type === 'npub' && typeof decoded.data === 'string') {
repoOwnerPubkey = decoded.data as string; repoOwnerPubkey = decoded.data;
} else { } else {
return error(400, 'Invalid npub format'); return error(400, 'Invalid npub format');
} }
@ -137,9 +138,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
let userPubkeyHex = userPubkey; let userPubkeyHex = userPubkey;
try { try {
const userDecoded = nip19.decode(userPubkey); const userDecoded = nip19.decode(userPubkey) as { type: string; data: unknown };
if (userDecoded.type === 'npub') { if (userDecoded.type === 'npub' && typeof userDecoded.data === 'string') {
userPubkeyHex = userDecoded.data as string; userPubkeyHex = userDecoded.data;
} }
} catch { } catch {
// Assume it's already hex // Assume it's already hex

4
src/routes/api/repos/[npub]/[repo]/transfer/+server.ts

@ -44,7 +44,7 @@ export const GET: RequestHandler = async ({ params }) => {
const currentOwner = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repo); const currentOwner = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repo);
// Fetch transfer events for history // Fetch transfer events for history
const repoTag = `30617:${originalOwnerPubkey}:${repo}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`;
const transferEvents = await nostrClient.fetchEvents([ const transferEvents = await nostrClient.fetchEvents([
{ {
kinds: [KIND.OWNERSHIP_TRANSFER], kinds: [KIND.OWNERSHIP_TRANSFER],
@ -141,7 +141,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Verify the 'a' tag references this repo // Verify the 'a' tag references this repo
const aTag = transferEvent.tags.find(t => t[0] === 'a'); const aTag = transferEvent.tags.find(t => t[0] === 'a');
const expectedRepoTag = `30617:${originalOwnerPubkey}:${repo}`; const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`;
if (!aTag || aTag[1] !== expectedRepoTag) { if (!aTag || aTag[1] !== expectedRepoTag) {
return error(400, "Transfer event 'a' tag does not match this repository"); return error(400, "Transfer event 'a' tag does not match this repository");
} }

24
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -31,9 +31,9 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
// Decode npub to get pubkey // Decode npub to get pubkey
let ownerPubkey: string; let ownerPubkey: string;
try { try {
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub) as { type: string; data: unknown };
if (decoded.type === 'npub') { if (decoded.type === 'npub' && typeof decoded.data === 'string') {
ownerPubkey = decoded.data as string; ownerPubkey = decoded.data;
} else { } else {
return error(400, 'Invalid npub format'); return error(400, 'Invalid npub format');
} }
@ -68,7 +68,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
const announcement = events[0]; const announcement = events[0];
// Check for ownership transfer events (including self-transfer for initial ownership) // Check for ownership transfer events (including self-transfer for initial ownership)
const repoTag = `30617:${ownerPubkey}:${repo}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${ownerPubkey}:${repo}`;
const transferEvents = await nostrClient.fetchEvents([ const transferEvents = await nostrClient.fetchEvents([
{ {
kinds: [KIND.OWNERSHIP_TRANSFER], kinds: [KIND.OWNERSHIP_TRANSFER],
@ -86,9 +86,9 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
// Decode npub if needed // Decode npub if needed
if (toPubkey) { if (toPubkey) {
try { try {
const decoded = nip19.decode(toPubkey); const decoded = nip19.decode(toPubkey) as { type: string; data: unknown };
if (decoded.type === 'npub') { if (decoded.type === 'npub' && typeof decoded.data === 'string') {
toPubkey = decoded.data as string; toPubkey = decoded.data;
} }
} catch { } catch {
// Assume it's already hex // Assume it's already hex
@ -102,7 +102,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
// Verify ownership - prefer self-transfer event, fall back to verification file // Verify ownership - prefer self-transfer event, fall back to verification file
let verified = false; let verified = false;
let verificationMethod = ''; let verificationMethod = '';
let error: string | undefined; let verificationError: string | undefined;
if (selfTransfer) { if (selfTransfer) {
// Verify self-transfer event signature // Verify self-transfer event signature
@ -112,7 +112,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
verificationMethod = 'self-transfer-event'; verificationMethod = 'self-transfer-event';
} else { } else {
verified = false; verified = false;
error = 'Self-transfer event signature is invalid'; verificationError = 'Self-transfer event signature is invalid';
verificationMethod = 'self-transfer-event'; verificationMethod = 'self-transfer-event';
} }
} else { } else {
@ -121,11 +121,11 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD'); const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD');
const verification = verifyRepositoryOwnership(announcement, verificationFile.content); const verification = verifyRepositoryOwnership(announcement, verificationFile.content);
verified = verification.valid; verified = verification.valid;
error = verification.error; verificationError = verification.error;
verificationMethod = 'verification-file'; verificationMethod = 'verification-file';
} catch (err) { } catch (err) {
verified = false; verified = false;
error = 'No ownership proof found (neither self-transfer event nor verification file)'; verificationError = 'No ownership proof found (neither self-transfer event nor verification file)';
verificationMethod = 'none'; verificationMethod = 'none';
} }
} }
@ -142,7 +142,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
} else { } else {
return json({ return json({
verified: false, verified: false,
error: error || 'Repository ownership verification failed', error: verificationError || 'Repository ownership verification failed',
announcementId: announcement.id, announcementId: announcement.id,
verificationMethod, verificationMethod,
message: 'Repository ownership verification failed' message: 'Repository ownership verification failed'

23
src/routes/repos/[npub]/[repo]/+page.svelte

@ -312,7 +312,9 @@
error = null; error = null;
try { try {
console.log(`[Fork UI] Starting fork of ${npub}/${repo}...`); // Security: Truncate npub in logs
const truncatedNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub;
console.log(`[Fork UI] Starting fork of ${truncatedNpub}/${repo}...`);
const response = await fetch(`/api/repos/${npub}/${repo}/fork`, { const response = await fetch(`/api/repos/${npub}/${repo}/fork`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -324,7 +326,9 @@
if (response.ok && data.success !== false) { if (response.ok && data.success !== false) {
const message = data.message || `Repository forked successfully! Published to ${data.fork?.publishedTo?.announcement || 0} relay(s).`; const message = data.message || `Repository forked successfully! Published to ${data.fork?.publishedTo?.announcement || 0} relay(s).`;
console.log(`[Fork UI] ✓ ${message}`); console.log(`[Fork UI] ✓ ${message}`);
console.log(`[Fork UI] - Fork location: /repos/${data.fork.npub}/${data.fork.repo}`); // Security: Truncate npub in logs
const truncatedForkNpub = data.fork.npub.length > 16 ? `${data.fork.npub.slice(0, 12)}...` : data.fork.npub;
console.log(`[Fork UI] - Fork location: /repos/${truncatedForkNpub}/${data.fork.repo}`);
console.log(`[Fork UI] - Announcement ID: ${data.fork.announcementId}`); console.log(`[Fork UI] - Announcement ID: ${data.fork.announcementId}`);
console.log(`[Fork UI] - Ownership Transfer ID: ${data.fork.ownershipTransferId}`); console.log(`[Fork UI] - Ownership Transfer ID: ${data.fork.ownershipTransferId}`);
@ -1084,7 +1088,7 @@
<a href="/" class="back-link">← Back to Repositories</a> <a href="/" class="back-link">← Back to Repositories</a>
<div class="repo-title-section"> <div class="repo-title-section">
{#if repoImage} {#if repoImage}
<img src={repoImage} alt="Repository image" class="repo-image" /> <img src={repoImage} alt="" class="repo-image" />
{/if} {/if}
<div> <div>
<h1>{pageData.repoName || repo}</h1> <h1>{pageData.repoName || repo}</h1>
@ -1507,7 +1511,18 @@
{/each} {/each}
{:else} {:else}
{#each prs as pr} {#each prs as pr}
<div class="pr-detail" onclick={() => selectedPR = pr.id} style="cursor: pointer;"> <div
class="pr-detail"
role="button"
tabindex="0"
onclick={() => selectedPR = pr.id}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedPR = pr.id;
}
}}
style="cursor: pointer;">
<h3>{pr.subject}</h3> <h3>{pr.subject}</h3>
<div class="pr-meta-detail"> <div class="pr-meta-detail">
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}> <span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}>

27
src/routes/search/+page.svelte

@ -55,7 +55,6 @@
bind:value={query} bind:value={query}
placeholder="Search repositories or code..." placeholder="Search repositories or code..."
class="search-input" class="search-input"
autofocus
/> />
<select bind:value={searchType} class="search-type-select"> <select bind:value={searchType} class="search-type-select">
<option value="repos">Repositories</option> <option value="repos">Repositories</option>
@ -82,7 +81,18 @@
<h3>Repositories ({results.repos.length})</h3> <h3>Repositories ({results.repos.length})</h3>
<div class="repo-list"> <div class="repo-list">
{#each results.repos as repo} {#each results.repos as repo}
<div class="repo-item" onclick={() => goto(`/repos/${repo.npub}/${repo.name.toLowerCase().replace(/\s+/g, '-')}`)}> <div
class="repo-item"
role="button"
tabindex="0"
onclick={() => goto(`/repos/${repo.npub}/${repo.name.toLowerCase().replace(/\s+/g, '-')}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/repos/${repo.npub}/${repo.name.toLowerCase().replace(/\s+/g, '-')}`);
}
}}
style="cursor: pointer;">
<h4>{repo.name}</h4> <h4>{repo.name}</h4>
{#if repo.description} {#if repo.description}
<p class="repo-description">{repo.description}</p> <p class="repo-description">{repo.description}</p>
@ -103,7 +113,18 @@
<h3>Code Files ({results.code.length})</h3> <h3>Code Files ({results.code.length})</h3>
<div class="code-list"> <div class="code-list">
{#each results.code as file} {#each results.code as file}
<div class="code-item" onclick={() => goto(`/repos/${file.npub}/${file.repo}?file=${encodeURIComponent(file.file)}`)}> <div
class="code-item"
role="button"
tabindex="0"
onclick={() => goto(`/repos/${file.npub}/${file.repo}?file=${encodeURIComponent(file.file)}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/repos/${file.npub}/${file.repo}?file=${encodeURIComponent(file.file)}`);
}
}}
style="cursor: pointer;">
<div class="code-file-path">{file.file}</div> <div class="code-file-path">{file.file}</div>
<div class="code-repo"> <div class="code-repo">
<a href={`/repos/${file.npub}/${file.repo}`} onclick={(e) => e.stopPropagation()}> <a href={`/repos/${file.npub}/${file.repo}`} onclick={(e) => e.stopPropagation()}>

2
src/routes/signup/+page.svelte

@ -87,7 +87,7 @@
} }
if (event.kind !== KIND.REPO_ANNOUNCEMENT) { if (event.kind !== KIND.REPO_ANNOUNCEMENT) {
error = 'The provided event is not a repository announcement (kind 30617).'; error = `The provided event is not a repository announcement (kind ${KIND.REPO_ANNOUNCEMENT}).`;
loadingExisting = false; loadingExisting = false;
return; return;
} }

13
src/routes/users/[npub]/+page.svelte

@ -138,7 +138,18 @@
{:else} {:else}
<div class="repo-grid"> <div class="repo-grid">
{#each repos as event} {#each repos as event}
<div class="repo-card" onclick={() => goto(`/repos/${npub}/${getRepoId(event)}`)}> <div
class="repo-card"
role="button"
tabindex="0"
onclick={() => goto(`/repos/${npub}/${getRepoId(event)}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/repos/${npub}/${getRepoId(event)}`);
}
}}
style="cursor: pointer;">
<h3>{getRepoName(event)}</h3> <h3>{getRepoName(event)}</h3>
{#if getRepoDescription(event)} {#if getRepoDescription(event)}
<p class="repo-description">{getRepoDescription(event)}</p> <p class="repo-description">{getRepoDescription(event)}</p>

Loading…
Cancel
Save