'Always allow…' on a sign_event request now opens an inline kind editor
prefilled with the request's own kind, so the standing grant's scope is
chosen while the user sees the event. Approved with grant_kinds, the
backend records exactly the edited scope (normalised); without them the
fallback stays the request's own kind. Both approval UIs (Signer and
Signer Mode) share the parseKindsInput helper; the bunker status JSON now
carries pending 'details' so the legacy screen can prefill too.
Also fixes a latent bug: AppProvider.signerApprove dropped the 'always'
argument, so the legacy 'Always allow' button never actually recorded a
grant.
The 'Always-allow permissions' card on the Signer screen now edits an
existing grant's event-kind scope via the signer_grant_update RPC:
Edit toggles an input (comma-separated kinds, client-validated), Save
applies, Revert restores. Grants list is left alone by the 5s poll so
an open editor is never yanked out from under the user.
Step 4 remainder, first half: Vault::update_signer_grant_kinds replaces a
standing grant's kind scope (sorted, deduped; empty = all kinds, a
deliberate broadening matching legacy semantics). New signer_grant_update
IPC + api/AppProvider/fakeBackend plumbing. The Signer-screen editor UI
is the next unit; no UI surface calls the RPC yet.
The app icon is a dark glyph on transparency — invisible on dark launcher
backgrounds. icon-white.png keeps the exact silhouette/alpha with opaque
pixels recolored white; generated by make_icon_white.py. Used by the
desktop entry (launcher/taskbar); in-app artwork unchanged.
- package.json homepage: github.com/avi/Keynctr -> git.atitlan.io/avi/Keynctr
- README: title 'Nostr Feed Manager' -> 'Keynctr', resolve all
[YOUR_FORGEJO_INSTANCE_URL]/<OWNER>/<REPO> placeholders with the real
Forgejo URL, fix clone dir and packaged-binary name (nost-feed-manager
-> keynectr), data dir default ~/.local/share/keynectr, refresh test
counts (cargo 225+6 e2e, npm 139/19 files) and project layout
(audit/bunker/feed/updates modules, signer/ dir, Feed+SignerMode screens)
- PRODUCT.md: data dir corrected with migration note
- launch-keynctr.sh: builds renderer/electron main if missing, exports
KEYNCTR_ENABLE_GPU=1 (software rendering dies 'GPU process isn't
usable' on this Hyprland box), execs bundled electron with
--class=keynectr for WM_CLASS grouping.
- keynctr.desktop installed at ~/.local/share/applications/ (validated,
icon = public/icon.png, categories Utility, StartupWMClass keynectr).
- requestSingleInstanceLock: second launch focuses the existing window
(app.exit(0) in the doomed instance) instead of a duplicate shell
fighting the vault.
Verified via gtk-launch: 'keynectr | Keynctr' window maps; second
gtk-launch keeps exactly 1 window.
First pass was abstract gradient washes; the reference mock is the
synthwave sun/tower illustration itself behind frosted-glass panels.
Now: real artwork (public/archipelago-bg.jpg from the user's reference)
fills .main cover/fixed under a dark scrim, cards + sidebar are
translucent blurred glass with light hairline borders, coral accent and
is-active nav pill kept. Verified computed background stack in browser;
139 frontend tests + build green.
Workshop Dark accent is now #007AFF (hover #4da3ff, soft #0f2a44, white labels), including the ambient wash tint. New Settings -> Appearance -> Accent color: a color picker plus hex field that overrides primary/focus for ANY theme, with a 'Use theme default' reset. Persisted in settings.json (accent_color), validated server-side (#rrggbb, empty clears), painted over the theme via inline CSS custom properties with automatic hover/soft mixes and luminance-based label contrast. Verified live: .btn-primary renders rgb(0,122,255)/white under workshop-dark. 220 Rust unit + 6 e2e, 139 frontend tests (4 new), clippy 0, all gates green, release rebuilt.
New app:selfupdate IPC (main process): npm run build + cargo build --release with the augmented PATH, then kill the backend child, reset the spawn flag so the next request starts the NEW binary, and reloadIgnoringCache every window. The Electron shell keeps running — no manual restart. Settings install now triggers it automatically when restart_required. Honest limits: a change to the Electron main process itself still needs one manual relaunch, and packaged builds report that bundle replacement is the update path.
The logo PNG is dark ink art; invert+brighten it on the dark workshop material (same technique cosmic uses) so it reads as warm paper ink on #12110f. Nav icons are currentColor and already themed. Verified filter present in built bundle.
The Moi dark material is not just tokens: site.css paints a 24px hairline grid (rgba(235,230,220,0.035)) plus pine and clay radial washes over the paper. Applied the identical background stack to .main under both workshop themes (fixed attachment), with per-theme --wk-grid/--wk-wash-mint/--wk-wash-clay/--wk-copper tokens from Moi's light and dark blocks. Sidebar stays a clean opaque surface. Verified against the live Moi site in a browser: both render body #12110f with grid:true, wash:true, pine #7eb89a, copper #d4a574.
Moi DESIGN.md dark column: paper #12110f, surface #1c1a17, stone #26221c, warm ink #ebe6dc, pale pine #7eb89a. Same serif type and radii as the light Workshop theme. Verified rendering live via computed styles (body rgb(18,17,15), cards rgb(28,26,23), pine #7eb89a).
Warm paper (#f3efe6), near-black ink, hairline borders, one pine accent (#215c48), serif display headings, Moi radii (14/9). Tokens verified rendering live via computed styles on the built shell: body bg #f3efe6, card #faf7f0, border #d9d1c3, h2 Iowan/Palatino 500. Settings -> Appearance -> 'Workshop — Cybernetic'.
Applying npm audit fix + npm update + cargo update via the (fixed) updater: clears the high-severity js-yaml advisory (maxTotalMergeKeys CPU use on empty merge sources). Full suites verified green after the bump: 219 Rust unit + 6 e2e, 135 frontend, vite build clean.
Step 4 groundwork, the enforcement half that was invisible or too broad:
- Nip46Status now carries the connection's declared perms= grant list and
its expiry; Signer Mode shows a Permissions panel on a live session
(explicit grant rows, or a plain statement that the signer app approves
each request when no list was declared).
- 'Always allow' grants are kind-scoped: a sign_event grant records the
kind of the request the user actually approved and never covers other
kinds. Legacy kind-less grants keep their all-kinds meaning so existing
vaults keep working. Enforced in both bunker.rs and nip46_client.rs.
- Grants list on the Signer screen renders human labels with kind scope.
Tests: vault kind-scoping unit tests, frontend permission-label unit
tests + two SignerModeScreen tests (declared list, signer-side note).
One live NIP-46 session, many saved ones (Option A):
- start_pairing/connect while a session is live PARKS it instead of
refusing: row, pairing secret, and persisted client key stay intact,
so the parked account is restorable with no fresh scan.
- SelectProfile follows the switch: target has a restorable connection ->
park current + re-dial target's row (expected_identity guard applies);
target is local-key or unpaired -> live session untouched.
- New nip46_cancel_pairing IPC: aborts ONLY an in-flight pairing and
re-dials the parked session, so cancel-after-park is transparent.
The QR cancel paths (Add-profile modal, Signer Mode screen) use it —
plain disconnect would revoke the parked connection.
- e2e: two fake Ambers on one relay; A pairs, B's pairing parks A
(revoked_at none, client key resolvable), switch back re-dials A and
signs; no-op switch; local profile leaves session alone; B restorable.
Sep 25 feedback: the prefilled 'Amber' name had to be cleared letter by
letter before the QR would appear (the input fight), and naming should
be automatic anyway. One click on 'Sign in with a signer app (Amber)'
now goes straight to the QR with the seed label 'Amber'; the existing
adopt_identity background enrichment fetches the account's kind-0 after
the handshake and upgrades the profile row to the account's REAL
display name whenever it still carries our seed (a manual rename in
Profiles always wins and is never overwritten).
frontend: 125 tests green (pairing test asserts the one-click path and
the 'Amber' seed label), typecheck/lint/format/electron:build/build
green. Rust untouched — the auto-naming enrichment already shipped at
a76d8df.
The pairing path minted its profile under the backend's 'Remote Signer'
default, so several test pairings left indistinguishable rows. The
'Sign in with a signer app (Amber)' button now shows a prefilled
('Amber') Connection name step first; the QR step starts only from
there and the typed name reaches nip46_pair_start as the profile label.
Enter submits directly (input focused, prefilled).
Also cleaned the live vault (not in git): dropped 2 stale 'Remote
Signer' connections and the secret-less 'Dev' remote stub tied to them,
plus 13 orphaned connection_secrets entries; kept the live Amber
pairing (npub1qn0w4a…, its connection + client key) and Testing123.
frontend: 125 tests passed (label step covered: prefill, typed label
reaches nip46_pair_start), typecheck/lint/format/electron:build/build
green. Rust untouched.
The Amber pairing QR existed but only behind the sidebar's Signer Mode,
so clicking 'Add profile' expectedly led to a local-key form and users
never found the signer flow. The modal now opens as a choice:
- 'Sign in with a signer app (Amber)' — mints the nostrconnect:// QR
inline, polls signer status every 2s (same channel as Signer Mode),
and flips to an 'Amber is now your signer!' confirmation once the
handshake lands; cancelling mid-pairing aborts it cleanly.
- 'Create a new key on this computer' — the previous local-key form,
unchanged, with a Back step.
frontend: 125 tests passed (CreateProfileModal suite rewritten to cover
choice, QR start, connected poll, cancel-abort; App.test updated for the
new dialog title), typecheck/lint/format:check/electron:build/build green.
- Home first-run now has three entry points: create a new profile, I already
have an account (opens ImportProfileModal), and Sign in with a signer
(navigates to Signer Mode where Amber/NIP-46 pairing lives).
- Copy states the per-mode truth: local-vault keys vs remote signer where the
private key never lives on this device.
- Tests updated to assert all three entry points; 116 frontend tests green.
- .gitignore now covers .directory, .opencode/, .impeccable/.
- SignerScreen.tsx reformatted (format:check was failing since 81b082f).
- Dead untracked stub src/signer/nip46_external.rs deleted from disk
(superseded by nip46_client.rs, never declared in signer/mod.rs).
Apps asking Keynctr to sign (NIP-46) can now be granted standing
permission per (peer pubkey, method). Approvals gained an 'Always
allow' option; existing grants are listed with a Revoke button on the
Signer screen and persist in the encrypted vault.
Keynctr is the NIP-46 client; Amber is the scanner. Amber hands out no
link — it scans one — so the signer screen now mints a pairing token:
- start_pairing(): ephemeral key + secret, nostrconnect:// token via
NostrConnectUri::client_with_secret, status().pairing_uri for the GUI
- run_pairing_task(): listens for the signer's connect request, echoes
the secret (anti-spoofing), persists the connection row + secret,
then adopts identity via get_public_key and hands to the demux loop
- pairing subscription is closed at handoff so the demux loop owns the
conversation (relay could otherwise deliver signer replies under the
stale pairing sub id where nobody routes them)
- IPC: nip46_pair_start; status carries pairing_uri
- SignerModeScreen: 'Show QR' button, QR render (qrcode) of the token,
copy-link fallback, cancel; paste-link flow unchanged
- e2e: fake QR scanner consumes the real pairing token end-to-end
(scan -> secret echo -> identity -> sign -> vault persistence)
- SignerManager/SignerModeScreen parse both nostrconnect:// and bunker://
(Amber presents bunker://; signer pubkey extracted before '@')
- Signer permission surface made async (permissions, can_*, is_connection_valid)
- tests/nip46_e2e.rs: full client handshake against fake Amber over a local
relay — NIP-44 round-trip, get_public_key identity, signed-event verification,
vault persistence asserting no secret material for remote profiles
- prettier formatting of touched frontend files
- public/icon.png and src/assets/logo.png were grayscale (mode L): a flat
white field, no alpha, which rendered as a white box/halo on every
non-white surface (window/taskbar icon, sidebar, launchers)
- regenerate both as RGBA: alpha is the ink coverage, RGB forced to 0 so
no white matte can leak through semi-transparent edge pixels
- styles.css: drop the white tile background/border-radius and cover-fit
from .sidebar-logo; the artwork now composites directly (contain-fit)
- originals preserved under deferred/original-icons/
- detect the session platform explicitly (Hyprland exports both DISPLAY
and WAYLAND_DISPLAY) and set ozone-platform before Chromium init
- software rendering by default on Linux: the GPU process segfaults in
eglCreateWindowSurface on some Mesa/Wayland setups (reproduced on
Intel Iris Xe under Hyprland), so hardware GL is opt-in via
KEYNCTR_ENABLE_GPU=1
- startup watchdog + bounded relaunch ladder (platform swap, then GPU
opt-in) when a launch dies before its window paints; give-up dialog
lists the escape hatches
- sandbox pre-flight: skip the SUID sandbox when user namespaces are
restricted (Ubuntu 24.04 AppArmor) instead of failing silently
The packaging break: frontend/build/icon.png was deleted from the working
tree, so electron-builder had no Linux icon and every AppImage/deb it
emitted carried the generic Electron placeholder instead of the Keynctr
mark.
Regenerate build/icon.png from KeynectrAppIconPossibility02.jpeg rather than
resizing the old file:
- cut the white (254) JPEG background to transparent (alpha from luma),
- flatten the art to a square canvas with symmetric padding,
- keep the ink pure black (RGB 0,0,0), export 512x512 RGBA.
The 512x512 master satisfies both declared linux targets with the config
kept single (linux.icon: build/icon.png, no build/linux/ fan-out):
- AppImage: electron-builder downscales to 256 internally (>=256 required).
- deb: installs usr/share/icons/hicolor/512x512/apps/keynectr.png, matching
the generated .desktop Icon=keynectr.
Verified end to end (npm run dist green): the embedded icon is byte-identical
(md5 b3e372f7) in the AppImage hicolor set, the AppImage .DirIcon, and the
deb hicolor set, all 512x512 RGBA with real transparency.
The source JPEG (KeynectrAppIconPossibility02.jpeg) stays untracked, as does
the pre-existing hygiene leftover set. No package.json change needed: the
single build/icon.png path is already correct.
Replace the plain reveal_secret_key flow with an explicit, audited key
export that is hard to misuse:
Backend (src/app.rs, src/ipc.rs):
- New App::export_secret_key(): always requires the vault passphrase
(even when the vault is already unlocked), requires a non-blank reason,
and resolves the profile server-side via profiles::find_stored_profile.
- Refuses export for Nip46Client (external) profiles — the secret key is
not present locally — logging the denial.
- FAIL-CLOSED: the successful audit entry is written and flushed BEFORE the
key is returned; if the audit write fails, the key is not returned
(log.record(...)? instead of let _ =).
- Audit entries are written on every outcome: external-profile denial,
wrong password, and the successful export.
- RevealSecretKey IPC is deprecated: it now errors when the vault is locked
and, when unlocked, records a [deprecated direct call] audit entry.
The method stays registered for the deprecation window.
Frontend:
- ExportSecretKeyModal requires password + reason every time; clears
sensitive state on close.
- ProfilesScreen uses ExportSecretKeyModal; ShowSecretKeyModal and its test
are removed. AppProvider exposes exportSecretKey (revealSecretKey gone);
api.ts maps to export_secret_key.
- fakeBackend implements the full export contract (profile-not-found,
external-signer refusal, password check, blank-reason rejection); apiMock
exposes exportSecretKey. 10 tests cover the required scenarios.
No secret material is logged; the reason is logged by design. Verified:
cargo test --release 186 passed; frontend tsc clean, vitest 116 passed.
Introduce three coexisting signing modes:
- Embedded (INTERNAL): vault-held nsec, decrypted in Rust, signs locally.
- Nip46Client (EXTERNAL): Keynctr is the NIP-46 CLIENT; the key never
touches this machine.
- Nip46Bunker: legacy inverted mode (Keynctr as signer serving others).
Data model:
- StoredProfile gains signer_mode (serde-defaults to Embedded for legacy
profiles); SignerMode moves from app.rs to vault.rs to break a circular
dependency; app.rs re-exports it.
- Vault gains nip46_connections (profile-owned) and bumps VAULT_VERSION to
3; migrate_vault_signer_modes() normalises on load (idempotent).
- Nip46Connection gains profile_npub ownership, parsed permissions,
expires_at, and revoked_at.
Signer abstraction (src/signer):
- Signer trait gains pubkey_for() identity validation, a Signing enum
(Local vs External) that re-verifies the returned event, and a permission
surface (permissions/can_*/is_connection_valid) with safe defaults.
- permissions.rs: NIP-46 per-connection permission model (parse, validate,
deny-by-default, no-broadening checks) with 52 unit tests.
- Nip46ClientSigner parses perms from nostrconnect:// URIs, enforces
permissions on every gated request, persists/revokes connections in the
vault, and audits permission denials via the app's audit log.
- App gains audit_log and a nip46_bunker_signer handle; default mode is
Nip46Client (most secure).
Frontend: SignerModeScreen redesigned for the three modes with a
nostr-tools-based SignerManager client, new IPC allowlist entries, and
signer-mode styling.
Verified: cargo test --release 186 passed; clippy/fmt clean; frontend tsc
clean, vitest 110 passed.
- Add Signer trait with common interface for both signing modes
- Implement EmbeddedSigner: keys stored in encrypted vault (Argon2id + AES-256-GCM)
- Implement Nip46ClientSigner: connects to remote signer via nostrconnect:// URI
- Support both local and remote NIP-46 signers
- Add signer mode selection UI (SignerModeScreen)
- Add IPC endpoints for signer mode management, embedded signer, and NIP-46 client
- Update frontend types, API, and AppProvider
- All tests pass (119 Rust + 110 frontend)
- Add PublicationStatus type and computePublicationStatus() helper
- Add useProfilePublications hook that queries relays via feedGet and
determines per-event publication status by comparing served relays
against all enabled relays
- Rewrite HomeScreen PublicationResult to show only fully published
events in the main box; partial events appear only in the expandable
Relay results section
- Show empty state when no fully published events exist
- Add tests: fully published shown, partial hidden, older full shown
when newest is partial, all-partial shows empty, relay details
expandable, no duplicates
- Fix publishFlow integration test to seed profileFeedItems
- Remove dead .active-profile-row CSS (unused class)
- Replace hardcoded rgba fallbacks in .home-profile-row.is-active with
design tokens (var(--surface-2), var(--border))
- Remove duplicate edit icon from publication empty state (icon was
shown above the button, redundant with the button's own icon)
- HomeScreen header button: 'Compose note' → 'Compose' (matches sidebar)
- Updated tests to use exact name match and scoped queries to avoid
matching the sidebar nav button
Flat var(--success-soft) instead of the removed gradient — keeps the
green tint that signals active security state without breaking the
flat-by-default rule.