Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 87 additions & 58 deletions src/lib/invoiceMetadataStore.server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/**
* Invoice Metadata Store (server-side, in-memory with TTL)
* Invoice Metadata Store (server-side, Cloudflare KV with in-memory dev fallback)
*
* Maps Strike receiveRequestId to membership metadata (pubkey, tier, period)
* so that webhooks and verification endpoints can match payments to users.
*
* Entries expire after 2 hours (Lightning invoices typically expire in 1 hour).
* KV key scheme:
* inv:{receiveRequestId} → full InvoiceMetadata JSON (TTL 2 hours)
* invhash:{paymentHash} → receiveRequestId string (TTL 2 hours)
*
* NOTE: This is an in-memory store. It works for single-instance deployments.
* For multi-instance or serverless deployments, replace with a database or KV store.
* In dev (no KV binding), falls back to an in-memory Map with expiry-on-read.
*/

export interface InvoiceMetadata {
Expand All @@ -18,99 +19,127 @@ export interface InvoiceMetadata {
createdAt: number;
}

const ENTRY_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // Clean up every 10 minutes
/** Matches the shape of the GATED_CONTENT KV binding. */
export type InvoiceKV = {
get(key: string, type?: 'text' | 'json'): Promise<string | unknown | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
delete(key: string): Promise<void>;
} | null | undefined;

const store = new Map<string, InvoiceMetadata>();
const KV_TTL_SECONDS = 7200; // 2 hours
const ENTRY_TTL_MS = KV_TTL_SECONDS * 1000;

// Also index by paymentHash for client-side verification lookups
const paymentHashIndex = new Map<string, string>(); // paymentHash -> receiveRequestId
// ── Dev-only in-memory fallback ──────────────────────────────────────
const memStore = new Map<string, InvoiceMetadata>();
const memHashIndex = new Map<string, string>(); // paymentHash → receiveRequestId

// Periodic cleanup of expired entries
let cleanupTimer: ReturnType<typeof setInterval> | null = null;

function ensureCleanupRunning() {
if (cleanupTimer) return;
cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [id, entry] of store) {
if (now - entry.createdAt > ENTRY_TTL_MS) {
store.delete(id);
}
}
// Clean stale paymentHash index entries
for (const [hash, receiveId] of paymentHashIndex) {
if (!store.has(receiveId)) {
paymentHashIndex.delete(hash);
}
}
}, CLEANUP_INTERVAL_MS);
// Don't prevent process exit
if (cleanupTimer && typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) {
cleanupTimer.unref();
}
function memIsExpired(entry: InvoiceMetadata): boolean {
return Date.now() - entry.createdAt > ENTRY_TTL_MS;
}

// ── Key helpers ──────────────────────────────────────────────────────
function invKey(receiveRequestId: string) { return `inv:${receiveRequestId}`; }
function hashKey(paymentHash: string) { return `invhash:${paymentHash}`; }

/**
* Store metadata for a newly created invoice.
* Call this when creating a Lightning invoice via Strike API.
*/
export function storeInvoiceMetadata(
export async function storeInvoiceMetadata(
kv: InvoiceKV,
receiveRequestId: string,
metadata: Omit<InvoiceMetadata, 'createdAt' | 'receiveRequestId'>,
paymentHash?: string
): void {
ensureCleanupRunning();

): Promise<void> {
const entry: InvoiceMetadata = {
...metadata,
receiveRequestId,
createdAt: Date.now(),
};
store.set(receiveRequestId, entry);

if (paymentHash) {
paymentHashIndex.set(paymentHash, receiveRequestId);
if (kv) {
const opts = { expirationTtl: KV_TTL_SECONDS };
await kv.put(invKey(receiveRequestId), JSON.stringify(entry), opts);
if (paymentHash) {
await kv.put(hashKey(paymentHash), receiveRequestId, opts);
}
} else {
// Dev fallback
memStore.set(receiveRequestId, entry);
if (paymentHash) {
memHashIndex.set(paymentHash, receiveRequestId);
}
}
}

/**
* Look up metadata by receiveRequestId (used by webhooks).
*/
export function getInvoiceMetadata(receiveRequestId: string): InvoiceMetadata | null {
const entry = store.get(receiveRequestId);
if (!entry) return null;
export async function getInvoiceMetadata(
kv: InvoiceKV,
receiveRequestId: string
): Promise<InvoiceMetadata | null> {
if (kv) {
const raw = await kv.get(invKey(receiveRequestId), 'text') as string | null;
if (!raw) return null;
try {
return JSON.parse(raw) as InvoiceMetadata;
} catch {
// Treat invalid/corrupted data as a cache miss and clean up the bad key.
await kv.delete(invKey(receiveRequestId));
return null;
}
}

// Check expiry
if (Date.now() - entry.createdAt > ENTRY_TTL_MS) {
store.delete(receiveRequestId);
// Dev fallback
const entry = memStore.get(receiveRequestId);
if (!entry) return null;
if (memIsExpired(entry)) {
memStore.delete(receiveRequestId);
return null;
}

return entry;
}

/**
* Look up metadata by paymentHash (used by client-side verify endpoint).
*/
export function getInvoiceMetadataByPaymentHash(paymentHash: string): InvoiceMetadata | null {
const receiveRequestId = paymentHashIndex.get(paymentHash);
export async function getInvoiceMetadataByPaymentHash(
kv: InvoiceKV,
paymentHash: string
): Promise<InvoiceMetadata | null> {
if (kv) {
const receiveRequestId = await kv.get(hashKey(paymentHash), 'text') as string | null;
if (!receiveRequestId) return null;
return getInvoiceMetadata(kv, receiveRequestId);
}

// Dev fallback
const receiveRequestId = memHashIndex.get(paymentHash);
if (!receiveRequestId) return null;
return getInvoiceMetadata(receiveRequestId);
return getInvoiceMetadata(kv, receiveRequestId);
}

/**
* Remove metadata after successful processing (optional cleanup).
*/
export function removeInvoiceMetadata(receiveRequestId: string): void {
const entry = store.get(receiveRequestId);
if (entry) {
store.delete(receiveRequestId);
// Also clean paymentHash index
for (const [hash, id] of paymentHashIndex) {
if (id === receiveRequestId) {
paymentHashIndex.delete(hash);
break;
export async function removeInvoiceMetadata(
kv: InvoiceKV,
receiveRequestId: string
): Promise<void> {
if (kv) {
// We don't have the paymentHash readily available, but KV TTL will
// clean up the hash entry. Delete the primary key immediately.
await kv.delete(invKey(receiveRequestId));
} else {
const entry = memStore.get(receiveRequestId);
if (entry) {
memStore.delete(receiveRequestId);
for (const [hash, id] of memHashIndex) {
if (id === receiveRequestId) {
memHashIndex.delete(hash);
break;
}
}
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/lib/memoization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,18 @@ export function memoize<T extends (...args: any[]) => any>(

/**
* Create a memoized reactive statement for Svelte
* Note: This function captures dependencies at creation time. For dynamic dependencies,
* recreate the memoized function when dependencies change.
*/
export function createMemoizedReactive<T>(
computeFn: () => T,
dependencies: any[],
maxCacheSize: number = 50
): () => T {
void dependencies;
const memoized = memoize(computeFn, maxCacheSize);
const memoized = memoize((...deps: any[]) => computeFn(), maxCacheSize);

return () => {
return memoized();
return memoized(...dependencies);
};
}

Expand Down
7 changes: 6 additions & 1 deletion src/lib/stores/groceryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ function createGroceryStore() {
// Add to local state immediately
update(s => ({
...s,
lists: [newList, ...s.lists]
lists: [newList, ...s.lists],
error: null
}));

// Save to Nostr (immediately, no debounce for new lists)
Expand All @@ -227,6 +228,10 @@ function createGroceryStore() {
update(s => ({ ...s, lastSaved: Date.now() }));
} catch (error) {
console.error('[GroceryStore] Failed to save new list:', error);
update(s => ({
...s,
error: error instanceof Error ? error.message : 'Failed to create grocery list'
}));
// Keep in local state even if save fails - will retry on next change
}

Expand Down
14 changes: 12 additions & 2 deletions src/routes/api/genesis/create-lightning-invoice/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,22 @@ export const POST: RequestHandler = async ({ request, platform }) => {
console.log('[Founders Club Lightning] Invoice created:', {
receiveRequestId,
amountSats,
pubkey: pubkey.substring(0, 16) + '...',
pubkey,
});

// Store metadata so webhook and verify endpoints can match payment to user
// Genesis uses 'pro' tier internally for NIP-05 claiming
storeInvoiceMetadata(
const kv = platform?.env?.GATED_CONTENT ?? null;
const isProd = env.NODE_ENV === 'production';
if (!kv && isProd) {
console.error('[Founders Club Lightning] GATED_CONTENT KV binding missing in production');
return json(
{ error: 'Service temporarily unavailable' },
{ status: 503 }
);
}
await storeInvoiceMetadata(
kv,
receiveRequestId,
{ pubkey, tier: 'pro', period: 'annual' },
paymentHash
Expand Down
5 changes: 3 additions & 2 deletions src/routes/api/genesis/verify-lightning-payment/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ export const POST: RequestHandler = async ({ request, platform }) => {
}

// Look up stored metadata to verify the request
const kv = platform?.env?.GATED_CONTENT ?? null;
const metadata = receiveRequestId
? getInvoiceMetadata(receiveRequestId)
: getInvoiceMetadataByPaymentHash(paymentHash);
? await getInvoiceMetadata(kv, receiveRequestId)
: await getInvoiceMetadataByPaymentHash(kv, paymentHash);

if (!metadata) {
return json(
Expand Down
14 changes: 12 additions & 2 deletions src/routes/api/membership/create-lightning-invoice/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,21 @@ export const POST: RequestHandler = async ({ request, platform }) => {
amountSats,
tier,
period,
pubkey: pubkey.substring(0, 16) + '...',
pubkey,
});

// Store invoice metadata so webhook and verify endpoints can match payment to user
storeInvoiceMetadata(
const kv = platform?.env?.GATED_CONTENT ?? null;
// In production, require the GATED_CONTENT KV binding to avoid falling back to in-memory storage
if (!kv && env.NODE_ENV === 'production') {
console.error('[Membership Lightning] GATED_CONTENT KV binding is missing in production');
return json(
{ error: 'Service temporarily unavailable' },
{ status: 503 }
);
}
await storeInvoiceMetadata(
kv,
receiveRequestId,
{ pubkey, tier: tier as 'cook' | 'pro', period: period as 'annual' | 'monthly' },
paymentHash
Expand Down
13 changes: 12 additions & 1 deletion src/routes/api/membership/strike-webhook/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,18 @@ export const POST: RequestHandler = async ({ request, platform }) => {
}

// Look up stored metadata for this receive request
const metadata = getInvoiceMetadata(receiveRequestId);
const kv = platform?.env?.GATED_CONTENT ?? null;
if (!kv && env.NODE_ENV === 'production') {
console.error('[Strike Webhook] GATED_CONTENT KV binding is missing in production environment');
return json(
{
error: 'Service unavailable',
message: 'GATED_CONTENT KV namespace is not configured'
},
{ status: 503 }
);
}
const metadata = await getInvoiceMetadata(kv, receiveRequestId);

if (!metadata) {
console.warn('[Strike Webhook] No metadata found for receiveRequestId:', receiveRequestId);
Expand Down
13 changes: 11 additions & 2 deletions src/routes/api/membership/verify-lightning-payment/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,18 @@ export const POST: RequestHandler = async ({ request, platform }) => {
}

// Look up stored metadata to verify the request matches what was created
const kv = platform?.env?.GATED_CONTENT ?? null;

// In production, missing KV binding is a misconfiguration, not a 404
if (!kv && env.NODE_ENV === 'production') {
return json(
{ error: 'Service unavailable: GATED_CONTENT KV binding is missing' },
{ status: 503 }
);
}
const metadata = receiveRequestId
? getInvoiceMetadata(receiveRequestId)
: getInvoiceMetadataByPaymentHash(paymentHash);
? await getInvoiceMetadata(kv, receiveRequestId)
: await getInvoiceMetadataByPaymentHash(kv, paymentHash);

if (!metadata) {
return json(
Expand Down