feat(encryptid): fix DID consistency, add PRF key derivation, stepper signup, and magic link login

- Fix DID mismatch: server now stores and reads proper did🔑z6Mk... DIDs
  from database instead of deriving truncated did🔑${slice(0,32)}
- Add PRF extension to WebAuthn create/get flows for client-side key derivation
- Derive DID, signing keys, encryption keys, and EOA wallet from passkey PRF
- Auto-upgrade truncated DIDs to proper format on sign-in
- Add POST /api/account/upgrade-did endpoint for DID migration
- Add 5-step educational registration wizard (identity, passkey, DID, wallet, security)
- Add email/username field to sign-in for scoped passkey selection
- Add magic link email login for external devices without passkeys
- Add POST /api/auth/magic-link and GET /magic-login verification page
- Add mintWelcomeBalance() for 5 fUSDC to new users
- Store EOA wallet address during registration when PRF available

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 11:29:20 -07:00
parent db61e54d7b
commit 8bd8348529
4 changed files with 813 additions and 121 deletions

View File

@ -150,3 +150,45 @@ export async function seedCUSDC() {
console.error('[TokenService] Failed to mint cUSDC to jeff'); console.error('[TokenService] Failed to mint cUSDC to jeff');
} }
} }
/** Mint $5 fUSDC welcome balance for a new user. */
export function mintWelcomeBalance(did: string, username: string): boolean {
if (!_syncServer) {
console.warn('[TokenService] SyncServer not initialized, skipping welcome balance');
return false;
}
const tokenId = 'fusdc';
const docId = tokenDocId(tokenId);
let doc = _syncServer.getDoc<TokenLedgerDoc>(docId);
// Create fUSDC token if it doesn't exist
if (!doc) {
doc = Automerge.change(Automerge.init<TokenLedgerDoc>(), 'init fUSDC ledger', (d) => {
const init = tokenLedgerSchema.init();
Object.assign(d, init);
});
_syncServer.setDoc(docId, doc);
}
if (!doc.token.name) {
_syncServer.changeDoc<TokenLedgerDoc>(docId, 'define fUSDC token', (d) => {
d.token.id = 'fusdc';
d.token.name = 'Fake USDC';
d.token.symbol = 'fUSDC';
d.token.decimals = 6;
d.token.description = 'Test stablecoin for new users — $5 welcome balance';
d.token.icon = '💵';
d.token.color = '#2775ca';
d.token.createdAt = Date.now();
d.token.createdBy = 'system';
});
}
// Mint 5 fUSDC (5 × 10^6 base units at 6 decimals)
const success = mintTokens(tokenId, did, username, 5_000_000, 'Welcome balance', 'system');
if (success) {
console.log(`[TokenService] Welcome balance: 5 fUSDC minted to ${username} (${did})`);
}
return success;
}

View File

@ -255,7 +255,7 @@ export async function migrateSpaceMemberDid(oldDid: string, newDid: string): Pro
export interface StoredRecoveryToken { export interface StoredRecoveryToken {
token: string; token: string;
userId: string; userId: string;
type: 'email_verify' | 'account_recovery'; type: 'email_verify' | 'account_recovery' | 'magic_login';
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
used: boolean; used: boolean;
@ -282,7 +282,7 @@ export async function getRecoveryToken(token: string): Promise<StoredRecoveryTok
return { return {
token: row.token, token: row.token,
userId: row.user_id, userId: row.user_id,
type: row.type as 'email_verify' | 'account_recovery', type: row.type as 'email_verify' | 'account_recovery' | 'magic_login',
createdAt: new Date(row.created_at).getTime(), createdAt: new Date(row.created_at).getTime(),
expiresAt: new Date(row.expires_at).getTime(), expiresAt: new Date(row.expires_at).getTime(),
used: row.used, used: row.used,

View File

@ -56,7 +56,7 @@ CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at);
CREATE TABLE IF NOT EXISTS recovery_tokens ( CREATE TABLE IF NOT EXISTS recovery_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery')), type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery', 'magic_login')),
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE used BOOLEAN DEFAULT FALSE
@ -65,6 +65,15 @@ CREATE TABLE IF NOT EXISTS recovery_tokens (
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at); CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at);
-- Migration: add magic_login type to existing recovery_tokens CHECK constraint
DO $$
BEGIN
ALTER TABLE recovery_tokens DROP CONSTRAINT IF EXISTS recovery_tokens_type_check;
ALTER TABLE recovery_tokens ADD CONSTRAINT recovery_tokens_type_check
CHECK (type IN ('email_verify', 'account_recovery', 'magic_login'));
EXCEPTION WHEN others THEN NULL;
END $$;
-- Space membership: source of truth for user roles across the r*.online ecosystem -- Space membership: source of truth for user roles across the r*.online ecosystem
CREATE TABLE IF NOT EXISTS space_members ( CREATE TABLE IF NOT EXISTS space_members (
space_slug TEXT NOT NULL, space_slug TEXT NOT NULL,

File diff suppressed because it is too large Load Diff