Replace 'Login as Guest' with 'Create New Account' + nsec backup
The throwaway guest identity was disposable by design — lost the session, lost the account, no way to recover it, not even shown the key to write down. Replaced with a real onboarding flow: - generateNewAccount() (lib/nsec.ts) creates a fresh keypair client-side and returns both nsec and npub for display, reusing the exact same login mechanism as the existing 'paste an nsec' path (loginWithNsec) — no new backend surface. - LoginView.vue now has a backup step between 'Create New Account' and actually being signed in: shows the npub (safe to share) and nsec (never share), a copy-to-clipboard button, and an explicit 'this will not be shown again' warning before the player confirms and logs in. - Removed lib/guest.ts and the loginAsGuest store action entirely — fully superseded, not kept alongside. Verified the nip19 encode/decode round-trip (nsec -> decode -> same pubkey) and the full generate-then-login flow work correctly before deploying.
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
// Guest login: a freshly generated, throwaway nostr identity, signed
|
||||
// client-side — no NIP-07 extension needed, no nsec to paste. Same
|
||||
// never-persisted-key posture as lib/nsec.ts (the key exists in page memory
|
||||
// only long enough to sign this one login event), except here nothing was
|
||||
// ever typed by a human, so there's no "don't paste your real key" risk to
|
||||
// warn about — it's random and disposable by construction.
|
||||
//
|
||||
// Tradeoff worth knowing: since the key is never saved anywhere (not even
|
||||
// as an nsec the player could copy down), losing the session cookie means
|
||||
// losing this identity for good — a fresh "Login as Guest" click makes a
|
||||
// brand new one, it can't recover the old pubkey's claims.
|
||||
import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
|
||||
|
||||
/** Generates a random identity and returns a signed NIP-98 Authorization header for it. */
|
||||
export function buildGuestNip98Header(url: string, method: string): string {
|
||||
const sk = generateSecretKey();
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
);
|
||||
return `Nostr ${btoa(JSON.stringify(event))}`;
|
||||
}
|
||||
@@ -5,9 +5,27 @@
|
||||
// the NIP-07 path also never retains key material — only the resulting
|
||||
// session cookie survives.
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
||||
import type { SignedEvent, UnsignedEvent } from './nip07';
|
||||
|
||||
export interface NewAccount {
|
||||
nsec: string;
|
||||
npub: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a brand-new identity entirely client-side. Returned so the
|
||||
* caller can show the player their nsec to back up *before* logging in with
|
||||
* it (see views/LoginView.vue) — unlike the raw-nsec paste flow above, this
|
||||
* key was never at risk of being someone's real identity, but it's still
|
||||
* the player's only copy, so it's worth the same "here it is, save it"
|
||||
* treatment real wallet software gives a freshly generated seed.
|
||||
*/
|
||||
export function generateNewAccount(): NewAccount {
|
||||
const sk = generateSecretKey();
|
||||
return { nsec: nip19.nsecEncode(sk), npub: nip19.npubEncode(getPublicKey(sk)) };
|
||||
}
|
||||
|
||||
export function isValidNsec(nsec: string): boolean {
|
||||
try {
|
||||
return nip19.decode(nsec.trim()).type === 'nsec';
|
||||
|
||||
@@ -2,7 +2,6 @@ import { defineStore } from 'pinia';
|
||||
import { api, apiUrl, ApiError } from '../lib/api';
|
||||
import { buildNip98Header, hasNip07 } from '../lib/nip07';
|
||||
import { buildNip98HeaderWithNsec, isValidNsec } from '../lib/nsec';
|
||||
import { buildGuestNip98Header } from '../lib/guest';
|
||||
|
||||
export type Team = 'orange' | 'green';
|
||||
|
||||
@@ -46,11 +45,12 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.team = res.team;
|
||||
},
|
||||
/**
|
||||
* Unsafe fallback login: signs with a raw pasted nsec instead of a NIP-07
|
||||
* extension. The key is used once, in memory, to sign this request and
|
||||
* is never persisted — but it does pass through this page's JS, which a
|
||||
* real identity's key never should. Explicit user-requested convenience
|
||||
* feature, not a recommended default.
|
||||
* Signs in with a raw nsec — either pasted by the player (the "unsafe"
|
||||
* fallback for testing/kiosk use, no extension needed) or one just
|
||||
* generated for them by "Create New Account" (lib/nsec.ts#generateNewAccount)
|
||||
* after they've had a chance to back it up. Same code path either way:
|
||||
* the key is used once, in memory, to sign this request and is never
|
||||
* persisted here — only the resulting session cookie survives.
|
||||
*/
|
||||
async loginWithNsec(nsec: string) {
|
||||
if (!isValidNsec(nsec)) throw new Error('That doesn\'t look like a valid nsec.');
|
||||
@@ -64,23 +64,6 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.pubkey = res.pubkey;
|
||||
this.team = res.team;
|
||||
},
|
||||
/**
|
||||
* A freshly generated, throwaway identity — no extension, no key to
|
||||
* paste. The key only exists in page memory for this one signing call;
|
||||
* losing the session means losing this identity for good, since nothing
|
||||
* is saved for the player to recover it with.
|
||||
*/
|
||||
async loginAsGuest() {
|
||||
const url = apiUrl('/api/auth/login');
|
||||
const header = buildGuestNip98Header(url, 'POST');
|
||||
const res = await api.post<{ pubkey: string; team: Team | null }>(
|
||||
'/api/auth/login',
|
||||
undefined,
|
||||
{ authorization: header },
|
||||
);
|
||||
this.pubkey = res.pubkey;
|
||||
this.team = res.team;
|
||||
},
|
||||
async logout() {
|
||||
await api.post('/api/auth/logout');
|
||||
this.pubkey = null;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { hasNip07 } from '../lib/nip07';
|
||||
import { generateNewAccount, type NewAccount } from '../lib/nsec';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const error = ref<string | null>(null);
|
||||
@@ -9,6 +10,9 @@ const loggingIn = ref(false);
|
||||
const showNsecForm = ref(false);
|
||||
const nsecInput = ref('');
|
||||
|
||||
const newAccount = ref<NewAccount | null>(null);
|
||||
const copied = ref(false);
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = null;
|
||||
loggingIn.value = true;
|
||||
@@ -21,11 +25,29 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGuestLogin() {
|
||||
function handleCreateAccount() {
|
||||
error.value = null;
|
||||
copied.value = false;
|
||||
newAccount.value = generateNewAccount();
|
||||
}
|
||||
|
||||
async function copyNsec() {
|
||||
if (!newAccount.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(newAccount.value.nsec);
|
||||
copied.value = true;
|
||||
} catch {
|
||||
// Clipboard API can be denied/unavailable — the field is still selectable by hand.
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmNewAccount() {
|
||||
if (!newAccount.value) return;
|
||||
error.value = null;
|
||||
loggingIn.value = true;
|
||||
try {
|
||||
await auth.loginAsGuest();
|
||||
await auth.loginWithNsec(newAccount.value.nsec);
|
||||
newAccount.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
@@ -54,58 +76,104 @@ async function handleNsecLogin() {
|
||||
style="background: radial-gradient(circle at 50% 40%, rgba(0,255,242,0.08), transparent 60%)"
|
||||
></div>
|
||||
|
||||
<h1 class="font-display text-5xl font-black tracking-[0.3em] text-cyan-neon text-glow-cyan">REGRESS</h1>
|
||||
<p class="max-w-sm text-cyan-neon/90">
|
||||
Real-world nodes. Two factions. Claim them for orange or green — sign in with Nostr to jack in.
|
||||
</p>
|
||||
<button
|
||||
class="rounded border-2 border-cyan-neon px-6 py-3 font-display font-bold uppercase tracking-wider text-cyan-neon transition hover:bg-cyan-neon/10 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
:disabled="loggingIn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loggingIn ? 'Authenticating…' : 'Connect with Nostr' }}
|
||||
</button>
|
||||
<p v-if="!hasNip07()" class="text-sm text-cyan-neon/75">
|
||||
No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard.
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="rounded border border-cyan-neon/50 px-5 py-2 font-display uppercase tracking-wider text-cyan-neon/90 transition hover:border-cyan-neon hover:text-cyan-neon disabled:opacity-50"
|
||||
:disabled="loggingIn"
|
||||
@click="handleGuestLogin"
|
||||
>
|
||||
{{ loggingIn ? 'Authenticating…' : 'Login as Guest' }}
|
||||
</button>
|
||||
<p class="max-w-sm text-xs text-cyan-neon/60">
|
||||
Jumps in with a fresh throwaway identity — no extension, nothing to install. It's disposable: if you lose this
|
||||
session there's no way to get the same identity back, so don't rely on it for territory you care about keeping.
|
||||
</p>
|
||||
|
||||
<button class="text-xs text-cyan-neon/60 underline hover:text-magenta-neon" @click="showNsecForm = !showNsecForm">
|
||||
{{ showNsecForm ? 'Hide' : 'Or paste an nsec (unsafe)' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showNsecForm" class="hud-panel flex w-full max-w-xs flex-col gap-2 p-4">
|
||||
<p class="rounded border border-magenta-neon/50 bg-magenta-neon/10 px-3 py-2 text-left text-xs text-magenta-neon">
|
||||
⚠ UNSAFE: your private key is decoded and used to sign right in this
|
||||
page. Only paste a throwaway/test nsec — never your real identity's.
|
||||
The key isn't saved anywhere; it's used once and discarded.
|
||||
<template v-if="!newAccount">
|
||||
<h1 class="font-display text-5xl font-black tracking-[0.3em] text-cyan-neon text-glow-cyan">REGRESS</h1>
|
||||
<p class="max-w-sm text-cyan-neon/90">
|
||||
Real-world nodes. Two factions. Claim them for orange or green — sign in with Nostr to jack in.
|
||||
</p>
|
||||
<input
|
||||
v-model="nsecInput"
|
||||
type="password"
|
||||
placeholder="nsec1…"
|
||||
class="rounded border border-cyan-neon/30 bg-black/60 px-3 py-2 text-sm text-cyan-neon outline-none focus:border-magenta-neon"
|
||||
@keyup.enter="handleNsecLogin"
|
||||
/>
|
||||
<button
|
||||
class="rounded border border-magenta-neon px-3 py-2 text-sm font-bold uppercase text-magenta-neon transition hover:shadow-glow-magenta disabled:opacity-50"
|
||||
:disabled="loggingIn || !nsecInput"
|
||||
@click="handleNsecLogin"
|
||||
class="rounded border-2 border-cyan-neon px-6 py-3 font-display font-bold uppercase tracking-wider text-cyan-neon transition hover:bg-cyan-neon/10 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
:disabled="loggingIn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loggingIn ? 'Authenticating…' : 'Connect with nsec (unsafe)' }}
|
||||
{{ loggingIn ? 'Authenticating…' : 'Connect with Nostr' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!hasNip07()" class="text-sm text-cyan-neon/75">
|
||||
No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard.
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="rounded border border-cyan-neon/50 px-5 py-2 font-display uppercase tracking-wider text-cyan-neon/90 transition hover:border-cyan-neon hover:text-cyan-neon disabled:opacity-50"
|
||||
:disabled="loggingIn"
|
||||
@click="handleCreateAccount"
|
||||
>
|
||||
Create New Account
|
||||
</button>
|
||||
<p class="max-w-sm text-xs text-cyan-neon/60">
|
||||
Generates a fresh Nostr identity right here — no extension needed. You'll get a private key (nsec) to save
|
||||
before you're signed in; that key is the only way back into this account, so back it up somewhere safe.
|
||||
</p>
|
||||
|
||||
<button class="text-xs text-cyan-neon/60 underline hover:text-magenta-neon" @click="showNsecForm = !showNsecForm">
|
||||
{{ showNsecForm ? 'Hide' : 'Or paste an existing nsec (unsafe)' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showNsecForm" class="hud-panel flex w-full max-w-xs flex-col gap-2 p-4">
|
||||
<p class="rounded border border-magenta-neon/50 bg-magenta-neon/10 px-3 py-2 text-left text-xs text-magenta-neon">
|
||||
⚠ UNSAFE: your private key is decoded and used to sign right in this
|
||||
page. Only paste a throwaway/test nsec — never your real identity's.
|
||||
The key isn't saved anywhere; it's used once and discarded.
|
||||
</p>
|
||||
<input
|
||||
v-model="nsecInput"
|
||||
type="password"
|
||||
placeholder="nsec1…"
|
||||
class="rounded border border-cyan-neon/30 bg-black/60 px-3 py-2 text-sm text-cyan-neon outline-none focus:border-magenta-neon"
|
||||
@keyup.enter="handleNsecLogin"
|
||||
/>
|
||||
<button
|
||||
class="rounded border border-magenta-neon px-3 py-2 text-sm font-bold uppercase text-magenta-neon transition hover:shadow-glow-magenta disabled:opacity-50"
|
||||
:disabled="loggingIn || !nsecInput"
|
||||
@click="handleNsecLogin"
|
||||
>
|
||||
{{ loggingIn ? 'Authenticating…' : 'Connect with nsec (unsafe)' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Backup step — shown once, right after generating a new account, before the player is actually signed in. -->
|
||||
<template v-else>
|
||||
<h1 class="font-display text-2xl font-black tracking-widest text-cyan-neon text-glow-cyan">BACK UP YOUR KEY</h1>
|
||||
<p class="max-w-sm text-cyan-neon/90">
|
||||
This is your account. Save this nsec somewhere safe — a password manager, written down, anywhere but nowhere.
|
||||
<span class="font-bold text-magenta-neon">It will not be shown again.</span> Lose it and this account (and
|
||||
anything you've claimed) is gone for good.
|
||||
</p>
|
||||
|
||||
<div class="hud-panel flex w-full max-w-sm flex-col gap-3 p-4 text-left">
|
||||
<div>
|
||||
<div class="mb-1 text-xs uppercase text-cyan-neon/60">Public key (npub) — safe to share</div>
|
||||
<div class="break-all rounded border border-cyan-neon/30 bg-black/60 px-3 py-2 text-xs text-cyan-neon">
|
||||
{{ newAccount.npub }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs uppercase text-magenta-neon">Private key (nsec) — never share this</div>
|
||||
<div
|
||||
class="select-all break-all rounded border border-magenta-neon/50 bg-black/60 px-3 py-2 text-xs text-magenta-neon"
|
||||
>
|
||||
{{ newAccount.nsec }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded border border-cyan-neon px-3 py-2 text-sm font-bold uppercase text-cyan-neon transition hover:shadow-glow-cyan"
|
||||
@click="copyNsec"
|
||||
>
|
||||
{{ copied ? 'Copied ✓' : 'Copy nsec to clipboard' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="rounded border-2 border-cyan-neon px-6 py-3 font-display font-bold uppercase tracking-wider text-cyan-neon transition hover:bg-cyan-neon/10 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
:disabled="loggingIn"
|
||||
@click="confirmNewAccount"
|
||||
>
|
||||
{{ loggingIn ? 'Signing in…' : "I've saved it — Continue" }}
|
||||
</button>
|
||||
<button class="text-xs text-cyan-neon/60 underline hover:text-magenta-neon" @click="newAccount = null">
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<p v-if="error" class="text-sm text-magenta-neon">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user