Compare commits

..

10 Commits

Author SHA1 Message Date
03583e5a52 Add generic modal component, refactor photo upload modal (WIP) 2026-04-19 11:09:41 +04:00
b9f64f30e1 Cut off overlong account status lines with ellipsis 2026-04-19 11:09:23 +04:00
4bd5c4bf2a Load signer on launch, disconnect or switch pubkey if necessary 2026-04-19 11:08:55 +04:00
f875fc1877 WIP Add Nostr auth 2026-04-18 18:36:35 +04:00
2268a607d5 Format docs 2026-04-18 18:35:45 +04:00
f01b5f8faa Add place photos NIP, update reviews NIP 2026-04-18 17:30:25 +04:00
9075089221 Update notes 2026-04-18 12:47:33 +04:00
5ad702e6e6 Add planning docs for Nostr place reviews 2026-04-18 11:35:29 +04:00
bae01a3c9b Add Nostr agent skills
All checks were successful
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 45s
2026-04-14 10:34:43 +04:00
0efc8994e9 Add AGENTS.md
All checks were successful
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 44s
2026-04-14 10:20:59 +04:00
19 changed files with 2603 additions and 3 deletions

359
.agents/skills/nak/SKILL.md Normal file
View File

@@ -0,0 +1,359 @@
---
name: nak
description: Interact with Nostr protocol using the nak CLI tool. Use to generate Nostr secret keys, encode and decode Nostr identifiers (hex/npub/nsec/nip05/etc), fetch events from relays, sign and publish Nostr events, and more.
license: CC-BY-SA-4.0
---
# nak - Nostr Army Knife
Work with the Nostr protocol using the `nak` CLI tool.
GitHub: https://github.com/fiatjaf/nak
## Installation
To install (or upgrade) nak:
```bash
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
```
## Core Concepts
Based on analyzing extensive real-world usage, here are the fundamental concepts distilled down, then branched out:
---
### CONCEPT 1: Query & Discovery
*"Finding events and data on Nostr"*
**Basic:**
- Fetch by identifier: "Get event/profile without knowing relays"
- `nak fetch nevent1...` (uses embedded relay hints)
- `nak fetch alex@gleasonator.dev` (NIP-05 resolution)
- Query by event ID: "I want THIS specific event from a relay"
- `nak req -i <event_id> <relay>`
- Query by author: "Show me everything from this person"
- `nak req -a <pubkey> <relay>`
- Query by kind: "Show me all profiles/notes/videos"
- `nak req -k <kind> <relay>`
**Intermediate:**
- Fetch addressable events: "Get event by naddr/nprofile"
- `nak fetch naddr1...` (kind, author, identifier encoded)
- `nak fetch -r relay.primal.net naddr1...` (override relays)
- Filter by multiple criteria: "Find posts by author X of kind Y"
- `nak req -k 1 -a <pubkey> <relay>`
- Tag-based queries: "Find events tagged with bitcoin"
- `nak req --tag t=bitcoin <relay>`
**Advanced:**
- Search with ranking: "Find trending/top content"
- `nak req --search "sort:hot" <relay>`
- `nak req --search "popular:24h" <relay>`
- Live monitoring: "Watch events in real-time"
- `nak req --stream <relay>`
- Cross-protocol queries: "Find bridged Bluesky content"
- `nak req --tag "proxy=at://..." <relay>`
**Use cases:** Discovering content, debugging relay data, testing search algorithms, monitoring live feeds
---
### CONCEPT 2: Broadcast & Migration
*"Moving/copying events between relays"*
**Basic:**
- Publish a single event: "Put this on relay X"
- `cat event.json | nak event <relay>`
- Query and republish: "Copy event from relay A to relay B"
- `nak req -i <id> relay1 | nak event relay2`
**Intermediate:**
- Batch migration: "Copy all events of kind X"
- `nak req -k 30717 source_relay | nak event dest_relay`
- Paginated backup: "Download everything from a relay"
- `nak req --paginate source_relay | nak event backup_relay`
- Multi-relay broadcast: "Publish to multiple relays at once"
- `cat event.json | nak event relay1 relay2 relay3`
**Advanced:**
- Selective migration: "Copy follow list members' data"
- Loop through follow list, query each author, republish to new relay
- Filter and migrate: "Copy only tagged/searched content"
- `nak req --tag client=X relay1 | nak event relay2`
- Cross-relay synchronization: "Keep two relays in sync"
- `nak sync source_relay dest_relay`
**Use cases:** Seeding new relays, backing up data, migrating content between relays, bridging Mostr/Fediverse content
---
### CONCEPT 3: Identity & Encoding
*"Working with Nostr identifiers and keys"*
**Basic:**
- Decode identifiers: "What's inside this npub/nevent?"
- `nak decode npub1...`
- `nak decode user@domain.com`
- Encode identifiers: "Turn this pubkey into npub"
- `nak encode npub <hex_pubkey>`
**Intermediate:**
- Generate keys: "Create a new identity"
- `nak key generate`
- `nak key generate | nak key public | nak encode npub`
- Extract hex from NIP-05: "Get the raw pubkey from an address"
- `nak decode user@domain.com | jq -r .pubkey`
- Create shareable references: "Make a nevent with relay hints"
- `nak encode nevent <event_id> --relay <relay>`
**Advanced:**
- Complex naddr creation: "Create addressable event reference with metadata"
- `nak encode naddr -k 30717 -a <author> -d <identifier> -r <relay>`
- Multi-relay nprofile: "Create profile with multiple relay hints"
- `nak encode nprofile <pubkey> -r relay1 -r relay2 -r relay3`
**Use cases:** Converting between formats, sharing references with relay hints, managing multiple identities, extracting pubkeys for scripting
---
### CONCEPT 4: Event Creation & Publishing
*"Creating and signing new events"*
**Basic:**
- Interactive creation: "Create an event with prompts"
- `nak event --prompt-sec <relay>`
- Simple note: "Publish a text note"
- `nak event -k 1 -c "Hello Nostr" <relay>`
**Intermediate:**
- Events with tags: "Create tagged content"
- `nak event -k 1 -t t=bitcoin -t t=nostr <relay>`
- Event deletion: "Delete a previous event"
- `nak event -k 5 -e <event_id> --prompt-sec <relay>`
- Replaceable events: "Create/update a profile or app data"
- `nak event -k 10019 -t mint=<url> -t pubkey=<key> <relay>`
**Advanced:**
- Remote signing with bunker: "Sign without exposing keys"
- `nak event --connect "bunker://..." <relay>`
- Batch event creation: "Generate many events via script"
- `for i in {1..100}; do nak event localhost:8000; done`
- Complex events from JSON: "Craft specific event structure"
- Modify JSON, then `cat event.json | nak event <relay>`
**Use cases:** Testing event creation, developing apps (kind 31990, 37515), wallet integration (kind 10019), moderation (kind 5), bunker/remote signing implementation
---
### CONCEPT 5: Development & Testing
*"Building on Nostr"*
**Basic:**
- Local relay testing: "Test against dev relay"
- `nak req localhost:8000`
- `nak event ws://127.0.0.1:7777`
**Intermediate:**
- Inspect JSON: "Examine event structure"
- `nak req -i <id> <relay> | jq .`
- Test search: "Verify search functionality"
- `nak req --search "<query>" localhost:8000`
- Admin operations: "Manage relay content"
- `nak admin --prompt-sec banevent --id <id> <relay>`
**Advanced:**
- Protocol bridging: "Query/test ATProto integration"
- `nak req --tag "proxy=at://..." eclipse.pub/relay`
- Git over Nostr: "Use git with Nostr transport"
- `nak git clone nostr://...`
- `nak req -k 30617 git.shakespeare.diy`
- Performance testing: "Measure query speed"
- `time nak req -k 0 -a <pubkey> <relay>`
- Custom event kinds: "Test proprietary event types"
- `nak req -k 37515 -a <author> -d <id> ditto.pub/relay`
**Use cases:** Relay development (Ditto), testing bridges (Mostr/Bluesky), developing video platforms, implementing Git-over-Nostr, testing search ranking algorithms, performance benchmarking
---
### CONCEPT 6: Analytics & Monitoring
*"Understanding Nostr data"*
**Basic:**
- Count results: "How many events match?"
- `nak req -k 1 <relay> | wc -l`
- `nak count -k 7 -e <event_id> <relay>`
**Intermediate:**
- Live monitoring: "Watch relay activity"
- `nak req --stream <relay>`
- `nak req -l 0 --stream relay1 relay2 relay3`
- Client analytics: "What apps are posting?"
- `nak req --tag client=<app> <relay>`
**Advanced:**
- Event chain analysis: "Track engagement"
- `nak count -k 7 -e <event_id> <relay>` (reactions)
- `nak req -k 6 -k 7 -e <event_id> <relay>` (reposts + reactions)
- Content ranking: "Find top/hot content"
- `nak req --search "sort:top" <relay>`
- Cross-relay comparison: "Compare event availability"
- Query same event from multiple relays, compare results
**Use cases:** Monitoring relay health, tracking client usage (Ditto, noStrudel, moStard), analyzing engagement, testing ranking algorithms
---
## Summary: The 6 Core Mental Models
1. **Query & Discovery**: "How do I find things?"
2. **Broadcast & Migration**: "How do I move things?"
3. **Identity & Encoding**: "How do I represent things?"
4. **Event Creation**: "How do I make things?"
5. **Development & Testing**: "How do I build things?"
6. **Analytics & Monitoring**: "How do I measure things?"
---
## Command Shapes and Edge-Cases
Non-obvious patterns and edge cases for nak commands.
### Signing Methods
**Using environment variable:**
```bash
export NOSTR_SECRET_KEY=<hex_key>
nak event -c "hello" # Automatically uses $NOSTR_SECRET_KEY
```
**Reading key from file:**
```bash
nak event -c "hello" --sec $(cat /path/to/key.txt)
```
### Content from File
**Using @ prefix to read content from file:**
```bash
echo "hello world" > content.txt
nak event -c @content.txt
```
### Tag Syntax
**Tag with multiple values (semicolon-separated):**
```bash
nak event -t custom="value1;value2;value3"
# Creates: ["custom", "value1", "value2", "value3"]
```
### Filter Output Modes
**Print bare filter (JSON only):**
```bash
nak req -k 1 -l 5 --bare
# Output: {"kinds":[1],"limit":5}
```
**Filter from stdin can be modified with flags:**
```bash
echo '{"kinds": [1]}' | nak req -l 5 -k 3 --bare
```
**Unlimited stream:**
```bash
nak req -l 0 --stream wss://relay.example.com
```
### Relay Specification
**Local relays and WebSocket schemes:**
```bash
nak req localhost:8000
nak req ws://127.0.0.1:7777
nak req relay.example.com # Assumes wss:// if not specified
```
### Encoding
**Encode from JSON stdin (auto-detects type):**
```bash
echo '{"pubkey":"<hex>","relays":["wss://relay.example.com"]}' | nak encode
```
### Key Operations
**Complete key generation pipeline:**
```bash
nak key generate | tee secret.key | nak key public | nak encode npub
# Saves private key to file AND prints the public npub
```
### Verification
**Verify and pipe:**
```bash
nak event -c "test" --sec <key> | nak verify && echo "Valid"
```
### Fetch vs Req
**nak fetch uses relay hints from identifiers:**
```bash
nak fetch nevent1... # Uses relays encoded in nevent
nak fetch naddr1... # Uses relays encoded in naddr
nak fetch alex@gleasonator.dev # Resolves NIP-05
nak fetch -r relay.primal.net naddr1... # Override relays
```
**nak req requires explicit relay specification:**
```bash
nak req -i <event_id> wss://relay.example.com
```
### Edge Cases
**No relays specified (prints event without publishing):**
```bash
nak event -c "test" --sec <key> # Just prints the event JSON
```
**Tag order matters for addressable events:**
```bash
# The first 'd' tag is the identifier
nak event -k 30023 -d first -d second # "first" is the identifier
```
**Timestamp override:**
```bash
nak event --ts 1700000000 -c "backdated" --sec <key>
nak event --ts 0 -c "genesis" --sec <key> # Event at Unix epoch
```
**Kind 0 (profile) requires JSON content:**
```bash
nak event -k 0 -c '{"name":"Alice","about":"Developer"}' --sec <key>
```
**POW (Proof of Work):**
```bash
nak event --pow 20 -c "mined event" --sec <key>
# Will compute hash until difficulty target is met
```
**NIP-42 AUTH:**
```bash
nak req --auth -k 1 wss://relay.example.com
nak event --auth -c "test" --sec <key> wss://relay.example.com
# Automatically handles AUTH challenges
```
**Stdin takes precedence over flags:**
```bash
echo '{"content":"from stdin"}' | nak event -c "from flag" --sec <key>
# Uses "from stdin" (stdin overrides flags)
```

View File

@@ -0,0 +1,51 @@
---
name: nostr
description: Knowledge about the Nostr protocol. Use to view up-to-date NIPs, discover capabilities of the Nostr protocol, and to implement Nostr functionality correctly.
license: CC-BY-SA-4.0
---
# Nostr Protocol
Nostr is a simple, open protocol that enables truly censorship-resistant and global social networks using cryptographic keys and signatures.
## Finding Nostr Documentation
All Nostr protocol documentation is maintained in the NIPs (Nostr Implementation Possibilities) repository. To access this information:
### Reading Individual NIPs
Individual NIPs can be fetched from:
```
https://github.com/nostr-protocol/nips/blob/master/{NIP}.md
```
For example, NIP-01 (the basic protocol specification) is available at:
```
https://github.com/nostr-protocol/nips/blob/master/01.md
```
### Finding Event Kinds
Documentation for Nostr event kinds is spread across one or more NIPs. **There is no direct relationship between the NIP number and the kind number.**
To find which NIPs document a specific event kind:
1. First, fetch the README:
```
https://github.com/nostr-protocol/nips/blob/master/README.md
```
2. Reference the "Event Kinds" table in the README to find which NIP(s) document the kind you're looking for
### Discovering Existing Capabilities
The README should be consulted to:
- **View the list of NIPs** - Use this to discover what capabilities already exist on the Nostr network
- **Review client and relay messages** - Understand the communication protocol
- **Check the list of tags** - See what standardized tags are available
- **Decide on using existing NIPs** - Before implementing a feature, check if an existing NIP already covers it
### Best Practices
Always start by fetching and reviewing the README to understand the current state of the protocol and avoid reinventing existing functionality.

49
AGENTS.md Normal file
View File

@@ -0,0 +1,49 @@
# Agent Instructions for Marco
Marco is a privacy-respecting, unhosted maps application that allows users to connect their own remote storage to sync place bookmarks across apps and devices.
## Relevant Commands
All commands must be executed using `pnpm`. Never use `npm`.
- **Install dependencies:** `pnpm install`
- **Start development server:** `pnpm start` (Runs Vite)
- **Run tests:** `pnpm test` (Builds and runs via Testem)
- **Linting:**
- `pnpm lint` (Runs ESLint, Stylelint, Prettier, and ember-template-lint concurrently)
- `pnpm lint:fix` (Automatically fixes linting and formatting errors)
### NEVER RUN
- `pnpm build` (only run for releases by the devs)
- `git add`, `git commit`, or any other commands that modify git history or the staging area
## Application Architecture & Frameworks
### Core Stack
- **Framework:** Ember.js (Octane / Polaris editions). The project heavily uses modern Ember paradigms including `.gjs` (template-tag format), `@glimmer/component`, and `@glimmer/tracking`.
- **Build System:** Vite coupled with Embroider (`@embroider/vite`) for fast, modern asset compilation.
### Mapping & Geocoding
- **Maps:** Uses OpenLayers (`ol` and `ol-mapbox-style`) for rendering the map interface.
- **Search:** Uses the Photon API (via `app/services/photon.js`) for geocoding and search functionality.
- **Data Source:** OpenStreetMap (OSM) data is fetched and parsed to display rich place details.
### Storage & Authentication
- **RemoteStorage:** The app is "unhosted" meaning user data isn't locked in a central database. It uses `remotestoragejs` and `@remotestorage/module-places` to sync bookmarks to a user's chosen storage provider. (Managed in `app/services/storage.js`).
- **OSM Auth:** Allows users to log into OpenStreetMap using OAuth 2.0 PKCE (`oauth2-pkce`) to fetch user info and potentially interact with OSM directly (Managed in `app/services/osm-auth.js`).
### Directory Structure Highlights
- `app/components/`: UI components using `.gjs` (Glimmer JS with embedded `<template>` tags).
- `app/services/`: Core business logic, state management, and API integrations.
- `app/utils/`: Helper functions for geohashing, parsing OSM tags, icon mapping, and link formatting.
- `tests/`: Comprehensive QUnit test suite containing unit, integration, and acceptance tests.
### Key Libraries
- `ember-concurrency` / `ember-lifeline`: Managing async tasks and debouncing.
- `remotestorage-widget`: The UI widget for connecting to a RemoteStorage provider.

43
app/components/modal.gjs Normal file
View File

@@ -0,0 +1,43 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from './icon';
export default class Modal extends Component {
@action
stopProp(e) {
e.stopPropagation();
}
@action
close() {
if (this.args.onClose) {
this.args.onClose();
}
}
<template>
<div
class="modal-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.close}}
>
<div
class="modal-content"
role="document"
tabindex="0"
{{on "click" this.stopProp}}
>
<button
type="button"
class="close-modal-btn btn-text"
{{on "click" this.close}}
>
<Icon @name="x" @size={{24}} />
</button>
{{yield}}
</div>
</div>
</template>
}

View File

@@ -9,6 +9,8 @@ import { getSocialInfo } from '../utils/social-links';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import Modal from './modal';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
@@ -17,6 +19,20 @@ export default class PlaceDetails extends Component {
@service storage;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@action
openPhotoUploadModal(e) {
if (e) {
e.preventDefault();
}
this.isPhotoUploadModalOpen = true;
}
@action
closePhotoUploadModal() {
this.isPhotoUploadModalOpen = false;
}
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
@@ -499,7 +515,24 @@ export default class PlaceDetails extends Component {
</p>
{{/if}}
{{#if this.osmUrl}}
<p class="content-with-icon">
<Icon @name="camera" />
<span>
<a href="#" {{on "click" this.openPhotoUploadModal}}>
Add a photo
</a>
</span>
</p>
{{/if}}
</div>
</div>
{{#if this.isPhotoUploadModalOpen}}
<Modal @onClose={{this.closePhotoUploadModal}}>
<PlacePhotoUpload @place={{this.saveablePlace}} />
</Modal>
{{/if}}
</template>
}

View File

@@ -0,0 +1,168 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import Geohash from 'latlon-geohash';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
@tracked photoUrl = '';
@tracked status = '';
@tracked error = '';
get place() {
return this.args.place || {};
}
get title() {
return this.place.title || 'this place';
}
@action
async login() {
try {
this.error = '';
await this.nostrAuth.login();
} catch (e) {
this.error = e.message;
}
}
@action
async uploadPhoto(event) {
event.preventDefault();
this.error = '';
this.status = 'Uploading...';
try {
// Mock upload
await new Promise((resolve) => setTimeout(resolve, 1000));
this.photoUrl =
'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
this.status = 'Photo uploaded! Ready to publish.';
} catch (e) {
this.error = 'Upload failed: ' + e.message;
this.status = '';
}
}
@action
async publish() {
if (!this.nostrAuth.isConnected) {
this.error = 'You must connect Nostr first.';
return;
}
if (!this.photoUrl) {
this.error = 'Please upload a photo.';
return;
}
const { osmId, lat, lon } = this.place;
const osmType = this.place.osmType || 'node';
if (!osmId) {
this.error = 'This place does not have a valid OSM ID.';
return;
}
this.status = 'Publishing event...';
this.error = '';
try {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const tags = [['i', `osm:${osmType}:${osmId}`]];
if (lat && lon) {
tags.push(['g', Geohash.encode(lat, lon, 4)]);
tags.push(['g', Geohash.encode(lat, lon, 6)]);
tags.push(['g', Geohash.encode(lat, lon, 7)]);
tags.push(['g', Geohash.encode(lat, lon, 9)]);
}
tags.push([
'imeta',
`url ${this.photoUrl}`,
'm image/jpeg',
'dim 600x400',
'alt A photo of a place',
]);
// NIP-XX draft Place Photo event
const template = {
kind: 360,
content: '',
tags,
};
// Ensure created_at is present before signing
if (!template.created_at) {
template.created_at = Math.floor(Date.now() / 1000);
}
const event = await factory.sign(template);
await this.nostrRelay.publish(event);
this.status = 'Published successfully!';
// Reset form
this.photoUrl = '';
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
}
}
<template>
<div class="place-photo-upload">
<h2>Add Photo for {{this.title}}</h2>
{{#if this.error}}
<div class="alert alert-error">
{{this.error}}
</div>
{{/if}}
{{#if this.status}}
<div class="alert alert-info">
{{this.status}}
</div>
{{/if}}
{{#if this.nostrAuth.isConnected}}
<div class="connected-status">
<strong>Connected:</strong>
{{this.nostrAuth.pubkey}}
</div>
<form {{on "submit" this.uploadPhoto}}>
{{#if this.photoUrl}}
<div class="preview-group">
<p>Photo Preview:</p>
<img src={{this.photoUrl}} alt="Preview" />
</div>
<button
type="button"
class="btn btn-primary"
{{on "click" this.publish}}
>
Publish Event (kind: 360)
</button>
{{else}}
<button type="submit" class="btn btn-secondary">
Mock Upload Photo
</button>
{{/if}}
</form>
{{else}}
<button type="button" class="btn btn-primary" {{on "click" this.login}}>
Connect Nostr Extension
</button>
{{/if}}
</div>
</template>
}

View File

@@ -8,6 +8,8 @@ export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
@service nostrAuth;
@action
connectRS() {
this.args.onClose();
@@ -30,6 +32,21 @@ export default class UserMenuComponent extends Component {
this.osmAuth.logout();
}
@action
async connectNostr() {
try {
await this.nostrAuth.login();
} catch (e) {
console.error(e);
alert(e.message);
}
}
@action
disconnectNostr() {
this.nostrAuth.logout();
}
<template>
<div class="user-menu-popover">
<ul class="account-list">
@@ -91,15 +108,34 @@ export default class UserMenuComponent extends Component {
</div>
</li>
<li class="account-item disabled">
<li class="account-item">
<div class="account-header">
<div class="account-info">
<Icon @name="zap" @size={{18}} />
<span>Nostr</span>
</div>
{{#if this.nostrAuth.isConnected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectNostr}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectNostr}}
>Connect</button>
{{/if}}
</div>
<div class="account-status">
Coming soon
{{#if this.nostrAuth.isConnected}}
<strong title={{this.nostrAuth.pubkey}}>
{{this.nostrAuth.pubkey}}
</strong>
{{else}}
Not connected
{{/if}}
</div>
</li>
</ul>

View File

@@ -0,0 +1,78 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ExtensionSigner } from 'applesauce-signers';
const STORAGE_KEY = 'marco:nostr_pubkey';
export default class NostrAuthService extends Service {
@tracked pubkey = null;
constructor() {
super(...arguments);
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
this.pubkey = saved;
this._verifyPubkey();
}
}
async _verifyPubkey() {
if (typeof window.nostr === 'undefined') {
this.logout();
return;
}
try {
const signer = new ExtensionSigner();
const extensionPubkey = await signer.getPublicKey();
if (extensionPubkey !== this.pubkey) {
this.pubkey = extensionPubkey;
localStorage.setItem(STORAGE_KEY, this.pubkey);
}
} catch (e) {
console.warn('Failed to verify nostr pubkey, logging out', e);
this.logout();
}
}
get isConnected() {
return !!this.pubkey;
}
get signer() {
if (typeof window.nostr !== 'undefined') {
return new ExtensionSigner();
}
return null;
}
async login() {
if (typeof window.nostr === 'undefined') {
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
}
try {
this.pubkey = await this.signer.getPublicKey();
localStorage.setItem(STORAGE_KEY, this.pubkey);
return this.pubkey;
} catch (error) {
console.error('Failed to get public key from extension:', error);
throw error;
}
}
async signEvent(event) {
if (!this.signer) {
throw new Error(
'Not connected or extension missing. Please connect Nostr again.'
);
}
return await this.signer.signEvent(event);
}
logout() {
this.pubkey = null;
localStorage.removeItem(STORAGE_KEY);
}
}

View File

@@ -0,0 +1,25 @@
import Service from '@ember/service';
import { RelayPool } from 'applesauce-relay';
export default class NostrRelayService extends Service {
pool = new RelayPool();
// For Phase 1, we hardcode the local relay
relays = ['ws://127.0.0.1:7777'];
async publish(event) {
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
// and automatically handles reconnecting and retrying.
const responses = await this.pool.publish(this.relays, event);
// Check if at least one relay accepted the event
const success = responses.some((res) => res.ok);
if (!success) {
throw new Error(
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
);
}
return responses;
}
}

View File

@@ -252,6 +252,9 @@ body {
color: #898989;
margin-top: 0.35rem;
margin-left: calc(18px + 0.75rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-status strong {
@@ -1374,3 +1377,72 @@ button.create-place {
transform: translate(-50%, 0);
}
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 50%);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 1.5rem;
max-width: 90vw;
width: 450px;
position: relative;
}
.close-modal-btn {
position: absolute;
top: 1rem;
right: 1rem;
cursor: pointer;
}
/* Place Photo Upload */
.place-photo-upload h2 {
margin-top: 0;
}
.alert {
padding: 0.5rem;
margin-bottom: 1rem;
border-radius: 0.25rem;
}
.alert-error {
background: #fee;
color: #c00;
}
.alert-info {
background: #eef;
color: #00c;
}
.connected-status {
margin-bottom: 1rem;
color: #080;
word-break: break-all;
}
.preview-group {
margin-bottom: 1rem;
}
.preview-group p {
margin-bottom: 0.25rem;
font-weight: bold;
}
.preview-group img {
max-width: 100%;
border-radius: 0.25rem;
}

View File

@@ -0,0 +1,107 @@
# NIP-XX: Place Photos and Media
`draft` `optional`
## Abstract
This NIP defines a standardized event format for sharing photos, videos, and other visual media tied to specific real-world locations (e.g., OpenStreetMap POIs).
While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP specifically targets map-based applications, travel logs, and location directories by mandating strict entity identifiers (`i` tags) and spatial indexing (`g` tags).
## Event Kind
`kind: 360`
## Content
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
## Tags
This NIP relies on existing Nostr tag conventions to link media to places and provide inline metadata.
### Required Tags
#### 1. `i` — Entity Identifier
Identifies the exact place the media depicts using an external identifier (as defined in NIP-73). OpenStreetMap data is the default:
```json
["i", "osm:node:123456"]
```
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`.
#### 2. `g` — Geohash
Used for spatial indexing and discovery. Events MUST include at least one high-precision geohash. To optimize for map-based discovery across different zoom levels, clients SHOULD include geohashes at multiple resolutions:
```json
["g", "thrr"] // coarse (~city)
["g", "thrrn5"] // medium (~1km)
["g", "thrrn5k"] // fine (~150m)
["g", "thrrn5kxyz"] // exact
```
#### 3. `imeta` — Inline Media Metadata
Media files MUST be attached using the `imeta` tag as defined in NIP-92. Each `imeta` tag represents one media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
```json
[
"imeta",
"url https://example.com/photo.jpg",
"m image/jpeg",
"dim 3024x4032",
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
]
```
### Optional Tags
- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`).
- `content-warning`: If the media contains NSFW or sensitive imagery.
- `published_at`: Unix timestamp of when the photo was originally taken or published.
## Example Event
```json
{
"id": "<32-bytes hex>",
"pubkey": "<32-bytes hex>",
"created_at": 1713205000,
"kind": 360,
"content": "",
"tags": [
["i", "osm:node:987654321"],
["g", "xn0m"],
["g", "xn0m7h"],
["g", "xn0m7hwq"],
[
"imeta",
"url https://example.com/ramen.jpg",
"m image/jpeg",
"dim 1080x1080",
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
],
["t", "ramen"],
["t", "food"]
]
}
```
## Rationale
### Why not use NIP-68 (Picture-first feeds)?
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).
### Separation from Place Reviews
Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached.

View File

@@ -0,0 +1,313 @@
# NIP-XX: Place Reviews
`draft` `optional`
## Abstract
This NIP defines a standardized event format for decentralized place reviews using Nostr. Reviews are tied to real-world locations (e.g. OpenStreetMap POIs) via tags, and include structured, multi-aspect ratings, a binary recommendation signal, and optional contextual metadata.
The design prioritizes:
- Small event size
- Interoperability across clients
- Flexibility for different place types
- Efficient geospatial querying using geohashes
## Event Kind
`kind: 30360`
## Tags
Additional tags MAY be included by clients but are not defined by this specification.
This NIP reuses and builds upon existing Nostr tag conventions:
- `i` tag: see NIP-73 (External Content Identifiers)
- `g` tag: geohash-based geotagging (community conventions)
Where conflicts arise, this NIP specifies the behavior for review events.
### Required
#### `i` — Entity Identifier
Identifies the reviewed place using an external identifier. OpenStreetMap data is the default:
```
["i", "osm:<type>:<id>"]
```
Requirements:
- For OSM POIs, `<type>` MUST be one of: `node`, `way`, `relation`
Examples:
```
["i", "osm:node:123456"]
["i", "osm:way:987654"]
```
### Geospatial Tags
#### `g` — Geohash
Geohash tags are used for spatial indexing and discovery.
##### Requirements
- Clients MUST include at least one high-precision geohash (length ≥ 9)
##### Recommendations
Clients SHOULD include geohashes at the following resolutions:
- length 4 — coarse (city-scale discovery)
- length 6 — medium (default query level, ~1 km)
- length 7 — fine (neighborhood, ~150 m)
Example:
```
["g", "thrr"]
["g", "thrrn5"]
["g", "thrrn5k"]
["g", "thrrn5kxyz"]
```
##### Querying
Geospatial queries are performed using the `g` tag.
- Clients SHOULD query using a single geohash precision level per request
- Clients MAY include multiple geohash values in a filter to cover a bounding box
- Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30)
- Clients MAY reduce precision or split queries when necessary
Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying.
## Content (JSON)
The event `content` MUST be valid JSON matching the following schema.
### Schema
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["version", "ratings"],
"additionalProperties": false,
"properties": {
"version": {
"type": "integer",
"const": 1
},
"ratings": {
"type": "object",
"required": ["quality"],
"additionalProperties": false,
"properties": {
"quality": { "$ref": "#/$defs/score" },
"value": { "$ref": "#/$defs/score" },
"experience": { "$ref": "#/$defs/score" },
"accessibility": { "$ref": "#/$defs/score" },
"aspects": {
"type": "object",
"minProperties": 1,
"maxProperties": 20,
"additionalProperties": { "$ref": "#/$defs/score" },
"propertyNames": {
"pattern": "^[a-z][a-z0-9_]{1,31}$"
}
}
}
},
"recommend": {
"type": "boolean"
},
"familiarity": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "User familiarity: low = first visit; medium = occasional; high = frequent"
},
"context": {
"type": "object",
"additionalProperties": false,
"properties": {
"visited_at": {
"type": "integer",
"minimum": 0
},
"duration_minutes": {
"type": "integer",
"minimum": 0,
"maximum": 1440
},
"party_size": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
},
"review": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"maxLength": 1000
},
"language": {
"type": "string",
"pattern": "^[a-z]{2}(-[A-Z]{2})?$"
}
}
}
},
"$defs": {
"score": {
"type": "integer",
"minimum": 1,
"maximum": 10
}
}
}
```
## Example
### Restaurant Review Event
#### Tags
```
[
["i", "osm:node:123456"],
["g", "thrr"],
["g", "thrrn5"],
["g", "thrrn5k"],
["g", "thrrn5kxyz"]
]
```
#### Content
```json
{
"version": 1,
"ratings": {
"quality": 9,
"value": 8,
"experience": 9,
"accessibility": 7,
"aspects": {
"food": 9,
"service": 6,
"ambience": 8,
"wait_time": 5
}
},
"recommend": true,
"familiarity": "medium",
"context": {
"visited_at": 1713200000,
"duration_minutes": 90,
"party_size": 2
},
"review": {
"text": "Excellent food with bold flavors. Service was a bit slow, but the atmosphere made up for it.",
"language": "en"
}
}
```
## Semantics
### Ratings
- Scores are integers from 1 to 10
- `quality` is required and represents the core evaluation of the place
- Other fields are optional and context-dependent
### Aspects
- Free-form keys allow domain-specific ratings
- Clients MAY define and interpret aspect keys
- Clients SHOULD reuse commonly established aspect keys where possible
## Recommendation Signal
The `recommend` field represents a binary verdict:
- `true` → user recommends the place
- `false` → user does not recommend the place
Clients SHOULD strongly encourage users to provide this value.
## Familiarity
Represents user familiarity with the place:
- `low` → first visit or limited exposure
- `medium` → occasional visits
- `high` → frequent or expert-level familiarity
Clients MAY use this signal for weighting during aggregation.
## Context
Optional metadata about the visit.
- `visited_at` is a Unix timestamp
- `duration_minutes` represents time spent
- `party_size` indicates group size
## Interoperability
This specification defines a content payload only.
- In Nostr: place identity is conveyed via tags
- In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`)
Content payloads SHOULD NOT include place identifiers.
## Rationale
### No Place Field in Content
Avoids duplication and inconsistency with tags.
### Multi-Aspect Ratings
Separates concerns (e.g. quality vs service), improving signal quality.
### Recommendation vs Score
Binary recommendation avoids averaging pitfalls and improves ranking.
### Familiarity
Provides a human-friendly proxy for confidence without requiring numeric input.
### Geohash Strategy
Multiple resolutions balance:
- efficient querying
- small event size
- early-stage discoverability
## Future Work
- Standardized aspect vocabularies
- Reputation and weighting models
- Indexing/aggregation services
- Cross-protocol mappings
## Security Considerations
- Clients SHOULD validate all input
- Malicious or spam reviews may require external moderation or reputation systems

7
doc/nostr/notes.md Normal file
View File

@@ -0,0 +1,7 @@
# Notes
- NIP-73 for external IDs ("osm:node:123456"): https://github.com/nostr-protocol/nips/blob/744bce8fcae0aca07b936b6662db635c8b4253dd/73.md
- NIP 68/92/94 for place photos and image metadata (add "i" tag for "osm:node:123456" to NIP-68)
- Places NIP-XX draft PR: https://github.com/nostr-protocol/nips/pull/927
- NPM package for generating multi-resolution geotags: https://sandwichfarm.github.io/nostr-geotags/#md:nostr-geotags
- AppleSauce docs for AI agents: https://applesauce.build/introduction/mcp-server.html

250
doc/nostr/ranking.md Normal file
View File

@@ -0,0 +1,250 @@
# Ranking Algorithm
Your inputs:
- many users
- partial ratings
- different priorities
Your output:
> “Best place _for this user right now_”
---
## Step 1: Normalize scores
Convert 110 → 01:
```text
normalized_score = (score - 1) / 9
```
Why:
- easier math
- comparable across aspects
---
## Step 2: Per-aspect aggregation (avoid averages trap)
Instead of mean, compute:
### A. Positive ratio
```text
positive = score >= 7
negative = score <= 4
```
Then:
```text
positive_ratio = positive_votes / total_votes
```
---
### B. Confidence-weighted score
Use something like a **Wilson score interval** (this is key):
- prevents small-sample abuse
- avoids “1 review = #1 place”
---
## Step 3: Build aspect scores
For each aspect:
```text
aspect_score = f(
positive_ratio,
confidence,
number_of_reviews
)
```
You can approximate with:
```text
aspect_score = positive_ratio * log(1 + review_count)
```
(Simple, works surprisingly well)
---
## Step 4: User preference weighting
User defines:
```json
{
"quality": 0.5,
"value": 0.2,
"service": 0.2,
"speed": 0.1
}
```
Then:
```text
final_score = Σ (aspect_score × weight)
```
---
## Step 5: Context filtering (this is your unfair advantage)
Filter reviews before scoring:
- time-based:
- “last 6 months”
- context-based:
- lunch vs dinner
- solo vs group
This is something centralized platforms barely do.
---
## Step 6: Reviewer weighting (later, but powerful)
Weight reviews by:
- consistency
- similarity to user preferences
- past agreement
This gives you:
> “people like you liked this”
---
# 3. Example end-to-end
### Raw reviews:
| User | Food | Service |
| ---- | ---- | ------- |
| A | 9 | 4 |
| B | 8 | 5 |
| C | 10 | 3 |
---
### Derived:
- food → high positive ratio (~100%)
- service → low (~33%)
---
### User preferences:
```json
{
"food": 0.8,
"service": 0.2
}
```
→ ranks high
Another user:
```json
{
"food": 0.3,
"service": 0.7
}
```
→ ranks low
👉 Same data, different truth
Thats your killer feature.
---
# 4. Critical design choices (dont skip these)
## A. No global score in protocol
Let clients compute it.
---
## B. Embrace incomplete data
Most reviews will have:
- 13 aspects only
Thats fine.
---
## C. Time decay (important)
Recent reviews should matter more:
```text
weight = e^(-λ × age)
```
---
## D. Anti-gaming baseline
Even in nostr:
- spam will happen
Mitigation later:
- require minimum interactions
- reputation layers
---
# 5. What youve built (zooming out)
This is not a review system.
Its:
> A decentralized, multi-dimensional reputation graph for real-world places
Thats much bigger.
---
# 6. Next step (if you want to go deeper)
We can design:
### A. Query layer
- how clients fetch & merge nostr reviews efficiently
### B. Anti-spam / trust model
- web-of-trust
- staking / reputation
### C. OSM integration details
- handling duplicates
- POI identity conflicts
---
If I had to pick one next:
👉 **trust/reputation system** — because without it, everything you built _will_ get gamed.

100
doc/nostr/ratings.md Normal file
View File

@@ -0,0 +1,100 @@
# Canonical Aspect Vocabulary (v0.1)
## A. Core universal aspects
These should work for _any_ place:
```json
[
"quality", // core offering (food, repair, exhibits, etc.)
"value", // value for money/time
"experience", // comfort, usability, vibe
"accessibility" // ease of access, inclusivity
]
```
### Why these work
- **quality** → your “product” abstraction (critical)
- **value** → universally meaningful signal
- **experience** → captures everything “soft”
- **accessibility** → often ignored but high utility
👉 Resist adding more. Every extra “universal” weakens the concept.
---
## B. Common cross-domain aspects (recommended pool)
Not universal, but widely reusable:
```json
[
"service", // human interaction
"speed", // waiting time / turnaround
"cleanliness",
"safety",
"reliability",
"atmosphere"
]
```
These apply to:
- restaurants, garages, clinics, parks, etc.
---
## C. Domain-specific examples (NOT standardized)
Let clients define freely:
```json
{
"restaurant": ["food", "drinks"],
"bar": ["drinks", "music"],
"garage": ["work_quality", "honesty"],
"park": ["greenery", "amenities"],
"museum": ["exhibits", "crowding"]
}
```
---
## D. Key rule (this prevents chaos)
👉 **Aspect keys MUST be lowercase snake_case**
👉 **Meaning is defined socially, not technically**
To reduce fragmentation:
- publish a **public registry (GitHub repo)**
- clients can:
- suggest additions
- map synonyms
---
## E. Optional normalization hint (important later)
Allow this:
```json
"aspect_aliases": {
"food": "quality",
"work_quality": "quality"
}
```
Not required, but useful for aggregation engines.
---
## Notes
Map familiarity in UI to:
- high: “I know this place well”
- medium: “Been a few times”
- low: “First visit”

View File

@@ -0,0 +1,92 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.org/schemas/place-review.json",
"title": "Decentralized Place Review (Nostr/Event Content)",
"type": "object",
"required": ["version", "place", "ratings"],
"additionalProperties": false,
"properties": {
"version": {
"type": "integer",
"const": 1
},
"ratings": {
"type": "object",
"required": ["quality"],
"additionalProperties": false,
"properties": {
"quality": { "$ref": "#/$defs/score" },
"value": { "$ref": "#/$defs/score" },
"experience": { "$ref": "#/$defs/score" },
"accessibility": { "$ref": "#/$defs/score" },
"aspects": {
"type": "object",
"minProperties": 1,
"maxProperties": 20,
"additionalProperties": { "$ref": "#/$defs/score" },
"propertyNames": {
"pattern": "^[a-z][a-z0-9_]{1,31}$"
}
}
}
},
"recommend": {
"type": "boolean",
"description": "Whether the user recommends this place to others"
},
"familiarity": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "User familiarity with the place. Suggested interpretation: 'low' = first visit or very limited experience; 'medium' = visited a few times or moderate familiarity; 'high' = frequent visitor or strong familiarity."
},
"context": {
"type": "object",
"additionalProperties": false,
"properties": {
"visited_at": {
"type": "integer",
"minimum": 0
},
"duration_minutes": {
"type": "integer",
"minimum": 0,
"maximum": 1440
},
"party_size": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
},
"review": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"maxLength": 1000
},
"language": {
"type": "string",
"pattern": "^[a-z]{2}(-[A-Z]{2})?$"
}
}
}
},
"$defs": {
"score": {
"type": "integer",
"minimum": 1,
"maximum": 10
}
}
}

View File

@@ -103,8 +103,13 @@
},
"dependencies": {
"@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0",
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3"
"oauth2-pkce": "^2.1.3",
"rxjs": "^7.8.2"
}
}

802
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,16 @@
"source": "nullvoxpopuli/agent-skills",
"sourceType": "github",
"computedHash": "7909c3def6c4ddefb358d1973cf724269ede9f6cdba1dd2888e4e6072a897f3e"
},
"nak": {
"source": "soapbox-pub/nostr-skills",
"sourceType": "github",
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
},
"nostr": {
"source": "soapbox-pub/nostr-skills",
"sourceType": "github",
"computedHash": "e1e6834c18d18a5deef4cd9555f6eee0fc0b968acf1c619253999eda76beab8e"
}
}
}