mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat: Nostr-native data models + initial schema
models/db.rs: User keyed by pubkey_hash/nostr_pubkey/username (pubkey_hash and seed_fingerprint peppered at rest); Wallet, Session (platform=extension|web|nostr), UserKey, GrinSlatepack, AuditLog. Dropped all telegram/discord/social-identity columns and every tip/social/swap struct (swaps land as their own feature unit). migrations/20260622000001_initial_schema: consolidated clean schema; enum types; UNIQUE on identity columns; FK ON DELETE (CASCADE/SET NULL) for self-service erasure; indexes on every lookup column. PostgreSQL 13+.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
-- Initial schema for smirk-backend-core.
|
||||
--
|
||||
-- Nostr-native identity: a user is keyed by pubkey_hash (peppered), an optional
|
||||
-- nostr_pubkey, and an optional reserved username. No platform-login columns.
|
||||
-- pubkey_hash and seed_fingerprint are stored HMAC-peppered (the pepper lives in
|
||||
-- config); UNIQUE constraints are on the peppered values. FK ON DELETE clauses
|
||||
-- support self-service erasure. Requires PostgreSQL 13+ (gen_random_uuid).
|
||||
|
||||
-- ── Enum types ──────────────────────────────────────────────────────────────
|
||||
CREATE TYPE asset_type AS ENUM ('btc', 'ltc', 'xmr', 'wow', 'grin');
|
||||
|
||||
CREATE TYPE slatepack_status AS ENUM (
|
||||
'pending_recipient', 'pending_sender', 'finalized', 'expired', 'cancelled'
|
||||
);
|
||||
|
||||
CREATE TYPE audit_action AS ENUM (
|
||||
'user_created', 'user_login', 'wallet_created', 'wallet_registered',
|
||||
'tx_broadcast', 'session_created', 'session_revoked'
|
||||
);
|
||||
|
||||
-- ── users ───────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE,
|
||||
pubkey_hash TEXT UNIQUE, -- peppered at rest
|
||||
nostr_pubkey TEXT UNIQUE,
|
||||
wallet_birthday TIMESTAMPTZ,
|
||||
seed_fingerprint TEXT UNIQUE, -- peppered at rest
|
||||
xmr_start_height BIGINT,
|
||||
wow_start_height BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ
|
||||
);
|
||||
-- UNIQUE constraints already index username/pubkey_hash/nostr_pubkey/seed_fingerprint.
|
||||
|
||||
-- ── wallets ─────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE wallets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
asset asset_type NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
view_key TEXT,
|
||||
derivation_index INTEGER,
|
||||
registered_with_node BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
registration_error TEXT,
|
||||
label TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_wallets_user_id ON wallets (user_id);
|
||||
CREATE INDEX idx_wallets_address ON wallets (address);
|
||||
|
||||
-- ── sessions ────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
refresh_token_hash TEXT NOT NULL, -- peppered at rest
|
||||
platform TEXT NOT NULL, -- extension | web | nostr
|
||||
device_info TEXT,
|
||||
ip_address INET,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_sessions_user_id ON sessions (user_id);
|
||||
CREATE INDEX idx_sessions_refresh_token_hash ON sessions (refresh_token_hash);
|
||||
|
||||
-- ── user_keys ───────────────────────────────────────────────────────────────
|
||||
CREATE TABLE user_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
asset asset_type NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
public_spend_key TEXT,
|
||||
key_type TEXT NOT NULL DEFAULT 'primary',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, asset, key_type)
|
||||
);
|
||||
CREATE INDEX idx_user_keys_user_id ON user_keys (user_id);
|
||||
|
||||
-- ── grin_slatepacks ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE grin_slatepacks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slate_id TEXT NOT NULL,
|
||||
sender_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
recipient_address TEXT,
|
||||
slatepack_content TEXT NOT NULL,
|
||||
amount_nanogrin BIGINT NOT NULL,
|
||||
status slatepack_status NOT NULL DEFAULT 'pending_recipient',
|
||||
response_slatepack TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
finalized_at TIMESTAMPTZ,
|
||||
tx_hash TEXT
|
||||
);
|
||||
CREATE INDEX idx_grin_slatepacks_slate_id ON grin_slatepacks (slate_id);
|
||||
CREATE INDEX idx_grin_slatepacks_sender ON grin_slatepacks (sender_user_id);
|
||||
CREATE INDEX idx_grin_slatepacks_recipient ON grin_slatepacks (recipient_user_id);
|
||||
CREATE INDEX idx_grin_slatepacks_status ON grin_slatepacks (status);
|
||||
|
||||
-- ── audit_logs ──────────────────────────────────────────────────────────────
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action audit_action NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id UUID,
|
||||
details JSONB,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_audit_logs_user_id ON audit_logs (user_id);
|
||||
CREATE INDEX idx_audit_logs_action ON audit_logs (action);
|
||||
CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at);
|
||||
@@ -11,3 +11,4 @@
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Database entity models. These map directly to tables and are used with sqlx.
|
||||
//!
|
||||
//! Identity is Nostr-native: a user is keyed by `pubkey_hash` (the wallet's
|
||||
//! identity pubkey), an optional `nostr_pubkey`, and an optional reserved
|
||||
//! `username`. `pubkey_hash` and `seed_fingerprint` are stored HMAC-peppered at
|
||||
//! rest (the pepper lives in config); the values here are already peppered.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Enums (mapped to PostgreSQL enum types) ─────────────────────────────────
|
||||
|
||||
/// Supported cryptocurrency assets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "asset_type", rename_all = "lowercase")]
|
||||
pub enum AssetType {
|
||||
Btc,
|
||||
Ltc,
|
||||
Xmr,
|
||||
Wow,
|
||||
Grin,
|
||||
}
|
||||
|
||||
impl AssetType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AssetType::Btc => "btc",
|
||||
AssetType::Ltc => "ltc",
|
||||
AssetType::Xmr => "xmr",
|
||||
AssetType::Wow => "wow",
|
||||
AssetType::Grin => "grin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AssetType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Grin slatepack relay status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "slatepack_status", rename_all = "snake_case")]
|
||||
pub enum SlatepackStatus {
|
||||
PendingRecipient,
|
||||
PendingSender,
|
||||
Finalized,
|
||||
Expired,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Audit log action types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "audit_action", rename_all = "snake_case")]
|
||||
pub enum AuditAction {
|
||||
UserCreated,
|
||||
UserLogin,
|
||||
WalletCreated,
|
||||
WalletRegistered,
|
||||
TxBroadcast,
|
||||
SessionCreated,
|
||||
SessionRevoked,
|
||||
}
|
||||
|
||||
// ── Entities (field order matches column order) ─────────────────────────────
|
||||
|
||||
/// A user account. Identity is the wallet's key material, not a platform login.
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
/// Reserved `@handle` (backs NIP-05). Unique, optional.
|
||||
pub username: Option<String>,
|
||||
/// Peppered hash of the wallet identity pubkey. Unique, optional.
|
||||
pub pubkey_hash: Option<String>,
|
||||
/// The user's Nostr public key (hex). Unique, optional.
|
||||
pub nostr_pubkey: Option<String>,
|
||||
/// Approximate wallet creation time, to bound chain scans.
|
||||
pub wallet_birthday: Option<DateTime<Utc>>,
|
||||
/// Peppered seed fingerprint, for restore lookup. Unique, optional.
|
||||
pub seed_fingerprint: Option<String>,
|
||||
/// Monero scan start height (skip pre-birthday blocks).
|
||||
pub xmr_start_height: Option<i64>,
|
||||
/// Wownero scan start height.
|
||||
pub wow_start_height: Option<i64>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_seen_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// A wallet associated with a user.
|
||||
///
|
||||
/// Non-custodial: we store the public address and (for XMR/WOW) the view key
|
||||
/// for balance scanning. We NEVER store a spend key or seed.
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct Wallet {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub asset: AssetType,
|
||||
pub address: String,
|
||||
/// View key (XMR/WOW only) for balance scanning.
|
||||
pub view_key: Option<String>,
|
||||
/// HD derivation index (BTC/LTC).
|
||||
pub derivation_index: Option<i32>,
|
||||
pub registered_with_node: bool,
|
||||
pub registration_error: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A session backing a JWT refresh token.
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// Peppered hash of the refresh token (never the token itself).
|
||||
pub refresh_token_hash: String,
|
||||
/// Client kind: `extension`, `web`, or `nostr`.
|
||||
pub platform: String,
|
||||
pub device_info: Option<String>,
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A user's per-asset public key, so others can send to them.
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct UserKey {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub asset: AssetType,
|
||||
/// The public key (format depends on asset).
|
||||
pub public_key: String,
|
||||
/// Public spend key (XMR/WOW).
|
||||
pub public_spend_key: Option<String>,
|
||||
/// `primary` or `backup`.
|
||||
pub key_type: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A Grin slatepack relayed between two parties for an interactive transaction.
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct GrinSlatepack {
|
||||
pub id: Uuid,
|
||||
pub slate_id: String,
|
||||
pub sender_user_id: Uuid,
|
||||
/// Recipient user (if registered on this backend).
|
||||
pub recipient_user_id: Option<Uuid>,
|
||||
/// Recipient slatepack address (if not registered here).
|
||||
pub recipient_address: Option<String>,
|
||||
pub slatepack_content: String,
|
||||
pub amount_nanogrin: i64,
|
||||
pub status: SlatepackStatus,
|
||||
pub response_slatepack: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub finalized_at: Option<DateTime<Utc>>,
|
||||
pub tx_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Audit log entry for security-relevant actions.
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct AuditLog {
|
||||
pub id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub action: AuditAction,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<Uuid>,
|
||||
pub details: Option<serde_json::Value>,
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ── Input structs (no auto-generated fields) ────────────────────────────────
|
||||
|
||||
/// Input for creating a new user. Peppered values are supplied by the db layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewUser {
|
||||
pub username: Option<String>,
|
||||
pub pubkey_hash: Option<String>,
|
||||
pub nostr_pubkey: Option<String>,
|
||||
pub wallet_birthday: Option<DateTime<Utc>>,
|
||||
pub seed_fingerprint: Option<String>,
|
||||
pub xmr_start_height: Option<i64>,
|
||||
pub wow_start_height: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewWallet {
|
||||
pub user_id: Uuid,
|
||||
pub asset: AssetType,
|
||||
pub address: String,
|
||||
pub view_key: Option<String>,
|
||||
pub derivation_index: Option<i32>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSession {
|
||||
pub user_id: Uuid,
|
||||
pub refresh_token_hash: String,
|
||||
pub platform: String,
|
||||
pub device_info: Option<String>,
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewUserKey {
|
||||
pub user_id: Uuid,
|
||||
pub asset: AssetType,
|
||||
pub public_key: String,
|
||||
pub public_spend_key: Option<String>,
|
||||
pub key_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewGrinSlatepack {
|
||||
pub slate_id: String,
|
||||
pub sender_user_id: Uuid,
|
||||
pub recipient_user_id: Option<Uuid>,
|
||||
pub recipient_address: Option<String>,
|
||||
pub slatepack_content: String,
|
||||
pub amount_nanogrin: i64,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAuditLog {
|
||||
pub user_id: Option<Uuid>,
|
||||
pub action: AuditAction,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<Uuid>,
|
||||
pub details: Option<serde_json::Value>,
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Data models: database entities (sqlx) and shared types.
|
||||
//!
|
||||
//! Request/response DTOs are added alongside the handlers that use them.
|
||||
|
||||
pub mod db;
|
||||
Reference in New Issue
Block a user