diff --git a/.agents/skills/capacitor-compat/SKILL.md b/.agents/skills/capacitor-compat/SKILL.md new file mode 100644 index 00000000..9f1b6829 --- /dev/null +++ b/.agents/skills/capacitor-compat/SKILL.md @@ -0,0 +1,73 @@ +--- +name: capacitor-compat +description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior. +--- + +# Capacitor Compatibility + +Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features. + +## What Doesn't Work in WKWebView (iOS) + +- **`` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely. +- **`` new tabs** — programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app. +- **`window.open()`** — may be blocked or behave unexpectedly without user-gesture context. + +For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill. + +## File Downloads and URL Opening + +`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors. + +### `downloadTextFile(filename, content)` + +Saves a text file to the user's device. On web it uses the `` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`. + +```typescript +import { downloadTextFile } from '@/lib/downloadFile'; + +await downloadTextFile('backup.txt', fileContents); +``` + +### `openUrl(url)` + +Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor. + +```typescript +import { openUrl } from '@/lib/downloadFile'; + +await openUrl('https://example.com/image.jpg'); +``` + +**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. The utilities above work correctly on all platforms; manual anchors silently fail on iOS. + +## Detecting Native Platforms + +Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior: + +```typescript +import { Capacitor } from '@capacitor/core'; + +if (Capacitor.isNativePlatform()) { + // iOS or Android +} else { + // Web browser +} +``` + +Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic. + +## Installed Capacitor Plugins + +- `@capacitor/app` — app lifecycle events (deep links, back button) +- `@capacitor/core` — core runtime and platform detection +- `@capacitor/filesystem` — read/write files on the native filesystem +- `@capacitor/haptics` — native haptics +- `@capacitor/keyboard` — keyboard control (hide accessory bar, etc.) +- `@capacitor/local-notifications` — schedule local push notifications +- `@capacitor/share` — native share sheet +- `@capacitor/status-bar` — control the native status-bar style +- `@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec +- `capacitor-secure-storage-plugin` — OS-level secure storage (iOS Keychain / Android KeyStore) + +After adding or removing plugins, run `npm run cap:sync` to update the native projects. diff --git a/.agents/skills/ci-cd-publishing/SKILL.md b/.agents/skills/ci-cd-publishing/SKILL.md new file mode 100644 index 00000000..93f0e4cf --- /dev/null +++ b/.agents/skills/ci-cd-publishing/SKILL.md @@ -0,0 +1,350 @@ +--- +name: ci-cd-publishing +description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation. +--- + +# CI/CD Pipeline and Publishing + +Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target. + +## Pipeline Overview + +| Stage | Runs on | Job | +|-----------|---------------------------|-----------------------------------------| +| `test` | every commit (not tags) | `npm run test` | +| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) | +| `build` | tags only | `build-apk` (signed APK + AAB) + `build-ipa` (signed IPA on the Mac runner) | +| `release` | tags only | GitLab Release with APK / AAB / IPA links | +| `publish` | tags only | `publish-zapstore` + `publish-google-play` + `publish-app-store` | + +## Creating a Release + +Releases are triggered by pushing a version tag: + +```bash +npm run release +``` + +This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs. + +For the full versioning / changelog / native-build workflow, load the **`release`** skill. + +## Zapstore Publishing + +The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber. + +**Configuration files:** + +- `zapstore.yaml` — app metadata for Zapstore (name, tags, icon, supported NIPs) +- `.gitlab-ci.yml` — the `publish-zapstore` job definition + +**GitLab CI/CD variables** (Settings → CI/CD → Variables): + +| Variable | Description | Protected | Masked | Raw | +|---|---|---|---|---| +| `ZAPSTORE_BUNKER_URL` | NIP-46 bunker URL (`bunker://?relay=...`). No `secret` param needed after initial auth. | Yes | No | Yes | +| `ZAPSTORE_CLIENT_KEY` | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes | +| `ANDROID_KEYSTORE_BASE64` | Base64-encoded Android signing keystore | Yes | Yes | Yes | +| `KEYSTORE_PASSWORD` | Android keystore password | Yes | Yes | Yes | +| `KEY_PASSWORD` | Android key password | Yes | Yes | Yes | + +### How NIP-46 bunker auth works in CI + +NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval. + +The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client. + +### Initial setup (one-time) + +Run the NIP-46 client-initiated auth script: + +```bash +node scripts/nip46-auth.mjs +``` + +This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values. + +Options: +- `--relay ` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`) +- `--name ` — app name shown to the signer (default: `Ditto`) +- `--timeout ` — how long to wait for approval (default: 300) + +After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables. + +## nsite Publishing + +The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays. + +nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`. + +**GitLab CI/CD variables:** + +| Variable | Description | Protected | Masked | Raw | +|---|---|---|---|---| +| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes | + +### Initial setup (one-time) + +1. Install nsyte locally: + ```bash + curl -fsSL https://nsyte.run/get/install.sh | bash + ``` +2. Generate the CI credential: + ```bash + nsyte ci + ``` + This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once. +3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. + +### Configured relays and servers + +Relays the deploy job publishes to: + +- `wss://relay.ditto.pub` +- `wss://relay.nsite.lol` +- `wss://relay.dreamith.to` +- `wss://relay.primal.net` + +Blossom servers: + +- `https://blossom.primal.net` +- `https://blossom.ditto.pub` +- `https://blossom.dreamith.to` + +The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing. + +### Credential rotation + +To rotate the nsite credential: + +1. Revoke the old bunker connection in your signer app. +2. Run `nsyte ci` again to generate a new `nbunksec1...` string. +3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings. + +## Google Play Publishing + +The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track. + +**GitLab CI/CD variables:** + +| Variable | Description | Protected | Masked | Raw | +|---|---|---|---|---| +| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No | + +### Initial setup (one-time) + +1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate). +2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project. +3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it. +4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`. +5. **Base64-encode** the key file: + + ```bash + # Linux + base64 -w0 service-account.json + + # macOS + base64 -i service-account.json | tr -d '\n' + ``` + +6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value. + +### Key points + +- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles. +- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users. +- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses `--skip_upload_metadata`, `--skip_upload_images`, `--skip_upload_screenshots`). +- **Changelogs ("What's new in this version")** are uploaded from `android/fastlane/metadata/android/en-US/changelogs/.txt`, generated at CI time from the release summary paragraph in `CHANGELOG.md`. See "Release notes pipeline" below. +- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`). + +## App Store Publishing + +Ditto's iOS pipeline is split across two jobs: + +- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs. +- **`publish-app-store`** (stage `publish`, `tags: [macos]`) also runs on the self-hosted Mac runner. Consumes the IPA artifact via `needs: [build-ipa]` and the release-notes artifact via `needs: [release-notes]`. Decodes the API key, copies the release-notes summary into `ios/fastlane/metadata/en-US/release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + push the prebuilt IPA + auto-submit for App Store review. **macOS is required** even though the IPA is already signed: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. A Linux container ran into `No such file or directory @ dir_chdir0` from `JavaTransporterExecutor#execute` because `Helper.itms_path` resolved to a missing Xcode path. + +The Mac runner is therefore used for both iOS jobs. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill. + +**Configuration files:** + +- `ios/fastlane/Fastfile` — exposes four lanes: + - `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`. + - `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`. + - `release` — combines build_ipa + submit_release; convenience for local one-shot runs. + - `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill. +- `ios/fastlane/Appfile` — bundle identifier and team ID +- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo +- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it with the release summary paragraph from `CHANGELOG.md` per release +- `.gitlab-ci.yml` — `build-ipa` and `publish-app-store` both run on the Mac runner (`tags: [macos]`) + +**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`. + +**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI. + +**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`. + +**Release notes**: copied from the `release-notes` job's artifact `artifacts/release-notes-summary.txt` (the leading plaintext paragraph of the version's `CHANGELOG.md` section) into `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. See "Release notes pipeline" below. + +**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile). + +**GitLab CI/CD variables:** + +| Variable | Description | Protected | Masked | Raw | +|---|---|---|---|---| +| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes | +| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes | +| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes | +| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes | +| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_.p8` and removes it in `after_script`. | Yes | Yes | Yes | +| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes | + +### Initial setup (one-time) + +1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration. + +2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page). + + Set the three GitLab CI variables: + ```bash + # Replace , , and the path to your .p8 + curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \ + "https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \ + --data-urlencode "key=APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ + --data-urlencode "value=" \ + --data-urlencode "protected=true" --data-urlencode "raw=true" + # repeat for APP_STORE_CONNECT_API_KEY_ID + # for the .p8, base64 first: + base64 -i AuthKey_.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked) + ``` + +3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw). + +4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables. + +5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS): + + a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw). + + b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue): + + ```yaml + setup-match: + stage: publish + tags: [macos] + rules: + - if: $SETUP_MATCH == "1" + when: manual + script: + - export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" + - mkdir -p "$HOME/.private_keys" && chmod 700 "$HOME/.private_keys" + - echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH" + - chmod 600 "$ASC_KEY_PATH" + - cd ios + - export MATCH_GIT_BASIC_AUTHORIZATION="$MATCH_GIT_BASIC_AUTHORIZATION_WRITE" + - unset APP_STORE_CONNECT_API_KEY_PATH || true + - | + cat > Fastfile.setup <<'RUBY' + default_platform(:ios) + platform :ios do + lane :setup do + setup_ci + api_key = { + key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"), + issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"), + key: File.binread(ENV.fetch("ASC_KEY_PATH")), + duration: 1200, + in_house: false, + } + match(type: "appstore", readonly: false, api_key: api_key, force_for_new_devices: true) + end + end + RUBY + - mv fastlane/Fastfile fastlane/Fastfile.bak + - mv Fastfile.setup fastlane/Fastfile + - fastlane setup + - mv fastlane/Fastfile.bak fastlane/Fastfile + after_script: + - rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true + ``` + + c. Trigger the pipeline manually with `SETUP_MATCH=1`: + + ```bash + curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \ + "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline" \ + --data-urlencode "ref=main" \ + --data-urlencode "variables[][key]=SETUP_MATCH" \ + --data-urlencode "variables[][value]=1" + # Then play the manual setup-match job + ``` + + d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap. + +### Yearly cert renewal + +Apple distribution certs expire annually. Renewal is one command per year, run on any Mac: + +```bash +cd ~/Projects/ditto/ios +fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo +fastlane match appstore # creates new cert + profile, encrypts, commits, pushes +``` + +CI's next tag run picks up the new files automatically (`match(... readonly: true)`). + +### Disaster recovery (Mac dies / new developer joins) + +```bash +git clone https://gitlab.com/soapbox-pub/ditto.git +cd ditto/ios +fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD +``` + +No re-issuance of certs needed — the cert repo is the source of truth. + +### App Store Connect API key rotation + +App Store Connect API keys can be revoked anytime. To rotate: + +1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key +2. Download the new `.p8`, note the new key ID +3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables +4. (Issuer ID stays the same — it's per-team, not per-key) +5. Revoke the old key in App Store Connect + +### Key points + +- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (also Mac) submits it to Apple via `deliver`. +- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI. +- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`. +- Release notes ("What's New in This Version") come from the release-notes summary paragraph (see "Release notes pipeline" below). +- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in. +- `publish-app-store` does no code signing, but it still needs macOS: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. + +## Release notes pipeline + +Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: `CHANGELOG.md`. + +**The `release-notes` job** (stage `build`, default `node:22` image, runs only on `v*` tags) calls `scripts/extract-release-notes.mjs` twice and publishes two artifacts: + +- `artifacts/release-notes.md` — the full section for this version (summary paragraph + `### Added` / `### Changed` / etc. lists). Used as the GitLab Release description. +- `artifacts/release-notes-summary.txt` — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to `Ditto vX.Y.Z` if the section has no summary paragraph. + +**Downstream consumers** all pull from the `release-notes` job via `needs:`: + +| Consumer | Job | Artifact used | +|---|---|---| +| GitLab Release description | `release` | `release-notes.md` | +| App Store "What's New" | `publish-app-store` | `release-notes-summary.txt` → copied to `ios/fastlane/metadata/en-US/release_notes.txt` → uploaded by `deliver` | +| Play Store "What's new" | `publish-google-play` | `release-notes-summary.txt` → copied to `android/fastlane/metadata/android/en-US/changelogs/.txt` → uploaded by `supply` | +| In-app toast | `src/components/VersionCheck.tsx` (runtime) | Re-parses `public/CHANGELOG.md` via `parseChangelog()` and reads `entry.summary` (with a fallback to the legacy first-bullet behavior) | + +**The summary format** is documented in the `release` skill — a single plaintext paragraph immediately under the `## [X.Y.Z] - YYYY-MM-DD` heading, before any `### Category`. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build. + +**To preview locally** what each storefront will receive: + +```bash +node scripts/extract-release-notes.mjs vX.Y.Z # full GitLab Release body +node scripts/extract-release-notes.mjs vX.Y.Z --summary # storefront blurb +``` diff --git a/.agents/skills/file-uploads/SKILL.md b/.agents/skills/file-uploads/SKILL.md new file mode 100644 index 00000000..cb891662 --- /dev/null +++ b/.agents/skills/file-uploads/SKILL.md @@ -0,0 +1,83 @@ +--- +name: file-uploads +description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags. +--- + +# File Uploads on Nostr + +This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.). + +## The `useUploadFile` Hook + +```tsx +import { useUploadFile } from "@/hooks/useUploadFile"; + +function MyComponent() { + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + + const handleUpload = async (file: File) => { + try { + // Returns an array of NIP-94-compatible tags. + // The first tag is the `url` tag; its second element is the file URL. + const tags = await uploadFile(file); + const url = tags[0][1]; + // ...use the url + } catch (error) { + // ...handle errors (show a toast, etc.) + } + }; + + // ...rest of component +} +``` + +The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows. + +## Attaching Files to Events + +### Kind 0 (profile metadata) + +Use the plain URL in the relevant JSON field: + +```ts +const tags = await uploadFile(file); +const url = tags[0][1]; + +createEvent({ + kind: 0, + content: JSON.stringify({ ...existingMetadata, picture: url }), +}); +``` + +### Kind 1 (text notes) and other content events + +Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned: + +```ts +const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...] +const url = tags[0][1]; + +// Flatten the NIP-94 tags into a single imeta tag value. +const imeta = tags.map(([name, value]) => `${name} ${value}`); + +createEvent({ + kind: 1, + content: `Check this out ${url}`, + tags: [["imeta", ...imeta]], +}); +``` + +Repeat the pattern (one `imeta` tag per file) for multiple attachments. + +## Common Patterns + +- **Avatar / banner pickers:** wrap an `` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event. +- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`. +- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton. +- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI. + +## Constraints + +- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`. +- Don't store or display raw `File` objects after upload — always use the returned URL. +- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL. diff --git a/.agents/skills/git-workflow/SKILL.md b/.agents/skills/git-workflow/SKILL.md new file mode 100644 index 00000000..1d56a2b5 --- /dev/null +++ b/.agents/skills/git-workflow/SKILL.md @@ -0,0 +1,66 @@ +--- +name: git-workflow +description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section. +--- + +# Git Workflow + +Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog. + +## Pre-commit Validation + +**Your task is not finished until the code type-checks and builds without errors.** In priority order: + +1. **Type Checking** (required) — `tsc --noEmit` +2. **Building/Compilation** (required) — `vite build` +3. **Linting** (recommended; fix anything critical) — `eslint` +4. **Tests** (if available) — `vitest run` +5. **Git commit** (required) + +The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 1–4. + +## Using Git + +Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files. + +When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes. + +**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.** + +## Contributing Guide + +When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing. + +## Attributing Regressions + +When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA: + +``` +Fix missing background on expanded emoji picker in feeds + +The compose box overhaul accidentally dropped the bg-background class +when refactoring the picker out of QuickReactMenu. + +Regression-of: 3aa08ba9 +``` + +This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster. + +### When to add it + +- The commit fixes a bug (not a new feature, refactor, or doc change). +- The introducing commit is identifiable with reasonable effort. + +### When to skip it + +- The bug is pre-existing with no clear single origin. +- The behavior was always wrong (no regression). +- The introducing commit cannot be determined after a brief search. + +### Finding the introducing commit + +- `git log -S ''` — find commits that touched a specific string. +- `git log --oneline -- path/to/file` — list all commits touching a file. +- `git blame -L , -- path/to/file` — find who last changed specific lines. + +This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely. diff --git a/.agents/skills/mac-runner/SKILL.md b/.agents/skills/mac-runner/SKILL.md new file mode 100644 index 00000000..eaaba2a5 --- /dev/null +++ b/.agents/skills/mac-runner/SKILL.md @@ -0,0 +1,249 @@ +--- +name: mac-runner +description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates. +--- + +# Mac Runner Operations + +Ditto's iOS pipeline runs two CI jobs on a self-hosted GitLab Runner on a MacBook in the rack: `build-ipa` (signs and builds the IPA via Xcode + fastlane match) and `publish-app-store` (uploads the IPA via `fastlane deliver`, which shells out to Apple's iTMSTransporter — that tool only ships inside Xcode, so this job can't run on Linux). This skill covers operating the Mac. + +This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill. + +## Quick reference + +| Need | Command | +|---|---| +| SSH in | `ssh alex@alexs-air.lan` | +| Runner status | `gitlab-runner status` | +| Restart runner | `gitlab-runner restart` (after `eval "$(/opt/homebrew/bin/brew shellenv)"`) | +| Stdout log | `tail -f ~/gitlab-runner.out.log` | +| Stderr log | `tail -f ~/gitlab-runner.err.log` | +| Runner config | `~/.gitlab-runner/config.toml` | +| LaunchAgent plist | `~/Library/LaunchAgents/gitlab-runner.plist` | + +## Architecture + +- **Host**: `alexs-air.lan` (Apple Silicon MacBook, macOS 26+, Xcode 26+) +- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling) +- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/` +- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot. +- **Tags**: `macos`, `ios`, `xcode` — both `build-ipa` and `publish-app-store` in `.gitlab-ci.yml` target this runner. `publish-app-store` doesn't sign anything, but it still needs Xcode's bundled iTMSTransporter to push the IPA to App Store Connect. +- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3. + +### Why Ruby 3.3, not the brewed 4.0 + +Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`. + +### Why IPv6 is disabled on Wi-Fi + +`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`. + +## Verifying the runner is healthy + +From any machine: + +```bash +curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \ + "https://gitlab.com/api/v4/runners/53111580" \ + | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['online'])" +``` + +Expected: `online True`. If `offline` or `not_connected`, SSH in and check: + +```bash +ssh alex@alexs-air.lan +gitlab-runner status +ps aux | grep gitlab-runner +tail -50 ~/gitlab-runner.err.log +``` + +## Restarting the runner + +After a Mac reboot, the runner should start automatically via the LaunchAgent. To restart manually: + +```bash +ssh alex@alexs-air.lan +eval "$(/opt/homebrew/bin/brew shellenv)" +gitlab-runner restart +``` + +If `gitlab-runner restart` reports "service not installed", reinstall: + +```bash +gitlab-runner install +gitlab-runner start +``` + +This rewrites the LaunchAgent plist. + +## Watching a CI job run live + +```bash +ssh alex@alexs-air.lan 'tail -f ~/gitlab-runner.out.log' +``` + +The runner streams build output to stdout. The same output appears in the GitLab job UI. + +## Updating Xcode + +After a major Xcode update: + +```bash +ssh alex@alexs-air.lan +sudo xcodebuild -license accept # accept the new license non-interactively +xcode-select --install # ensure command-line tools are present +xcodebuild -version # confirm version +``` + +Then trigger a no-op tag rebuild (e.g. cut a patch release) to verify the runner still works. + +## Debugging fastlane locally + +If `build-ipa` fails in CI, reproduce on the Mac. The env vars below mirror what CI sets up: + +```bash +ssh alex@alexs-air.lan +cd ~/Projects/ditto +git pull origin main +eval "$(/opt/homebrew/bin/brew shellenv)" + +# Match what CI provides +export CI_COMMIT_TAG=v2.x.y +export CI_PIPELINE_IID=99999 +export MATCH_PASSWORD='' +export MATCH_GIT_BASIC_AUTHORIZATION='' +export APP_STORE_CONNECT_API_KEY_ID= +export APP_STORE_CONNECT_API_KEY_ISSUER_ID= +export ASC_KEY_PATH=~/.private_keys/AuthKey_.p8 + +# Build web assets and sync to Capacitor iOS project (CI does this in before_script) +npm ci +npx vite build -l error +cp dist/index.html dist/404.html +npx cap sync ios +node scripts/patch-cap-config.mjs + +# Stamp marketing version (CI does this in script) +VERSION="${CI_COMMIT_TAG#v}" +sed -i '' "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g" ios/App/App.xcodeproj/project.pbxproj + +# Run the build lane +cd ios +fastlane build_ipa +``` + +This produces the IPA at `../artifacts/Ditto.ipa` exactly like CI. Add `--verbose` for detailed output. + +To also test the submission step end-to-end (this calls Apple, so be ready to "Remove from Review" in App Store Connect afterward): + +```bash +export IPA_PATH="$HOME/Projects/ditto/artifacts/Ditto.ipa" +fastlane submit_release +``` + +Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below). + +## Rotating match certificates (yearly) + +Apple distribution certs expire one year after issuance. To renew: + +```bash +ssh alex@alexs-air.lan +cd ~/Projects/ditto/ios +eval "$(/opt/homebrew/bin/brew shellenv)" + +# Set Apple credentials (API key path) +export MATCH_PASSWORD='' + +# Revoke the expiring cert in Apple's portal and remove from the match repo +fastlane match nuke distribution + +# Issue a new cert, generate a new App Store profile, encrypt, commit, push +fastlane match appstore \ + --api_key_path ~/.private_keys/AuthKey_.p8 \ + --api_key_id \ + --api_issuer_id +``` + +CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update. + +## Debugging App Store submission with the `submit_only` lane + +The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build. + +```bash +ssh alex@alexs-air.lan +export PATH="$HOME/.gem/ruby/3.3.0/bin:/opt/homebrew/opt/ruby@3.3/bin:$PATH" +cd ~/Projects/ditto/ios + +# Make sure the .p8 is on disk; CI's after_script wipes it after each job +scp $LAPTOP:/path/to/AuthKey_.p8 ~/.private_keys/ + +export ASC_KEY_PATH=$HOME/.private_keys/AuthKey_.p8 +export APP_STORE_CONNECT_API_KEY_ID= +export APP_STORE_CONNECT_API_KEY_ISSUER_ID= +export BUILD_NUMBER= +export VERSION= + +fastlane submit_only +``` + +The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed. + +If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number. + +## Inspecting App Store Connect state directly + +When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version: + +```ruby +require "json"; require "openssl"; require "net/http"; require "base64" +key_pem = File.read(ENV["ASC_KEY_PATH"]) +ec = OpenSSL::PKey::EC.new(key_pem) +header = { alg: "ES256", kid: ENV["APP_STORE_CONNECT_API_KEY_ID"], typ: "JWT" } +payload = { iss: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], iat: Time.now.to_i, exp: Time.now.to_i + 1200, aud: "appstoreconnect-v1" } +def b64(s); Base64.urlsafe_encode64(s, padding: false); end +si = b64(JSON.generate(header)) + "." + b64(JSON.generate(payload)) +sig_der = ec.sign(OpenSSL::Digest::SHA256.new, si) +asn = OpenSSL::ASN1.decode(sig_der) +r = asn.value[0].value.to_s(2); s = asn.value[1].value.to_s(2) +r = ("\x00".b * (32 - r.bytesize)) + r if r.bytesize < 32 +s = ("\x00".b * (32 - s.bytesize)) + s if s.bytesize < 32 +jwt = si + "." + b64(r + s) +# Now: GET https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=pub.ditto.app +# with header Authorization: Bearer +``` + +Useful endpoints: +- `GET /v1/apps?filter[bundleId]=pub.ditto.app` → app id +- `GET /v1/apps//appStoreVersions` → version list with `appStoreState` +- `GET /v1/apps//builds?sort=-uploadedDate` → recent builds and processing state +- `GET /v1/appStoreVersions//appStoreVersionLocalizations` → release notes (`whatsNew`) + +## What can go wrong + +| Symptom | Likely cause | Fix | +|---|---|---| +| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` | +| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` | +| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above | +| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables | +| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables | +| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) | +| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` | +| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` | +| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) | +| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` | +| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. | +| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore ' DEVELOPMENT_TEAM="` in the Fastfile (already configured) | +| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var | +| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) | +| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version | +| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) | + +## When the Mac dies + +1. Get a replacement Mac. Install Xcode from the App Store. +2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag. +3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`. +4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth. diff --git a/.agents/skills/nip19-routing/SKILL.md b/.agents/skills/nip19-routing/SKILL.md new file mode 100644 index 00000000..8b056ba3 --- /dev/null +++ b/.agents/skills/nip19-routing/SKILL.md @@ -0,0 +1,146 @@ +--- +name: nip19-routing +description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events. +--- + +# NIP-19 Identifier Routing + +NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`. + +Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system. + +## Identifier Reference + +| Prefix | Payload | Use when… | +|--------------|------------------------------------------------------------------|--------------------------------------------------------------| +| `npub1` | 32-byte public key | Simple user reference | +| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context | +| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread | +| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context | +| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products | +| `nsec1` | Private key | **Never display or route** — treat as a 404 | +| `nrelay1` | Relay URL | Deprecated | + +### `note1` vs `nevent1` + +- `note1` carries only an event ID, and is canonically tied to kind:1 text notes. +- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link. + +### `npub1` vs `nprofile1` + +- `npub1` is just a pubkey. +- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters. + +## Routing Rules + +1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`. +2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this. +3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag. + +## Decoding and Filtering + +Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter. + +```ts +import { nip19 } from 'nostr-tools'; + +const decoded = nip19.decode(value); // throws on invalid input + +switch (decoded.type) { + case 'npub': { + const pubkey = decoded.data; // hex string + return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]); + } + + case 'nprofile': { + const { pubkey /*, relays */ } = decoded.data; + return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]); + } + + case 'note': { + const id = decoded.data; + return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]); + } + + case 'nevent': { + const { id /*, relays, author, kind */ } = decoded.data; + return nostr.query([{ ids: [id], limit: 1 }]); + } + + case 'naddr': { + const { kind, pubkey, identifier } = decoded.data; + return nostr.query([{ + kinds: [kind], + authors: [pubkey], // critical: prevents d-tag spoofing + '#d': [identifier], + limit: 1, + }]); + } + + default: + // nsec, nrelay, unknown → 404 + throw new Error('Unsupported Nostr identifier'); +} +``` + +### Common mistakes + +```ts +// ❌ Passing bech32 into a filter +nostr.query([{ ids: [naddr] }]); + +// ❌ Addressable lookup without the author — anyone can spoof the d-tag +nostr.query([{ kinds: [30023], '#d': [slug] }]); + +// ✅ Decode first, then include author +const { kind, pubkey, identifier } = nip19.decode(naddr).data; +nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]); +``` + +## Populating `NIP19Page` + +`src/pages/NIP19Page.tsx` already: + +- Decodes `params.nip19` with `nip19.decode`. +- Branches on `decoded.type` with a section for each supported identifier. +- Redirects invalid / unsupported identifiers to the 404 page. +- Provides a responsive container wrapper. + +To turn it into a real router, replace each placeholder section with a concrete component: + +| `decoded.type` | Typical view | +|-----------------------|---------------------------------------------------------------| +| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events | +| `note` | Single kind:1 text note with thread + replies | +| `nevent` | Generic event renderer; branch on `kind` for specialized UIs | +| `naddr` | Addressable-event view (article, product, community, etc.) | + +Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch. + +## Linking to NIP-19 Routes + +When building links elsewhere in the app: + +```tsx +import { nip19 } from 'nostr-tools'; +import { Link } from 'react-router-dom'; + +// To a profile +Profile + +// To an addressable event (article, product, …) + + Open + + +// To a specific event of any kind, with relay hints +Open +``` + +Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays. + +## Security Recap + +- Decode **before** querying. +- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary. +- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`. diff --git a/.agents/skills/nip85-stats/SKILL.md b/.agents/skills/nip85-stats/SKILL.md new file mode 100644 index 00000000..7e01e18d --- /dev/null +++ b/.agents/skills/nip85-stats/SKILL.md @@ -0,0 +1,190 @@ +--- +name: nip85-stats +description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig. +--- + +# NIP-85 Trusted Assertion Stats + +[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider. + +This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers. + +## Kinds Used + +| Kind | Subject | `d` tag value | +| ----- | ---------------------------- | -------------------------- | +| 30382 | User | user pubkey (hex) | +| 30383 | Event (regular, kind 1 etc.) | event id (hex) | +| 30384 | Addressable event | `::` | + +The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it. + +## Files Provided by This Skill + +| Skill file | Copy to | +|---|---| +| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` | + +## Setup Instructions + +### 1. Copy the Hooks File + +Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template. + +### 2. Add `nip85StatsPubkey` to `AppConfig` + +In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface: + +```typescript +export interface AppConfig { + // ...existing fields... + /** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */ + nip85StatsPubkey: string; +} +``` + +### 3. Update the Zod Schema in `AppProvider.tsx` + +In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`: + +```typescript +const AppConfigSchema = z.object({ + // ...existing fields... + nip85StatsPubkey: z.string().refine( + (val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val), + { message: 'Must be empty or a 64-character hex pubkey' }, + ), +}) satisfies z.ZodType; +``` + +### 4. Set the Default in `App.tsx` + +Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default: + +```typescript +const defaultConfig: AppConfig = { + // ...existing fields... + nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea', +}; +``` + +Set to `''` to ship with stats disabled. + +### 5. Update `TestApp.tsx` + +In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider: + +```typescript +const defaultConfig: AppConfig = { + // ...existing fields... + nip85StatsPubkey: '', +}; +``` + +## Usage + +### User stats (kind 30382) + +```tsx +import { useNip85UserStats } from '@/hooks/useNip85Stats'; + +function FollowerCount({ pubkey }: { pubkey: string }) { + const { data: stats } = useNip85UserStats(pubkey); + if (!stats) return null; // no provider configured or no assertion yet + return {stats.followers.toLocaleString()} followers; +} +``` + +### Event stats (kind 30383) + +```tsx +import { useNip85EventStats } from '@/hooks/useNip85Stats'; + +function NoteStats({ eventId }: { eventId: string }) { + const { data: stats } = useNip85EventStats(eventId); + if (!stats) return null; + return ( +
+ {stats.reactionCount} reactions + {stats.repostCount} reposts + {stats.commentCount} comments + {stats.zapAmount} sats +
+ ); +} +``` + +### Addressable event stats (kind 30384) + +The `addr` argument is the full NIP-01 event address `::`: + +```tsx +import { useNip85AddrStats } from '@/hooks/useNip85Stats'; + +function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) { + const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`); + if (!stats) return null; + return {stats.reactionCount} reactions; +} +``` + +## Behavior Notes + +- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth. +- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI. +- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically. +- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics. +- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts. + +## Extending the Stats + +The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`: + +```typescript +export interface Nip85UserStats { + followers: number; + postCount: number; + rank: number; // new + zapAmtReceived: number; // new +} + +// inside useNip85UserStats queryFn +return { + followers: getIntTag(tags, 'followers'), + postCount: getIntTag(tags, 'post_cnt'), + rank: getIntTag(tags, 'rank'), + zapAmtReceived: getIntTag(tags, 'zap_amt_recd'), +}; +``` + +See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md). + +## Exposing a Provider Picker (Optional) + +If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value: + +```tsx +import { useAppContext } from '@/hooks/useAppContext'; + +function StatsProviderInput() { + const { config, updateConfig } = useAppContext(); + return ( + { + const v = e.target.value.trim().toLowerCase(); + if (v === '' || /^[0-9a-f]{64}$/.test(v)) { + updateConfig(() => ({ nip85StatsPubkey: v })); + } + }} + placeholder="64-char hex pubkey (blank to disable)" + /> + ); +} +``` + +## Related NIPs + +- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill) +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`::`) +- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate) diff --git a/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts b/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts new file mode 100644 index 00000000..38f87993 --- /dev/null +++ b/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts @@ -0,0 +1,156 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { useAppContext } from '@/hooks/useAppContext'; + +/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */ +export interface Nip85EventStats { + commentCount: number; + repostCount: number; + reactionCount: number; + zapCount: number; + /** Zap amount in sats. */ + zapAmount: number; +} + +/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */ +export interface Nip85UserStats { + followers: number; + postCount: number; +} + +/** + * Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing + * or unparseable, which mirrors the semantics of "no data" in NIP-85. + */ +function getIntTag(tags: string[][], tagName: string): number { + const tag = tags.find(([name]) => name === tagName); + if (!tag?.[1]) return 0; + const n = parseInt(tag[1], 10); + return Number.isFinite(n) ? n : 0; +} + +/** + * Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey. + * Returns `null` when no stats pubkey is configured or the provider has no + * assertion for this event. + */ +export function useNip85EventStats(eventId: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-event-stats', eventId, statsPubkey], + queryFn: async ({ signal }) => { + if (!eventId || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + commentCount: getIntTag(tags, 'comment_cnt'), + repostCount: getIntTag(tags, 'repost_cnt'), + reactionCount: getIntTag(tags, 'reaction_cnt'), + zapCount: getIntTag(tags, 'zap_cnt'), + zapAmount: getIntTag(tags, 'zap_amount'), + }; + } catch { + return null; + } + }, + enabled: !!eventId && !!statsPubkey, + staleTime: 30 * 1000, + retry: false, + }); +} + +/** + * Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey. + * Returns `null` when no stats pubkey is configured or the provider has no + * assertion for this pubkey. + */ +export function useNip85UserStats(pubkey: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-user-stats', pubkey, statsPubkey], + queryFn: async ({ signal }) => { + if (!pubkey || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + followers: getIntTag(tags, 'followers'), + postCount: getIntTag(tags, 'post_cnt'), + }; + } catch { + return null; + } + }, + enabled: !!pubkey && !!statsPubkey, + staleTime: 60 * 1000, + retry: false, + }); +} + +/** + * Fetches NIP-85 addressable event stats (kind 30384) from the configured + * stats pubkey. The `addr` argument is the full NIP-01 event address string, + * e.g. `30023::`. + */ +export function useNip85AddrStats(addr: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-addr-stats', addr, statsPubkey], + queryFn: async ({ signal }) => { + if (!addr || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + commentCount: getIntTag(tags, 'comment_cnt'), + repostCount: getIntTag(tags, 'repost_cnt'), + reactionCount: getIntTag(tags, 'reaction_cnt'), + zapCount: getIntTag(tags, 'zap_cnt'), + zapAmount: getIntTag(tags, 'zap_amount'), + }; + } catch { + return null; + } + }, + enabled: !!addr && !!statsPubkey, + staleTime: 30 * 1000, + retry: false, + }); +} diff --git a/.agents/skills/nostr-direct-messages/SKILL.md b/.agents/skills/nostr-direct-messages/SKILL.md deleted file mode 100644 index 26f3ef56..00000000 --- a/.agents/skills/nostr-direct-messages/SKILL.md +++ /dev/null @@ -1,478 +0,0 @@ ---- -name: nostr-direct-messages -description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17). ---- - -# Direct Messaging on Nostr - -This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage. - -**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application. - -## Setup Instructions - -### 1. Add DMProvider to Your App - -First, add the `DMProvider` to your app's provider tree in `src/App.tsx`: - -```tsx -// Add these imports at the top of src/App.tsx -import { DMProvider, type DMConfig } from '@/components/DMProvider'; -import { PROTOCOL_MODE } from '@/lib/dmConstants'; - -// Add this configuration before your App component -const dmConfig: DMConfig = { - // Enable or disable DMs entirely - enabled: true, // Set to true to enable messaging functionality - - // Choose one protocol mode: - // PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only - // PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only - // PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17) - protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps -}; - -// Then wrap your app components with DMProvider: -export function App() { - return ( - - - - - - - - - - - - - - - - - - - - ); -} -``` - -### 2. Configure DM Settings - -The `DMConfig` object supports the following options: - -- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed. -- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support: - - `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only - - `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended) - - `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility) - -**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain. - -## Quick Start - -### 1. Send Messages - -```tsx -import { useDMContext } from '@/hooks/useDMContext'; -import { MESSAGE_PROTOCOL } from '@/lib/dmConstants'; - -function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) { - const { sendMessage } = useDMContext(); - const [content, setContent] = useState(''); - - const handleSend = async () => { - await sendMessage({ - recipientPubkey, - content, - protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping - }); - setContent(''); - }; - - return ( -
{ e.preventDefault(); handleSend(); }}> -