Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-23 14:37:32 -07:00
commit 5c9ef2300b
7 changed files with 427 additions and 13 deletions

View File

@ -141,6 +141,144 @@ const registry: CanvasToolDefinition[] = [
}), }),
actionLabel: (args) => `Generating image: ${args.prompt.slice(0, 50)}${args.prompt.length > 50 ? "..." : ""}`, actionLabel: (args) => `Generating image: ${args.prompt.slice(0, 50)}${args.prompt.length > 50 ? "..." : ""}`,
}, },
// ── Trip Planning Tools ──
{
declaration: {
name: "create_destination",
description: "Create a destination card for a trip location. Use when the user mentions a city, place, or stop on their trip.",
parameters: {
type: "object",
properties: {
destName: { type: "string", description: "Name of the destination (city or place)" },
country: { type: "string", description: "Country name" },
lat: { type: "number", description: "Latitude coordinate" },
lng: { type: "number", description: "Longitude coordinate" },
arrivalDate: { type: "string", description: "Arrival date in YYYY-MM-DD format" },
departureDate: { type: "string", description: "Departure date in YYYY-MM-DD format" },
notes: { type: "string", description: "Additional notes about this destination" },
},
required: ["destName"],
},
},
tagName: "folk-destination",
buildProps: (args) => ({
destName: args.destName,
...(args.country ? { country: args.country } : {}),
...(args.lat != null ? { lat: args.lat } : {}),
...(args.lng != null ? { lng: args.lng } : {}),
...(args.arrivalDate ? { arrivalDate: args.arrivalDate } : {}),
...(args.departureDate ? { departureDate: args.departureDate } : {}),
...(args.notes ? { notes: args.notes } : {}),
}),
actionLabel: (args) => `Created destination: ${args.destName}${args.country ? `, ${args.country}` : ""}`,
},
{
declaration: {
name: "create_itinerary",
description: "Create an itinerary card with a list of activities/events organized by date. Use when planning a schedule or day-by-day plan.",
parameters: {
type: "object",
properties: {
tripTitle: { type: "string", description: "Title for the itinerary" },
itemsJson: { type: "string", description: 'JSON array of items. Each: {"id":"<uuid>","title":"...","date":"YYYY-MM-DD","startTime":"HH:MM","category":"ACTIVITY|TRANSPORT|MEAL|FREE_TIME|FLIGHT"}' },
},
required: ["tripTitle", "itemsJson"],
},
},
tagName: "folk-itinerary",
buildProps: (args) => {
let items: any[] = [];
try { items = JSON.parse(args.itemsJson); } catch { items = []; }
return { tripTitle: args.tripTitle, items };
},
actionLabel: (args) => `Created itinerary: ${args.tripTitle}`,
},
{
declaration: {
name: "create_booking",
description: "Create a booking card for a flight, hotel, transport, activity, or restaurant reservation.",
parameters: {
type: "object",
properties: {
bookingType: {
type: "string",
description: "Type of booking",
enum: ["FLIGHT", "HOTEL", "CAR_RENTAL", "TRAIN", "BUS", "FERRY", "ACTIVITY", "RESTAURANT", "OTHER"],
},
provider: { type: "string", description: "Provider/company name (e.g. airline, hotel name)" },
cost: { type: "number", description: "Cost amount" },
currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" },
startDate: { type: "string", description: "Start/check-in date in YYYY-MM-DD format" },
endDate: { type: "string", description: "End/check-out date in YYYY-MM-DD format" },
bookingStatus: { type: "string", description: "Booking status", enum: ["PENDING", "CONFIRMED", "CANCELLED"] },
details: { type: "string", description: "Additional booking details or notes" },
},
required: ["bookingType", "provider"],
},
},
tagName: "folk-booking",
buildProps: (args) => ({
bookingType: args.bookingType,
provider: args.provider,
...(args.cost != null ? { cost: args.cost } : {}),
...(args.currency ? { currency: args.currency } : {}),
...(args.startDate ? { startDate: args.startDate } : {}),
...(args.endDate ? { endDate: args.endDate } : {}),
...(args.bookingStatus ? { bookingStatus: args.bookingStatus } : {}),
...(args.details ? { details: args.details } : {}),
}),
actionLabel: (args) => `Created booking: ${args.bookingType}${args.provider}`,
},
{
declaration: {
name: "create_budget",
description: "Create a budget tracker card with total budget and expense line items. Use when the user wants to track trip costs.",
parameters: {
type: "object",
properties: {
budgetTotal: { type: "number", description: "Total budget amount" },
currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" },
expensesJson: { type: "string", description: 'JSON array of expenses. Each: {"id":"<uuid>","category":"TRANSPORT|ACCOMMODATION|FOOD|ACTIVITY|SHOPPING|OTHER","description":"...","amount":123,"date":"YYYY-MM-DD"}' },
},
required: ["budgetTotal"],
},
},
tagName: "folk-budget",
buildProps: (args) => {
let expenses: any[] = [];
try { expenses = JSON.parse(args.expensesJson); } catch { expenses = []; }
return {
budgetTotal: args.budgetTotal,
...(args.currency ? { currency: args.currency } : {}),
expenses,
};
},
actionLabel: (args) => `Created budget: ${args.currency || "USD"} ${args.budgetTotal}`,
},
{
declaration: {
name: "create_packing_list",
description: "Create a packing list card with checkable items organized by category. Use when the user needs help with what to pack.",
parameters: {
type: "object",
properties: {
itemsJson: { type: "string", description: 'JSON array of packing items. Each: {"id":"<uuid>","name":"...","category":"CLOTHING|FOOTWEAR|ELECTRONICS|GEAR|PERSONAL|DOCUMENTS|SAFETY|SUPPLIES","quantity":1,"packed":false}' },
},
required: ["itemsJson"],
},
},
tagName: "folk-packing-list",
buildProps: (args) => {
let items: any[] = [];
try { items = JSON.parse(args.itemsJson); } catch { items = []; }
return { items };
},
actionLabel: (args) => {
let count = 0;
try { count = JSON.parse(args.itemsJson).length; } catch {}
return `Created packing list (${count} items)`;
},
},
]; ];
export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry]; export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry];

View File

@ -386,6 +386,7 @@ export class FolkPrompt extends FolkShape {
#model = "gemini-flash"; #model = "gemini-flash";
#pendingImages: string[] = []; #pendingImages: string[] = [];
#toolsEnabled = false; #toolsEnabled = false;
#systemPrompt = "";
#messagesEl: HTMLElement | null = null; #messagesEl: HTMLElement | null = null;
#promptInput: HTMLTextAreaElement | null = null; #promptInput: HTMLTextAreaElement | null = null;
@ -399,6 +400,20 @@ export class FolkPrompt extends FolkShape {
return this.#messages; return this.#messages;
} }
get toolsEnabled() { return this.#toolsEnabled; }
set toolsEnabled(v: boolean) {
this.#toolsEnabled = v;
this.#toolsBtn?.classList.toggle("active", v);
if (this.#promptInput) {
this.#promptInput.placeholder = v
? "Ask me to create maps, notes, images, or embeds on the canvas..."
: "Type your message...";
}
}
get systemPrompt() { return this.#systemPrompt; }
set systemPrompt(v: string) { this.#systemPrompt = v; }
override createRenderRoot() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
@ -471,15 +486,15 @@ export class FolkPrompt extends FolkShape {
// Tools toggle // Tools toggle
this.#toolsBtn?.addEventListener("click", (e) => { this.#toolsBtn?.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
this.#toolsEnabled = !this.#toolsEnabled; this.toolsEnabled = !this.#toolsEnabled;
this.#toolsBtn!.classList.toggle("active", this.#toolsEnabled);
if (this.#promptInput) {
this.#promptInput.placeholder = this.#toolsEnabled
? "Ask me to create maps, notes, images, or embeds on the canvas..."
: "Type your message...";
}
}); });
// Sync initial state after DOM ready
this.#toolsBtn?.classList.toggle("active", this.#toolsEnabled);
if (this.#toolsEnabled && this.#promptInput) {
this.#promptInput.placeholder = "Ask me to create maps, notes, images, or embeds on the canvas...";
}
// Attach button // Attach button
attachBtn?.addEventListener("click", (e) => { attachBtn?.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@ -635,7 +650,7 @@ export class FolkPrompt extends FolkShape {
...(m.images?.length ? { images: m.images } : {}), ...(m.images?.length ? { images: m.images } : {}),
})), })),
model: this.#model, model: this.#model,
...(useTools ? { useTools: true } : {}), ...(useTools ? { useTools: true, systemPrompt: this.#systemPrompt || undefined } : {}),
}), }),
}); });
@ -780,6 +795,8 @@ export class FolkPrompt extends FolkShape {
static override fromData(data: Record<string, any>): FolkPrompt { static override fromData(data: Record<string, any>): FolkPrompt {
const shape = FolkShape.fromData(data) as FolkPrompt; const shape = FolkShape.fromData(data) as FolkPrompt;
if (data.toolsEnabled) shape.toolsEnabled = true;
if (data.systemPrompt) shape.systemPrompt = data.systemPrompt;
return shape; return shape;
} }
@ -788,6 +805,8 @@ export class FolkPrompt extends FolkShape {
...super.toJSON(), ...super.toJSON(),
type: "folk-prompt", type: "folk-prompt",
model: this.#model, model: this.#model,
toolsEnabled: this.#toolsEnabled || undefined,
systemPrompt: this.#systemPrompt || undefined,
messages: this.messages.filter((m) => m.role !== "tool-action").map((msg) => ({ messages: this.messages.filter((m) => m.role !== "tool-action").map((msg) => ({
role: msg.role, role: msg.role,
content: msg.content, content: msg.content,
@ -799,5 +818,7 @@ export class FolkPrompt extends FolkShape {
override applyData(data: Record<string, any>): void { override applyData(data: Record<string, any>): void {
super.applyData(data); super.applyData(data);
if (data.toolsEnabled !== undefined) this.toolsEnabled = !!data.toolsEnabled;
if (data.systemPrompt !== undefined) this.systemPrompt = data.systemPrompt;
} }
} }

View File

@ -421,6 +421,8 @@ class FolkTripsPlanner extends HTMLElement {
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; } .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.rapp-nav__btn:hover { background: #0d9488; } .rapp-nav__btn:hover { background: #0d9488; }
.rapp-nav__btn--ai { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.rapp-nav__btn--ai:hover { opacity: 0.9; background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } .trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.trip-card { .trip-card {
@ -504,6 +506,7 @@ class FolkTripsPlanner extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
<span class="rapp-nav__title">My Trips</span> <span class="rapp-nav__title">My Trips</span>
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button> <button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai"> Plan with AI</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button> <button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div> </div>
${this.trips.length > 0 ? `<div class="trip-grid"> ${this.trips.length > 0 ? `<div class="trip-grid">
@ -546,6 +549,7 @@ class FolkTripsPlanner extends HTMLElement {
<div class="rapp-nav"> <div class="rapp-nav">
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""} ${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
<span class="rapp-nav__title">${this.esc(t.title)}</span> <span class="rapp-nav__title">${this.esc(t.title)}</span>
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai"> Plan with AI</button>
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span> <span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
</div> </div>
<div class="tabs"> <div class="tabs">
@ -678,6 +682,9 @@ class FolkTripsPlanner extends HTMLElement {
private attachListeners() { private attachListeners() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip()); this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
this.shadow.getElementById("btn-plan-ai")?.addEventListener("click", () => {
window.location.href = `/${this.space}/rspace#trip-planner`;
});
this.shadow.querySelectorAll("[data-trip]").forEach(el => { this.shadow.querySelectorAll("[data-trip]").forEach(el => {
el.addEventListener("click", () => { el.addEventListener("click", () => {

View File

@ -2225,7 +2225,9 @@ for (const mod of getAllModules()) {
|| pathname.endsWith("/api/transak/config") || pathname.endsWith("/api/transak/config")
|| pathname.endsWith("/api/transak/webhook") || pathname.endsWith("/api/transak/webhook")
|| pathname.endsWith("/api/coinbase/webhook") || pathname.endsWith("/api/coinbase/webhook")
|| pathname.endsWith("/api/ramp/webhook"); || pathname.endsWith("/api/ramp/webhook")
|| pathname.includes("/rcart/api/payments")
|| pathname.includes("/rcart/pay/");
if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) { if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) {
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);

View File

@ -540,7 +540,7 @@ export async function getRecoveryRequest(requestId: string): Promise<StoredRecov
export async function getActiveRecoveryRequest(userId: string): Promise<StoredRecoveryRequest | null> { export async function getActiveRecoveryRequest(userId: string): Promise<StoredRecoveryRequest | null> {
const rows = await sql` const rows = await sql`
SELECT * FROM recovery_requests SELECT * FROM recovery_requests
WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW() WHERE user_id = ${userId} AND status IN ('pending', 'approved') AND expires_at > NOW()
ORDER BY initiated_at DESC LIMIT 1 ORDER BY initiated_at DESC LIMIT 1
`; `;
if (rows.length === 0) return null; if (rows.length === 0) return null;

View File

@ -2257,6 +2257,220 @@ app.get('/recover', (c) => {
`); `);
}); });
// ============================================================================
// SOCIAL RECOVERY COMPLETION PAGE
// ============================================================================
/**
* GET /recover/social page where user finalizes social recovery
* After guardians approve, user opens this to register a new passkey
*/
app.get('/recover/social', (c) => {
return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Social Recovery rStack Identity</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 480px; width: 100%; text-align: center; }
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.5; }
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
.status.warning { background: rgba(234,179,8,0.1); color: #eab308; }
.progress-bar { width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; margin: 1rem 0; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #00d4ff, #7c3aed); border-radius: 4px; transition: width 0.5s; }
.approval-list { text-align: left; margin: 1rem 0; }
.approval-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.85rem; color: #94a3b8; }
.approval-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot.approved { background: #22c55e; }
.dot.pending { background: #64748b; }
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.hidden { display: none; }
.link { color: #00d4ff; text-decoration: none; }
.link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#128101;</div>
<h1>Social Recovery</h1>
<p class="sub">Your guardians are helping you recover your account</p>
<div id="status" class="status loading">Checking recovery status...</div>
<div id="progress-section" class="hidden">
<div class="progress-bar"><div id="progress-fill" class="progress-fill" style="width:0%"></div></div>
<div id="approval-list" class="approval-list"></div>
</div>
<button id="complete-btn" class="btn hidden" onclick="completeRecovery()">Complete Recovery</button>
<div id="register-section" class="hidden">
<button id="register-btn" class="btn" onclick="registerNewPasskey()">Add New Passkey</button>
</div>
<div id="success-section" class="hidden">
<p style="color:#94a3b8;margin-top:1rem;">You can now <a class="link" href="/">sign in with your new passkey</a> on any r*.online app.</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const requestId = params.get('id');
const statusEl = document.getElementById('status');
const progressSection = document.getElementById('progress-section');
const progressFill = document.getElementById('progress-fill');
const approvalList = document.getElementById('approval-list');
const completeBtn = document.getElementById('complete-btn');
const registerSection = document.getElementById('register-section');
const successSection = document.getElementById('success-section');
let sessionToken = null;
let userId = null;
let username = null;
let pollTimer = null;
async function checkStatus() {
if (!requestId) {
statusEl.className = 'status error';
statusEl.textContent = 'No recovery request ID. Use the link from your recovery email.';
return;
}
try {
const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/status');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
statusEl.className = 'status error';
statusEl.textContent = err.error || 'Recovery request not found.';
return;
}
const data = await res.json();
const pct = Math.round((data.approvalCount / data.threshold) * 100);
progressFill.style.width = Math.min(pct, 100) + '%';
// Show approval dots
approvalList.innerHTML = data.approvals.map(a =>
'<div class="approval-item"><span class="dot ' + (a.approved ? 'approved' : 'pending') + '"></span>' +
'Guardian ' + a.guardianId.slice(0, 8) + '... — ' + (a.approved ? 'Approved' : 'Waiting') + '</div>'
).join('');
progressSection.classList.remove('hidden');
if (data.status === 'approved') {
clearInterval(pollTimer);
statusEl.className = 'status success';
statusEl.textContent = 'Recovery approved! ' + data.approvalCount + '/' + data.threshold + ' guardians confirmed. Complete your recovery below.';
completeBtn.classList.remove('hidden');
} else if (data.status === 'completed') {
clearInterval(pollTimer);
statusEl.className = 'status success';
statusEl.textContent = 'This recovery has already been completed.';
progressSection.classList.add('hidden');
} else if (data.status === 'expired') {
clearInterval(pollTimer);
statusEl.className = 'status error';
statusEl.textContent = 'This recovery request has expired. Please start a new one.';
} else {
// pending
statusEl.className = 'status loading';
statusEl.textContent = 'Waiting for guardian approvals... ' + data.approvalCount + '/' + data.threshold + ' so far. This page auto-refreshes.';
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to check status: ' + e.message;
}
}
async function completeRecovery() {
completeBtn.disabled = true;
completeBtn.textContent = 'Completing...';
try {
const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to complete recovery');
}
sessionToken = data.token;
userId = data.userId;
username = data.username;
statusEl.className = 'status success';
statusEl.textContent = 'Recovery verified! Now register a new passkey for your account.';
completeBtn.classList.add('hidden');
registerSection.classList.remove('hidden');
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed: ' + e.message;
completeBtn.disabled = false;
completeBtn.textContent = 'Complete Recovery';
}
}
async function registerNewPasskey() {
const btn = document.getElementById('register-btn');
btn.disabled = true;
btn.textContent = 'Registering...';
try {
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + sessionToken },
body: JSON.stringify({ username, displayName: username }),
});
const { options } = await startRes.json();
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
const credential = await navigator.credentials.create({ publicKey: options });
const credentialData = {
credentialId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
publicKey: btoa(String.fromCharCode(...new Uint8Array(credential.response.getPublicKey?.() || new ArrayBuffer(0)))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
transports: credential.response.getTransports?.() || [],
};
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }),
});
const result = await completeRes.json();
if (result.success) {
statusEl.className = 'status success';
statusEl.textContent = 'New passkey registered successfully!';
registerSection.classList.add('hidden');
successSection.classList.remove('hidden');
progressSection.classList.add('hidden');
} else {
throw new Error(result.error || 'Registration failed');
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to register passkey: ' + e.message;
btn.disabled = false;
btn.textContent = 'Add New Passkey';
}
}
// Initial check + poll every 10s while pending
checkStatus();
pollTimer = setInterval(checkStatus, 10000);
</script>
</body>
</html>
`);
});
// ============================================================================ // ============================================================================
// GUARDIAN MANAGEMENT ROUTES // GUARDIAN MANAGEMENT ROUTES
// ============================================================================ // ============================================================================
@ -2757,7 +2971,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
// Check for existing active request // Check for existing active request
const existing = await getActiveRecoveryRequest(user.id); const existing = await getActiveRecoveryRequest(user.id);
if (existing) { if (existing) {
return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' }); return c.json({ success: true, requestId: existing.id, message: 'A recovery request is already active. Recovery emails have been re-sent.' });
} }
// Create recovery request (7 day expiry, 2-of-3 threshold) // Create recovery request (7 day expiry, 2-of-3 threshold)
@ -2773,6 +2987,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
title: 'Account recovery initiated', title: 'Account recovery initiated',
body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`, body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`,
metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length }, metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length },
actionUrl: `/recover/social?id=${requestId}`,
}).catch(() => {}); }).catch(() => {});
// Create approval tokens and notify guardians // Create approval tokens and notify guardians
@ -2854,7 +3069,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
} }
} }
return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' }); return c.json({ success: true, requestId, message: 'If the account exists and has guardians, recovery emails have been sent.' });
}); });
/** /**
@ -2973,6 +3188,7 @@ app.post('/api/recovery/social/approve', async (c) => {
title: 'Account recovery approved', title: 'Account recovery approved',
body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`, body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`,
metadata: { recoveryRequestId: request.id }, metadata: { recoveryRequestId: request.id },
actionUrl: `https://auth.rspace.online/recover/social?id=${request.id}`,
}).catch(() => {}); }).catch(() => {});
} }
@ -7394,7 +7610,10 @@ app.get('/', (c) => {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await res.json(); const data = await res.json();
msgEl.textContent = data.message || 'Recovery request sent. Check with your guardians.'; msgEl.innerHTML = data.message || 'Recovery request sent. Check with your guardians.';
if (data.requestId) {
msgEl.innerHTML += '<br><br><a href="/recover/social?id=' + encodeURIComponent(data.requestId) + '" style="color:#00d4ff;">Track your recovery progress &rarr;</a>';
}
msgEl.style.color = '#86efac'; msgEl.style.color = '#86efac';
msgEl.style.display = 'block'; msgEl.style.display = 'block';
// Also try email recovery // Also try email recovery

View File

@ -3642,6 +3642,33 @@
if (hash.startsWith("#pin-")) { if (hash.startsWith("#pin-")) {
const pinId = hash.slice(5); const pinId = hash.slice(5);
setTimeout(() => pinManager.openPinById(pinId), 500); setTimeout(() => pinManager.openPinById(pinId), 500);
} else if (hash === "#trip-planner") {
history.replaceState(null, "", location.pathname + location.search);
setTimeout(() => {
const TRIP_SYSTEM_PROMPT = `You are a travel planning AI in rSpace. Help plan trips by creating visual shapes on the canvas.
When the user describes a trip, proactively create:
- Destination cards for each city/place (with coordinates and dates)
- A map showing the destinations
- Flight/accommodation search embeds (Google Flights, Airbnb, Booking.com)
- An itinerary with activities by date
- A budget tracker with estimated costs
- A packing list tailored to the destination
Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying questions if needed before creating shapes.`;
const prompt = newShape("folk-prompt", {
toolsEnabled: true,
systemPrompt: TRIP_SYSTEM_PROMPT,
});
if (prompt) {
setTimeout(() => {
const root = prompt.shadowRoot || prompt;
const ta = root.querySelector(".prompt-input");
if (ta) ta.focus();
}, 200);
}
}, 300);
} }
}); });