Phase 0 — identity (lib/crewAgent.ts): - Deterministic per-crew agent keypair derived from the crew coordinate (sha256 of a public salt + coord). Documented security tradeoff: the key is reproducible by design and holds no funds — used only for the agent to speak for itself. Includes kind-0 profile builder (bot:true, NIP-24) and a signAsCrewAgent helper for Phase 3. Phase 1 — opt-in toggle: - SkateCrew.agentEnabled parsed from a queryable ["t","crew-agent"] marker. - "Crew Agent" toggle (Beta) in CrewForm; publishes the marker when on. - NIP.md documents the crew-agent tag and the agent identity derivation. Phase 2 — crew-scoped Sasquatch with receipts: - lib/sasquatchBrain.ts: shared AI caller (model fallback) + tool-call parser. - useCrewAgentChat: reuses the CVM data tools, seeded with crew context (name/location) so "near us" resolves to the crew's turf. Ephemeral/local, no keys, no publishing yet. - CrewAgentPanel: chat dialog rendering agent answers PLUS structured receipt cards (spots / events / Lab pros / gear / crews) linking into the app. - CrewDetailPage: "Ask Sasquatch" card + panel, shown only when the crew enabled the agent. Also: Lab category tabs → fixed 5-across grid (no horizontal scroll). Bump 1.0.68. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
103 lines
4.2 KiB
TypeScript
103 lines
4.2 KiB
TypeScript
/**
|
|
* Crew Agent identity (Phase 0)
|
|
* ─────────────────────────────
|
|
* The Crew Agent ("🐝 Sasquatch") is a Nostr identity that belongs to a crew.
|
|
* Its key is DETERMINISTICALLY DERIVED from the crew's addressable coordinate,
|
|
* so any client can reproduce the same agent identity for a given crew without
|
|
* storing a key anywhere.
|
|
*
|
|
* ⚠️ SECURITY TRADEOFF (documented, deliberate for v1):
|
|
* Because the key is derived from public data (the crew coordinate) plus a
|
|
* fixed, public salt in this source, ANYONE who reads this code can derive the
|
|
* same private key. This is acceptable for v1 because the agent only ever:
|
|
* - publishes its own kind-0 profile, and
|
|
* - (later, Phase 3) posts helper replies into its own crew's chat.
|
|
* It holds no funds and speaks only for itself. It is NOT a secure identity and
|
|
* must never be trusted for signing anything sensitive. A future version can
|
|
* swap this for a founder-controlled stored key without changing call sites.
|
|
*/
|
|
|
|
import { getPublicKey, finalizeEvent, nip19, type EventTemplate } from 'nostr-tools';
|
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
import { SKATE_CREW_KIND } from '@/lib/types';
|
|
|
|
/** Public, fixed salt — versioned so we can rotate the scheme later if needed. */
|
|
const AGENT_KEY_SALT = 'skatespots:crew-agent:v1';
|
|
|
|
export interface CrewAgentIdentity {
|
|
/** 32-byte private key (hex). */
|
|
secretKeyHex: string;
|
|
/** 32-byte private key (bytes) — for signing. */
|
|
secretKey: Uint8Array;
|
|
/** Public key (hex). */
|
|
pubkey: string;
|
|
/** npub encoding of the public key. */
|
|
npub: string;
|
|
}
|
|
|
|
/**
|
|
* Build the crew's addressable coordinate `kind:pubkey:identifier`.
|
|
* This is the stable, public seed for the agent identity.
|
|
*/
|
|
export function crewCoord(crewPubkey: string, identifier: string): string {
|
|
return `${SKATE_CREW_KIND}:${crewPubkey}:${identifier}`;
|
|
}
|
|
|
|
/**
|
|
* Deterministically derive the crew agent's keypair from the crew coordinate.
|
|
* Same crew → same agent identity, everywhere, with no stored state.
|
|
*/
|
|
export function deriveCrewAgentIdentity(crewPubkey: string, identifier: string): CrewAgentIdentity {
|
|
const coord = crewCoord(crewPubkey, identifier);
|
|
// 32-byte secret = sha256(salt | coord). secp256k1 accepts any 32-byte scalar
|
|
// in range; sha256 output is overwhelmingly within range, and nostr-tools /
|
|
// noble will reject the astronomically unlikely invalid case at sign time.
|
|
const secretKey = sha256(new TextEncoder().encode(`${AGENT_KEY_SALT}|${coord}`));
|
|
const secretKeyHex = bytesToHex(secretKey);
|
|
const pubkey = getPublicKey(secretKey);
|
|
const npub = nip19.npubEncode(pubkey);
|
|
return { secretKeyHex, secretKey, pubkey, npub };
|
|
}
|
|
|
|
/** Just the agent pubkey — cheap when you don't need to sign. */
|
|
export function crewAgentPubkey(crewPubkey: string, identifier: string): string {
|
|
return deriveCrewAgentIdentity(crewPubkey, identifier).pubkey;
|
|
}
|
|
|
|
/** Sasquatch mascot avatar used for the agent's profile. */
|
|
export const CREW_AGENT_AVATAR =
|
|
'https://npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx.blossom.band/2ff0cb5bd8622fed54a80784f5cc9d1f569714e9a16de65857a727e63404f5c2.png';
|
|
|
|
/**
|
|
* Build (unsigned) the agent's kind-0 profile metadata for a crew.
|
|
* `bot: true` follows NIP-24 so clients can honestly label it as automated.
|
|
*/
|
|
export function buildCrewAgentProfile(crewName: string): EventTemplate {
|
|
const metadata = {
|
|
name: `🐝 Sasquatch · ${crewName}`,
|
|
display_name: `Sasquatch · ${crewName}`,
|
|
about: `The AI crew guide for ${crewName} on SkateSpots. Ask me about spots, events, coaches, gear and more — I answer with live data. 🛹`,
|
|
picture: CREW_AGENT_AVATAR,
|
|
bot: true,
|
|
};
|
|
return {
|
|
kind: 0,
|
|
content: JSON.stringify(metadata),
|
|
tags: [],
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sign an event template AS the crew agent (using its derived key).
|
|
* Used to publish the agent's profile now, and its chat replies in Phase 3.
|
|
*/
|
|
export function signAsCrewAgent(
|
|
crewPubkey: string,
|
|
identifier: string,
|
|
template: EventTemplate,
|
|
) {
|
|
const { secretKey } = deriveCrewAgentIdentity(crewPubkey, identifier);
|
|
return finalizeEvent(template, secretKey);
|
|
}
|