task/29: fix code review findings — LNURL zap, vouch binding, migration SQL
## Code review issues resolved ### 1. Zap-out: real LNURL-pay resolution (was: log-only when no bolt11) - `zap.ts`: added `resolveLnurlInvoice()` — full NIP-57 §4 flow: * user@domain → https://domain/.well-known/lnurlp/user * Fetch LNURL-pay metadata → extract callback URL + min/maxSendable * Build signed kind-9734 zap request, send to callback → receive bolt11 * Pay bolt11 via LNbits. Log event regardless of payment outcome. - `nostr-identities.ts`: added `lightningAddress` column (nullable TEXT) - `identity.ts /verify`: extracts `["lud16", "user@domain.com"]` tag from signed event and stores it so ZapService can resolve future invoices - `maybeZapOnJobComplete()` now triggers real payment when lightningAddress is stored; logs a warning and skips payment if not available ### 2. Vouch endpoint: signed event is now REQUIRED with p-tag binding - `event` field changed from optional to required (400 if absent) - Validates: Nostr signature, event.pubkey matches authenticated voucher - NEW: event MUST contain a `["p", voucheePubkey]` tag — proves the voucher intentionally named the vouchee in their signed event (co-signature binding) ### 3. DB migration file added - `lib/db/migrations/0006_timmy_economic_peer.sql` — covers: * CREATE TABLE IF NOT EXISTS timmy_nostr_events (with indexes) * CREATE TABLE IF NOT EXISTS nostr_trust_vouches (with indexes) * ALTER TABLE nostr_identities ADD COLUMN IF NOT EXISTS lightning_address - Schema pushed to production: `lightning_address` column confirmed live ### Additional - `GET /api/identity/timmy` now includes `relayUrl` field (null when unset) - TypeScript compiles cleanly (tsc --noEmit: 0 errors) - All smoke tests pass: /timmy 200, /challenge nonce, /vouch 401/400
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
/**
|
||||
* zap.ts — NIP-57 zap-out capability for Timmy.
|
||||
*
|
||||
* ZapService constructs and signs a NIP-57 zap request event with Timmy's key,
|
||||
* optionally pays a BOLT11 invoice via LNbits, and logs the outbound event to
|
||||
* the `timmy_nostr_events` audit table.
|
||||
* ZapService constructs and signs a NIP-57 zap request event, resolves the
|
||||
* recipient's LNURL-pay endpoint from their stored lightning address, obtains
|
||||
* a BOLT11 invoice, pays it via LNbits, and logs the outbound event.
|
||||
*
|
||||
* LNURL-pay resolution flow (NIP-57 §4):
|
||||
* 1. Look up recipient's lightningAddress from nostr_identities.
|
||||
* 2. Convert user@domain → https://domain/.well-known/lnurlp/user
|
||||
* 3. GET the LNURL-pay metadata — extract callback URL.
|
||||
* 4. Build the signed kind-9734 zap request event.
|
||||
* 5. GET callback?amount=<msats>&nostr=<urlencoded-event> → receive bolt11.
|
||||
* 6. Pay bolt11 via LNbits. Log event regardless of payment outcome.
|
||||
*
|
||||
* Configuration (env vars):
|
||||
* ZAP_PCT_DEFAULT — percentage of work fee to zap back (default 0 = disabled)
|
||||
@@ -12,7 +20,8 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { db, timmyNostrEvents } from "@workspace/db";
|
||||
import { db, timmyNostrEvents, nostrIdentities } from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { timmyIdentityService } from "./timmy-identity.js";
|
||||
import { lnbitsService } from "./lnbits.js";
|
||||
import { makeLogger } from "./logger.js";
|
||||
@@ -28,6 +37,9 @@ const ZAP_PCT_DEFAULT = envInt("ZAP_PCT_DEFAULT", 0);
|
||||
const ZAP_MIN_SATS = envInt("ZAP_MIN_SATS", 10);
|
||||
const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? "";
|
||||
|
||||
// LNURL fetch timeout (ms)
|
||||
const LNURL_TIMEOUT_MS = 8_000;
|
||||
|
||||
export interface ZapRequest {
|
||||
recipientPubkey: string;
|
||||
amountSats: number;
|
||||
@@ -42,6 +54,110 @@ export interface ZapResult {
|
||||
paymentHash?: string;
|
||||
}
|
||||
|
||||
// ── LNURL-pay resolution helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a lightning address (user@domain.com) to the LNURL-pay well-known URL.
|
||||
* Returns null if the input is not a valid address.
|
||||
*/
|
||||
function lightningAddressToUrl(address: string): string | null {
|
||||
const match = address.match(/^([^@]+)@(.+)$/);
|
||||
if (!match) return null;
|
||||
const [, user, domain] = match;
|
||||
return `https://${domain}/.well-known/lnurlp/${encodeURIComponent(user!)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a BOLT11 invoice for the given amount via the LNURL-pay protocol,
|
||||
* embedding a NIP-57 zap request event in the callback.
|
||||
*
|
||||
* Returns the invoice string, or null if any step fails.
|
||||
*/
|
||||
async function resolveLnurlInvoice(
|
||||
lightningAddress: string,
|
||||
amountMsats: number,
|
||||
zapRequestEvent: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
const metaUrl = lightningAddressToUrl(lightningAddress);
|
||||
if (!metaUrl) {
|
||||
logger.warn("zap: invalid lightning address format", { lightningAddress });
|
||||
return null;
|
||||
}
|
||||
|
||||
let callbackUrl: string;
|
||||
let minSendable: number;
|
||||
let maxSendable: number;
|
||||
|
||||
// Step 1 — fetch LNURL-pay metadata
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), LNURL_TIMEOUT_MS);
|
||||
const metaRes = await fetch(metaUrl, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!metaRes.ok) {
|
||||
logger.warn("zap: LNURL metadata fetch failed", { status: metaRes.status, metaUrl });
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = await metaRes.json() as Record<string, unknown>;
|
||||
if (typeof meta["callback"] !== "string") {
|
||||
logger.warn("zap: LNURL metadata missing callback field", { metaUrl });
|
||||
return null;
|
||||
}
|
||||
|
||||
callbackUrl = meta["callback"] as string;
|
||||
minSendable = typeof meta["minSendable"] === "number" ? meta["minSendable"] as number : 1000;
|
||||
maxSendable = typeof meta["maxSendable"] === "number" ? meta["maxSendable"] as number : 10_000_000_000;
|
||||
} catch (err) {
|
||||
logger.warn("zap: LNURL metadata fetch error", {
|
||||
lightningAddress,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate amount within LNURL-pay limits
|
||||
if (amountMsats < minSendable || amountMsats > maxSendable) {
|
||||
logger.warn("zap: amount outside LNURL-pay bounds", { amountMsats, minSendable, maxSendable });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2 — call LNURL-pay callback with zap request
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
amount: amountMsats.toString(),
|
||||
nostr: JSON.stringify(zapRequestEvent),
|
||||
});
|
||||
const callbackFull = `${callbackUrl}${callbackUrl.includes("?") ? "&" : "?"}${params.toString()}`;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), LNURL_TIMEOUT_MS);
|
||||
const callRes = await fetch(callbackFull, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!callRes.ok) {
|
||||
logger.warn("zap: LNURL callback failed", { status: callRes.status });
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await callRes.json() as Record<string, unknown>;
|
||||
if (typeof body["pr"] !== "string") {
|
||||
logger.warn("zap: LNURL callback response missing pr field");
|
||||
return null;
|
||||
}
|
||||
|
||||
return body["pr"] as string;
|
||||
} catch (err) {
|
||||
logger.warn("zap: LNURL callback error", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ZapService ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ZapService {
|
||||
/** Calculate the zap amount for a given work fee, returns 0 if disabled. */
|
||||
zapAmountSats(workFeeSats: number, pct: number = ZAP_PCT_DEFAULT): number {
|
||||
@@ -52,11 +168,16 @@ class ZapService {
|
||||
/**
|
||||
* Send a zap from Timmy to a trusted partner.
|
||||
*
|
||||
* If bolt11 is provided, pays the invoice via LNbits.
|
||||
* Event is always logged to timmy_nostr_events for auditing.
|
||||
* Resolution order for payment:
|
||||
* 1. Use provided bolt11 (caller already has invoice).
|
||||
* 2. Look up recipient's lightningAddress in nostr_identities and resolve
|
||||
* a fresh invoice via LNURL-pay.
|
||||
* 3. If neither is available, log the zap event without a payment.
|
||||
*
|
||||
* The signed kind-9734 event is always logged to timmy_nostr_events.
|
||||
*/
|
||||
async sendZap(req: ZapRequest): Promise<ZapResult> {
|
||||
const { recipientPubkey, amountSats, bolt11, message } = req;
|
||||
const { recipientPubkey, amountSats, bolt11: providedBolt11, message } = req;
|
||||
|
||||
if (amountSats < ZAP_MIN_SATS) {
|
||||
return { skipped: true, reason: `amount ${amountSats} < ZAP_MIN_SATS ${ZAP_MIN_SATS}` };
|
||||
@@ -80,12 +201,45 @@ class ZapService {
|
||||
content: message ?? "⚡ zap from Timmy",
|
||||
});
|
||||
|
||||
// ── Resolve bolt11 ────────────────────────────────────────────────────────
|
||||
let resolvedBolt11: string | undefined = providedBolt11;
|
||||
|
||||
if (!resolvedBolt11 && !lnbitsService.stubMode) {
|
||||
// Try LNURL-pay resolution from stored lightning address
|
||||
const rows = await db
|
||||
.select({ lightningAddress: nostrIdentities.lightningAddress })
|
||||
.from(nostrIdentities)
|
||||
.where(eq(nostrIdentities.pubkey, recipientPubkey));
|
||||
|
||||
const lightningAddress = rows[0]?.lightningAddress ?? null;
|
||||
|
||||
if (lightningAddress) {
|
||||
logger.info("zap: resolving LNURL-pay for recipient", {
|
||||
pubkey: recipientPubkey.slice(0, 8),
|
||||
lightningAddress,
|
||||
});
|
||||
const invoice = await resolveLnurlInvoice(lightningAddress, amountMsats, zapRequestEvent as unknown as Record<string, unknown>);
|
||||
if (invoice) {
|
||||
resolvedBolt11 = invoice;
|
||||
} else {
|
||||
logger.warn("zap: LNURL-pay resolution failed — event logged without payment", {
|
||||
pubkey: recipientPubkey.slice(0, 8),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info("zap: no lightning address stored for recipient — event logged without payment", {
|
||||
pubkey: recipientPubkey.slice(0, 8),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pay invoice ───────────────────────────────────────────────────────────
|
||||
let paymentHash: string | undefined;
|
||||
|
||||
if (bolt11 && !lnbitsService.stubMode) {
|
||||
if (resolvedBolt11 && !lnbitsService.stubMode) {
|
||||
try {
|
||||
paymentHash = await lnbitsService.payInvoice(bolt11);
|
||||
logger.info("zap payment sent", { amountSats, paymentHash, recipientPubkey: recipientPubkey.slice(0, 8) });
|
||||
paymentHash = await lnbitsService.payInvoice(resolvedBolt11);
|
||||
logger.info("zap payment sent", { amountSats, paymentHash, pubkey: recipientPubkey.slice(0, 8) });
|
||||
} catch (err) {
|
||||
logger.warn("zap payment failed — event logged anyway", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -96,6 +250,7 @@ class ZapService {
|
||||
logger.info("zap stub payment simulated", { amountSats, paymentHash });
|
||||
}
|
||||
|
||||
// ── Log event ─────────────────────────────────────────────────────────────
|
||||
await db.insert(timmyNostrEvents).values({
|
||||
id: randomUUID(),
|
||||
kind: 9734,
|
||||
@@ -107,8 +262,9 @@ class ZapService {
|
||||
|
||||
logger.info("zap event logged", {
|
||||
eventId: zapRequestEvent.id,
|
||||
recipientPubkey: recipientPubkey.slice(0, 8),
|
||||
pubkey: recipientPubkey.slice(0, 8),
|
||||
amountSats,
|
||||
paid: !!paymentHash,
|
||||
});
|
||||
|
||||
return { skipped: false, eventId: zapRequestEvent.id, paymentHash };
|
||||
|
||||
@@ -53,6 +53,8 @@ router.post("/identity/challenge", (_req: Request, res: Response) => {
|
||||
// event.pubkey — 64-char hex pubkey
|
||||
// event.content — the nonce returned by /identity/challenge
|
||||
// event.kind — any (27235 recommended per NIP-98, but not enforced)
|
||||
// event.tags — optional: include ["lud16", "user@domain.com"] to store
|
||||
// a lightning address for future zap-out payments
|
||||
|
||||
router.post("/identity/verify", async (req: Request, res: Response) => {
|
||||
// Accept both { event } and { pubkey, event } shapes (pubkey is optional but asserted if present)
|
||||
@@ -133,6 +135,22 @@ router.post("/identity/verify", async (req: Request, res: Response) => {
|
||||
const token = trustService.issueToken(pubkey);
|
||||
const tierInfo = await trustService.getIdentityWithDecay(pubkey);
|
||||
|
||||
// ── Optional: persist lightning address from lud16 tag ────────────────
|
||||
// Allows ZapService to resolve LNURL-pay invoices for this identity.
|
||||
const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : [];
|
||||
const lud16Tag = tags.find(t => Array.isArray(t) && t[0] === "lud16" && typeof t[1] === "string");
|
||||
if (lud16Tag) {
|
||||
const lightningAddress = lud16Tag[1] as string;
|
||||
const lnAddrRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
if (lnAddrRe.test(lightningAddress)) {
|
||||
await db
|
||||
.update(nostrIdentities)
|
||||
.set({ lightningAddress, updatedAt: new Date() })
|
||||
.where(eq(nostrIdentities.pubkey, pubkey));
|
||||
logger.info("lightning address stored", { pubkey: pubkey.slice(0, 8), lightningAddress });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("identity verified", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
tier: identity.tier,
|
||||
@@ -173,6 +191,7 @@ router.get("/identity/timmy", async (_req: Request, res: Response) => {
|
||||
npub: timmyIdentityService.npub,
|
||||
pubkeyHex: timmyIdentityService.pubkeyHex,
|
||||
zapCount,
|
||||
relayUrl: process.env["NOSTR_RELAY_URL"] ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch Timmy identity" });
|
||||
@@ -181,10 +200,15 @@ router.get("/identity/timmy", async (_req: Request, res: Response) => {
|
||||
|
||||
// ── POST /identity/vouch ──────────────────────────────────────────────────────
|
||||
// Allows an elite-tier identity to co-sign a new pubkey, granting it a trust
|
||||
// boost. The voucher's Nostr event is recorded in nostr_trust_vouches.
|
||||
// boost. The voucher MUST provide a signed Nostr event that explicitly tags the
|
||||
// voucheePubkey in a "p" tag, proving the co-signing is intentional.
|
||||
//
|
||||
// Requires: X-Nostr-Token with elite tier.
|
||||
// Body: { voucheePubkey: string, event: NostrEvent (optional — voucher's signed event) }
|
||||
// Body: {
|
||||
// voucheePubkey: string, — 64-char hex pubkey of the identity to vouch for
|
||||
// event: NostrSignedEvent — REQUIRED: voucher's signed kind-1 (or 9735) event
|
||||
// containing a ["p", voucheePubkey] tag
|
||||
// }
|
||||
|
||||
const VOUCH_TRUST_BOOST = 20;
|
||||
|
||||
@@ -224,40 +248,56 @@ router.post("/identity/vouch", async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the voucher event if provided
|
||||
if (event !== undefined) {
|
||||
if (!event || typeof event !== "object") {
|
||||
res.status(400).json({ error: "event must be a Nostr signed event object" });
|
||||
return;
|
||||
}
|
||||
if (!validateEvent(event as Parameters<typeof validateEvent>[0])) {
|
||||
res.status(400).json({ error: "Invalid Nostr event structure" });
|
||||
return;
|
||||
}
|
||||
let valid = false;
|
||||
try { valid = verifyEvent(event as Parameters<typeof verifyEvent>[0]); } catch { valid = false; }
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: "Nostr signature verification failed" });
|
||||
return;
|
||||
}
|
||||
const ev = event as Record<string, unknown>;
|
||||
if (ev["pubkey"] !== parsed.pubkey) {
|
||||
res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" });
|
||||
return;
|
||||
}
|
||||
// ── event is REQUIRED — voucher must produce a signed Nostr event ─────────
|
||||
if (!event || typeof event !== "object") {
|
||||
res.status(400).json({
|
||||
error: "event is required: include a signed Nostr event with a [\"p\", voucheePubkey] tag",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEvent(event as Parameters<typeof validateEvent>[0])) {
|
||||
res.status(400).json({ error: "Invalid Nostr event structure" });
|
||||
return;
|
||||
}
|
||||
|
||||
let valid = false;
|
||||
try { valid = verifyEvent(event as Parameters<typeof verifyEvent>[0]); } catch { valid = false; }
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: "Nostr signature verification failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ev = event as Record<string, unknown>;
|
||||
|
||||
// ── event.pubkey must match the authenticated voucher ────────────────────
|
||||
if (ev["pubkey"] !== parsed.pubkey) {
|
||||
res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── event must contain a ["p", voucheePubkey] tag — binding co-signature ─
|
||||
// This proves the voucher intentionally named the vouchee in their signed event.
|
||||
const tags = Array.isArray(ev["tags"]) ? ev["tags"] as unknown[][] : [];
|
||||
const pTag = tags.find(
|
||||
t => Array.isArray(t) && t[0] === "p" && t[1] === voucheePubkey
|
||||
);
|
||||
if (!pTag) {
|
||||
res.status(400).json({
|
||||
error: `event must include a ["p", "${voucheePubkey}"] tag to bind the co-signature`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Upsert vouchee identity so it exists before applying boost
|
||||
await trustService.getOrCreate(voucheePubkey);
|
||||
|
||||
const vouchEventJson = event ? JSON.stringify(event) : JSON.stringify({ voucher: parsed.pubkey, vouchee: voucheePubkey, ts: Date.now() });
|
||||
|
||||
await db.insert(nostrTrustVouches).values({
|
||||
id: randomUUID(),
|
||||
voucherPubkey: parsed.pubkey,
|
||||
voucheePubkey,
|
||||
vouchEventJson,
|
||||
vouchEventJson: JSON.stringify(event),
|
||||
trustBoost: VOUCH_TRUST_BOOST,
|
||||
});
|
||||
|
||||
|
||||
47
lib/db/migrations/0006_timmy_economic_peer.sql
Normal file
47
lib/db/migrations/0006_timmy_economic_peer.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Migration: Timmy as Economic Peer (Task #29)
|
||||
-- New tables for Timmy's outbound Nostr events and trust vouching,
|
||||
-- plus lightning address storage on existing identities.
|
||||
|
||||
-- ── timmy_nostr_events ────────────────────────────────────────────────────────
|
||||
-- Audit log of every Nostr event Timmy signs and broadcasts (zaps, DMs, etc).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timmy_nostr_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind INTEGER NOT NULL,
|
||||
recipient_pubkey TEXT,
|
||||
event_json TEXT NOT NULL,
|
||||
amount_sats INTEGER,
|
||||
relay_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_timmy_nostr_events_kind
|
||||
ON timmy_nostr_events(kind);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_timmy_nostr_events_recipient
|
||||
ON timmy_nostr_events(recipient_pubkey);
|
||||
|
||||
-- ── nostr_trust_vouches ───────────────────────────────────────────────────────
|
||||
-- Records elite-tier co-signing events. Each row grants a trust boost to the
|
||||
-- vouchee and stores the raw Nostr event for auditability.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nostr_trust_vouches (
|
||||
id TEXT PRIMARY KEY,
|
||||
voucher_pubkey TEXT NOT NULL REFERENCES nostr_identities(pubkey),
|
||||
vouchee_pubkey TEXT NOT NULL,
|
||||
vouch_event_json TEXT NOT NULL,
|
||||
trust_boost INTEGER NOT NULL DEFAULT 20,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nostr_trust_vouches_voucher
|
||||
ON nostr_trust_vouches(voucher_pubkey);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nostr_trust_vouches_vouchee
|
||||
ON nostr_trust_vouches(vouchee_pubkey);
|
||||
|
||||
-- ── nostr_identities: lightning address ──────────────────────────────────────
|
||||
-- NIP-57 lud16 field stored on first verify so ZapService can resolve invoices.
|
||||
|
||||
ALTER TABLE nostr_identities
|
||||
ADD COLUMN IF NOT EXISTS lightning_address TEXT;
|
||||
@@ -25,6 +25,11 @@ export const nostrIdentities = pgTable("nostr_identities", {
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
// Optional Lightning address (NIP-57 lud16 field, e.g. "user@domain.com").
|
||||
// Stored on first /identity/verify if the signed event includes a lud16 tag.
|
||||
// Used by ZapService to resolve LNURL-pay invoices for zap-out payments.
|
||||
lightningAddress: text("lightning_address"),
|
||||
|
||||
lastSeen: timestamp("last_seen", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
Reference in New Issue
Block a user