feat: establish NekoNest Cloud control and relay

This commit is contained in:
2026-08-12 23:25:43 +08:00
commit f27606b709
222 changed files with 71456 additions and 0 deletions
+776
View File
@@ -0,0 +1,776 @@
package tenantstore
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/klarkxy/nekonest/relaycore/protocol"
corestore "github.com/klarkxy/nekonest/relaycore/store"
_ "modernc.org/sqlite"
)
// DB wraps the SQLite database connection.
type DB struct {
conn *sql.DB
preexistingApplication bool
}
// New creates and initializes a new database.
func New(dbPath string) (*DB, error) {
return NewWithTransportMode(dbPath, "")
}
// NewWithTransportMode creates and initializes a database, then establishes
// its one persistent transport mode. requestedMode is only meaningful for the
// first initialization; a later mismatch is rejected rather than silently
// changing how a nest carries application data.
func NewWithTransportMode(dbPath, requestedMode string) (*DB, error) {
if err := preparePrivateDatabase(dbPath); err != nil {
return nil, err
}
// modernc.org/sqlite applies connection-local PRAGMAs through repeated
// _pragma query parameters. The similarly named _journal_mode and
// _busy_timeout parameters are not recognized by this driver, which leaves
// concurrent WebSocket handlers vulnerable to immediate SQLITE_BUSY errors.
conn, err := sql.Open(
"sqlite",
dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)",
)
if err != nil {
return nil, err
}
// A Relay process hosts many tenants. Bound each SQLite pool and database
// independently so one Nest cannot exhaust all descriptors or disk.
conn.SetMaxOpenConns(4)
conn.SetMaxIdleConns(2)
conn.SetConnMaxIdleTime(5 * time.Minute)
if _, err := conn.Exec(`PRAGMA max_page_count = 262144`); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("set tenant sqlite page limit: %w", err)
}
db := &DB{conn: conn}
hadApplicationTables, err := db.hasApplicationTables()
if err != nil {
_ = conn.Close()
return nil, err
}
db.preexistingApplication = hadApplicationTables
if _, err := db.bootstrapTransportMode(requestedMode); err != nil {
_ = conn.Close()
return nil, err
}
if err := db.migrate(); err != nil {
_ = conn.Close()
return nil, err
}
if err := tightenPrivateDatabaseArtifacts(dbPath); err != nil {
_ = conn.Close()
return nil, err
}
return db, nil
}
// Close closes the database connection.
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) migrate() error {
_, err := db.conn.Exec(`
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
os TEXT NOT NULL DEFAULT 'windows',
token_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
active_agents INTEGER NOT NULL DEFAULT 0,
revoked_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS pair_codes (
code TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (device_id) REFERENCES devices(id)
);
CREATE TABLE IF NOT EXISTS user_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id)
);
-- P2-A: Session message history
CREATE TABLE IF NOT EXISTS session_messages (
id TEXT NOT NULL,
device_id TEXT NOT NULL,
session_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'assistant',
content TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'text',
timestamp INTEGER NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (id, device_id, session_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_device_session
ON session_messages(device_id, session_id, timestamp);
-- P2-C: Push notification subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL DEFAULT '',
auth TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
UNIQUE(endpoint, device_id)
);
CREATE INDEX IF NOT EXISTS idx_push_device ON push_subscriptions(device_id);
-- Durable phone -> daemon command state. A client-generated id is scoped
-- to one device and is never forwarded twice.
CREATE TABLE IF NOT EXISTS prompt_commands (
device_id TEXT NOT NULL,
client_msg_id TEXT NOT NULL,
session_id TEXT NOT NULL,
prompt TEXT NOT NULL,
attachments_json TEXT NOT NULL DEFAULT '[]',
sealed_envelope_json TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'registered'
CHECK(status IN ('registered', 'pending', 'accepted', 'failed', 'indeterminate')),
error TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '',
retry_allowed INTEGER NOT NULL DEFAULT 0,
commit_sent INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (device_id, client_msg_id)
);
CREATE INDEX IF NOT EXISTS idx_prompt_commands_status
ON prompt_commands(status, updated_at);
-- Schema version tracking (v1+)
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Independent phone identities (v1)
CREATE TABLE IF NOT EXISTS phone_identities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
ed25519_public TEXT NOT NULL DEFAULT '',
x25519_public TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
revoked_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_phone_token ON phone_identities(token_hash);
-- Phone → host device grants (pairing result)
CREATE TABLE IF NOT EXISTS phone_device_grants (
phone_id TEXT NOT NULL,
device_id TEXT NOT NULL,
paired_at INTEGER NOT NULL,
revoked_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (phone_id, device_id),
FOREIGN KEY (phone_id) REFERENCES phone_identities(id),
FOREIGN KEY (device_id) REFERENCES devices(id)
);
CREATE INDEX IF NOT EXISTS idx_grants_device ON phone_device_grants(device_id);
-- E2E wrapped key packages (ciphertext only on server)
CREATE TABLE IF NOT EXISTS key_packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_id TEXT NOT NULL,
device_id TEXT NOT NULL,
scope TEXT NOT NULL,
session_id TEXT NOT NULL DEFAULT '',
epoch INTEGER NOT NULL,
wrapped_key TEXT NOT NULL,
nonce TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(phone_id, device_id, scope, session_id, epoch)
);
CREATE INDEX IF NOT EXISTS idx_key_packages_phone ON key_packages(phone_id, device_id);
-- Sealed-safe attention routing events. The server intentionally stores
-- no prompt, answer, path, approval detail, or event class here.
CREATE TABLE IF NOT EXISTS attention_events (
device_id TEXT NOT NULL,
event_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (device_id, event_id)
);
CREATE INDEX IF NOT EXISTS idx_attention_events_created_at
ON attention_events(created_at);
`)
if err != nil {
return err
}
if err := db.migratePushSubscriptions(); err != nil {
return err
}
if err := db.migratePromptCommands(); err != nil {
return err
}
if err := db.migratePushPhoneID(); err != nil {
return err
}
if err := db.migrateDeviceIdentityColumns(); err != nil {
return err
}
return db.ensureSchemaVersion()
}
// hasApplicationTables reports whether this database already contained
// NekoNest data before the current migration created its tables. schema_meta is
// deliberately excluded so a brand-new database remains distinguishable.
func (db *DB) hasApplicationTables() (bool, error) {
const q = `SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name IN (
'devices', 'pair_codes', 'user_tokens', 'session_messages',
'push_subscriptions', 'prompt_commands', 'phone_identities',
'phone_device_grants', 'key_packages', 'attention_events'
) LIMIT 1`
var one int
err := db.conn.QueryRow(q).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
// InitializeTransportMode returns the immutable mode for this nest. Existing
// mode metadata is authoritative. A legacy application database with no mode
// metadata is explicitly classified as open once; a genuinely new nest starts
// sealed unless an explicit first-run mode was supplied.
func (db *DB) InitializeTransportMode(requestedMode string) (protocol.TransportMode, error) {
return initializeTransportMode(db.conn, db.preexistingApplication, requestedMode)
}
type transportModeStore interface {
QueryRow(query string, args ...any) *sql.Row
Exec(query string, args ...any) (sql.Result, error)
}
// bootstrapTransportMode creates and pins the immutable nest mode in one
// transaction before application tables are migrated. If startup is
// interrupted after this point, a new sealed database can never be mistaken
// for a legacy open database merely because some tables already exist.
func (db *DB) bootstrapTransportMode(requestedMode string) (protocol.TransportMode, error) {
tx, err := db.conn.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`); err != nil {
return "", err
}
mode, err := initializeTransportMode(tx, db.preexistingApplication, requestedMode)
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return mode, nil
}
func initializeTransportMode(store transportModeStore, preexistingApplication bool, requestedMode string) (protocol.TransportMode, error) {
requestedMode = strings.TrimSpace(requestedMode)
var requested protocol.TransportMode
if requestedMode != "" {
parsed, err := protocol.ParseTransportMode(requestedMode)
if err != nil {
return "", fmt.Errorf("invalid requested transport_mode: %w", err)
}
requested = parsed
}
var stored string
err := store.QueryRow(`SELECT value FROM schema_meta WHERE key = 'transport_mode'`).Scan(&stored)
if err == nil {
mode, parseErr := protocol.ParseTransportMode(stored)
if parseErr != nil {
return "", fmt.Errorf("stored transport_mode is invalid: %w", parseErr)
}
if requested != "" && requested != mode {
return "", fmt.Errorf("transport_mode mismatch: persisted %s, requested %s", mode, requested)
}
return mode, nil
}
if err != sql.ErrNoRows {
return "", err
}
mode := protocol.TransportSealed
if preexistingApplication {
mode = protocol.TransportOpen
}
if requested != "" {
if preexistingApplication && requested != protocol.TransportOpen {
return "", fmt.Errorf("transport_mode mismatch: legacy nest is open; use the offline migration before sealed")
}
mode = requested
}
if _, err := store.Exec(`INSERT INTO schema_meta (key, value) VALUES ('transport_mode', ?)`, string(mode)); err != nil {
return "", err
}
return mode, nil
}
// TransportMode reads the persistent mode. Callers must treat an error as a
// fail-closed startup condition rather than choosing a fallback relay mode.
func (db *DB) TransportMode() (protocol.TransportMode, error) {
var raw string
if err := db.conn.QueryRow(`SELECT value FROM schema_meta WHERE key = 'transport_mode'`).Scan(&raw); err != nil {
return "", err
}
mode, err := protocol.ParseTransportMode(raw)
if err != nil {
return "", fmt.Errorf("stored transport_mode is invalid: %w", err)
}
return mode, nil
}
const attentionEventTTL = 24 * time.Hour
// AcceptAttentionEvent durably deduplicates an event across server instances.
// Only the routing identifiers and timestamp are persisted. Old event ids are
// removed opportunistically to bound the table.
func (db *DB) AcceptAttentionEvent(deviceID, eventID string, createdAt time.Time) (bool, error) {
if strings.TrimSpace(deviceID) == "" || strings.TrimSpace(eventID) == "" {
return false, fmt.Errorf("device_id and event_id required")
}
now := createdAt.Unix()
tx, err := db.conn.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM attention_events WHERE created_at < ?`, now-int64(attentionEventTTL/time.Second)); err != nil {
return false, err
}
result, err := tx.Exec(
`INSERT INTO attention_events (device_id, event_id, created_at) VALUES (?, ?, ?)
ON CONFLICT(device_id, event_id) DO NOTHING`,
deviceID, eventID, now,
)
if err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
n, err := result.RowsAffected()
return err == nil && n == 1, err
}
// SchemaVersion is the current server schema generation.
const SchemaVersion = "1"
func (db *DB) ensureSchemaVersion() error {
var v string
err := db.conn.QueryRow(`SELECT value FROM schema_meta WHERE key = 'version'`).Scan(&v)
if err == nil && v != "" {
return nil
}
_, err = db.conn.Exec(
`INSERT INTO schema_meta (key, value) VALUES ('version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
SchemaVersion,
)
return err
}
// SchemaVersion returns the stored schema version string.
func (db *DB) GetSchemaVersion() string {
var v string
if err := db.conn.QueryRow(`SELECT value FROM schema_meta WHERE key = 'version'`).Scan(&v); err != nil {
return ""
}
return v
}
// migratePushPhoneID adds optional phone_id to push_subscriptions for v1 scoping.
func (db *DB) migratePushPhoneID() error {
var schema string
if err := db.conn.QueryRow(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'push_subscriptions'`,
).Scan(&schema); err != nil {
return err
}
if strings.Contains(strings.ToLower(schema), "phone_id") {
return nil
}
_, err := db.conn.Exec(`ALTER TABLE push_subscriptions ADD COLUMN phone_id TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("migrate push phone_id: %w", err)
}
return nil
}
// migrateDeviceIdentityColumns adds E2E public key fields on devices.
func (db *DB) migrateDeviceIdentityColumns() error {
cols := []struct {
name string
ddl string
}{
{"ed25519_public", `ALTER TABLE devices ADD COLUMN ed25519_public TEXT NOT NULL DEFAULT ''`},
{"x25519_public", `ALTER TABLE devices ADD COLUMN x25519_public TEXT NOT NULL DEFAULT ''`},
{"identity_fingerprint", `ALTER TABLE devices ADD COLUMN identity_fingerprint TEXT NOT NULL DEFAULT ''`},
{"revoked_at", `ALTER TABLE devices ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`},
}
for _, c := range cols {
var schema string
if err := db.conn.QueryRow(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'devices'`,
).Scan(&schema); err != nil {
return err
}
if strings.Contains(strings.ToLower(schema), strings.ToLower(c.name)) {
continue
}
if _, err := db.conn.Exec(c.ddl); err != nil {
return fmt.Errorf("migrate devices.%s: %w", c.name, err)
}
}
return nil
}
// SetDevicePublicKeys stores daemon E2E public keys (base64url) and fingerprint.
func (db *DB) SetDevicePublicKeys(deviceID, ed25519Pub, x25519Pub, fingerprint string) error {
res, err := db.conn.Exec(
`UPDATE devices SET ed25519_public = ?, x25519_public = ?, identity_fingerprint = ? WHERE id = ?`,
ed25519Pub, x25519Pub, fingerprint, deviceID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("device not found")
}
return nil
}
// DevicePublicKeys is the public E2E material for a host daemon.
type DevicePublicKeys = corestore.DevicePublicKeys
// GetDevicePublicKeys returns stored daemon public keys (may be empty).
func (db *DB) GetDevicePublicKeys(deviceID string) (*DevicePublicKeys, error) {
row := db.conn.QueryRow(
`SELECT ed25519_public, x25519_public, identity_fingerprint FROM devices WHERE id = ?`,
deviceID,
)
var k DevicePublicKeys
if err := row.Scan(&k.Ed25519Public, &k.X25519Public, &k.Fingerprint); err != nil {
return nil, err
}
return &k, nil
}
// ClearPlaintextContentForV1 wipes server-held plaintext application content
// after a verified backup. Preserves devices (ids + token hashes) and schema.
// Phones must re-login/re-pair; native agent stores on hosts are untouched.
func (db *DB) ClearPlaintextContentForV1() error {
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
`DELETE FROM session_messages`,
`DELETE FROM prompt_commands`,
`DELETE FROM pair_codes`,
`DELETE FROM push_subscriptions`,
`DELETE FROM key_packages`,
`DELETE FROM phone_device_grants`,
`DELETE FROM phone_identities`,
`DELETE FROM user_tokens`,
} {
if _, err := tx.Exec(q); err != nil {
// Table may not exist on very old DBs — ignore.
if !strings.Contains(err.Error(), "no such table") {
return fmt.Errorf("%s: %w", q, err)
}
}
}
if _, err := tx.Exec(
`INSERT INTO schema_meta (key, value) VALUES ('version', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
SchemaVersion,
); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO schema_meta (key, value) VALUES ('migrated_v1_at', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
fmt.Sprintf("%d", time.Now().Unix()),
); err != nil {
return err
}
// This routine is reachable only from the offline migrator after a verified
// backup and plaintext cleanup. Make the sealed cutover part of the same
// database transaction; normal startup can never switch an existing nest.
if _, err := tx.Exec(
`INSERT INTO schema_meta (key, value) VALUES ('transport_mode', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
string(protocol.TransportSealed),
); err != nil {
return err
}
return tx.Commit()
}
// migratePushSubscriptions upgrades the original UNIQUE(endpoint) schema to a
// per-device mapping. Browsers intentionally reuse one PushSubscription for
// every device selected in the same PWA.
func (db *DB) migratePushSubscriptions() error {
var schema string
if err := db.conn.QueryRow(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'push_subscriptions'`,
).Scan(&schema); err != nil {
return err
}
compact := strings.Join(strings.Fields(strings.ToLower(schema)), "")
if strings.Contains(compact, "unique(endpoint,device_id)") {
return nil
}
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`
CREATE TABLE push_subscriptions_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL DEFAULT '',
auth TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
UNIQUE(endpoint, device_id)
);
INSERT OR REPLACE INTO push_subscriptions_v2
(id, device_id, endpoint, p256dh, auth, created_at)
SELECT id, device_id, endpoint, p256dh, auth, created_at
FROM push_subscriptions;
DROP TABLE push_subscriptions;
ALTER TABLE push_subscriptions_v2 RENAME TO push_subscriptions;
CREATE INDEX idx_push_device ON push_subscriptions(device_id);
`); err != nil {
return fmt.Errorf("migrate push subscriptions: %w", err)
}
return tx.Commit()
}
// migratePromptCommands adds the non-retryable indeterminate terminal state
// used when the daemon cannot prove whether an external CLI accepted a prompt.
func (db *DB) migratePromptCommands() error {
var schema string
if err := db.conn.QueryRow(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'prompt_commands'`,
).Scan(&schema); err != nil {
return err
}
compact := strings.Join(strings.Fields(strings.ToLower(schema)), "")
if strings.Contains(compact, "'registered'") &&
strings.Contains(compact, "'indeterminate'") &&
strings.Contains(compact, "retry_allowed") &&
strings.Contains(compact, "outcome") &&
strings.Contains(compact, "commit_sent") &&
strings.Contains(compact, "sealed_envelope_json") {
return nil
}
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`
CREATE TABLE prompt_commands_v2 (
device_id TEXT NOT NULL,
client_msg_id TEXT NOT NULL,
session_id TEXT NOT NULL,
prompt TEXT NOT NULL,
attachments_json TEXT NOT NULL DEFAULT '[]',
sealed_envelope_json TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'registered'
CHECK(status IN ('registered', 'pending', 'accepted', 'failed', 'indeterminate')),
error TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '',
retry_allowed INTEGER NOT NULL DEFAULT 0,
commit_sent INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (device_id, client_msg_id)
);
INSERT INTO prompt_commands_v2
(device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at)
SELECT device_id, client_msg_id, session_id, prompt, attachments_json, '',
status, error,
CASE status
WHEN 'accepted' THEN 'accepted'
WHEN 'failed' THEN 'failed'
ELSE ''
END,
CASE status WHEN 'failed' THEN 1 ELSE 0 END,
0,
created_at, updated_at
FROM prompt_commands;
DROP TABLE prompt_commands;
ALTER TABLE prompt_commands_v2 RENAME TO prompt_commands;
CREATE INDEX idx_prompt_commands_status
ON prompt_commands(status, updated_at);
`); err != nil {
return fmt.Errorf("migrate prompt commands: %w", err)
}
return tx.Commit()
}
// RegisterDevice registers a new device and returns its token.
// osName should be "windows" or "linux" (v1 formal hosts); empty defaults to windows.
func (db *DB) RegisterDevice(id, name string, osName ...string) (string, error) {
token := generateToken()
if token == "" {
return "", fmt.Errorf("secure device credential generation failed")
}
tokenHash := hashToken(token)
now := time.Now().Unix()
osVal := "windows"
if len(osName) > 0 {
switch strings.ToLower(strings.TrimSpace(osName[0])) {
case "linux":
osVal = "linux"
case "windows", "":
osVal = "windows"
default:
// Keep unknown values for forward compatibility (e.g. future darwin).
if s := strings.TrimSpace(osName[0]); s != "" {
osVal = s
}
}
}
_, err := db.conn.Exec(
`INSERT INTO devices (id, name, os, token_hash, created_at, last_seen) VALUES (?, ?, ?, ?, ?, ?)`,
id, name, osVal, tokenHash, now, now,
)
if err != nil {
return "", err
}
return token, nil
}
// GetDevice retrieves a device by ID.
func (db *DB) GetDevice(id string) (*protocol.Device, error) {
row := db.conn.QueryRow(`SELECT id, name, os, last_seen FROM devices WHERE id = ? AND revoked_at = 0`, id)
var d protocol.Device
if err := row.Scan(&d.ID, &d.Name, &d.OS, &d.LastSeen); err != nil {
return nil, err
}
d.Status = "offline" // default, updated by connection manager
return &d, nil
}
// DeviceExists reports whether a subscription target is registered.
func (db *DB) DeviceExists(id string) bool {
if id == "" {
return false
}
var exists int
err := db.conn.QueryRow(`SELECT 1 FROM devices WHERE id = ? AND revoked_at = 0 LIMIT 1`, id).Scan(&exists)
return err == nil && exists == 1
}
// UpdateDeviceLastSeen updates the last seen timestamp.
func (db *DB) UpdateDeviceLastSeen(id string) error {
_, err := db.conn.Exec(`UPDATE devices SET last_seen = ? WHERE id = ? AND revoked_at = 0`, time.Now().Unix(), id)
return err
}
// ListDevices returns all registered devices.
func (db *DB) ListDevices() ([]*protocol.Device, error) {
rows, err := db.conn.Query(`SELECT id, name, os, last_seen, active_agents FROM devices WHERE revoked_at = 0 ORDER BY last_seen DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []*protocol.Device
for rows.Next() {
d := &protocol.Device{}
if err := rows.Scan(&d.ID, &d.Name, &d.OS, &d.LastSeen, &d.ActiveAgents); err != nil {
return nil, err
}
d.Status = "offline"
devices = append(devices, d)
}
return devices, nil
}
// ValidateDeviceToken checks if a device token is valid.
func (db *DB) ValidateDeviceToken(deviceID, token string) bool {
tokenHash := hashToken(token)
var count int
err := db.conn.QueryRow(`SELECT COUNT(*) FROM devices WHERE id = ? AND token_hash = ? AND revoked_at = 0`, deviceID, tokenHash).Scan(&count)
return err == nil && count > 0
}
// CreatePairCode generates a temporary pairing code.
func (db *DB) CreatePairCode(code, deviceID string, expiresAt time.Time) error {
_, err := db.conn.Exec(
`INSERT INTO pair_codes (code, device_id, expires_at) VALUES (?, ?, ?)`,
code, deviceID, expiresAt.Unix(),
)
return err
}
// ConsumePairCode validates and marks a pair code as used (atomic single-winner).
func (db *DB) ConsumePairCode(code string) (string, error) {
now := time.Now().Unix()
res, err := db.conn.Exec(
`UPDATE pair_codes SET used = 1 WHERE code = ? AND used = 0 AND expires_at >= ?`,
code, now,
)
if err != nil {
return "", err
}
n, err := res.RowsAffected()
if err != nil {
return "", err
}
if n == 0 {
return "", sql.ErrNoRows
}
var deviceID string
err = db.conn.QueryRow(`SELECT device_id FROM pair_codes WHERE code = ?`, code).Scan(&deviceID)
if err != nil {
return "", err
}
return deviceID, nil
}
// UpdateDeviceSessions updates the session-count hint stored in active_agents.
func (db *DB) UpdateDeviceSessions(id string, count int) error {
_, err := db.conn.Exec(`UPDATE devices SET active_agents = ?, last_seen = ? WHERE id = ? AND revoked_at = 0`, count, time.Now().Unix(), id)
return err
}
+187
View File
@@ -0,0 +1,187 @@
package tenantstore
import (
"database/sql"
"fmt"
"time"
"github.com/klarkxy/nekonest/relaycore/protocol"
)
// SaveSealedMessage persists an opaque sealed session_message envelope.
// No application plaintext is written; ciphertext lives in metadata_json.
func (db *DB) SaveSealedMessage(deviceID, sessionID string, msg *protocol.NekoMessage) error {
if msg == nil || msg.SealedPayload == nil {
return nil
}
id := msg.ClientMsgID
if id == "" {
id = fmt.Sprintf("sealed_%d_%d", msg.Timestamp, msg.SealedPayload.Sequence)
}
meta, _ := marshalJSON(map[string]any{
"sealed": true,
"sealed_payload": msg.SealedPayload,
"protocol_version": msg.ProtocolVersion,
"transport_mode": msg.TransportMode,
})
_, err := db.conn.Exec(`
INSERT INTO session_messages (id, device_id, session_id, role, content, type, timestamp, metadata_json)
VALUES (?, ?, ?, 'assistant', '', 'sealed', ?, ?)
ON CONFLICT(id, device_id, session_id) DO UPDATE SET
timestamp = excluded.timestamp,
metadata_json = excluded.metadata_json`,
id, deviceID, sessionID, msg.Timestamp, string(meta),
)
return err
}
// SaveMessage stores a session message in the database.
// Same id is upserted so streaming patches update content in place.
func (db *DB) SaveMessage(deviceID, sessionID string, msg *protocol.SessionMessage) error {
_, err := db.conn.Exec(`
INSERT INTO session_messages (id, device_id, session_id, role, content, type, timestamp, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id, device_id, session_id) DO UPDATE SET
content = excluded.content,
type = excluded.type,
timestamp = excluded.timestamp,
metadata_json = excluded.metadata_json,
role = excluded.role`,
msg.ID, deviceID, sessionID, msg.Role, msg.Content, msg.Type, msg.Timestamp,
metadataToJSON(msg.Metadata),
)
return err
}
// GetMessages retrieves messages for a session, ordered by timestamp.
// limit=0 means no limit.
func (db *DB) GetMessages(deviceID, sessionID string, limit int) ([]*protocol.SessionMessage, error) {
query := `SELECT id, role, content, type, timestamp, metadata_json
FROM session_messages
WHERE device_id = ? AND session_id = ?
ORDER BY timestamp ASC`
if limit > 0 {
query = `SELECT id, role, content, type, timestamp, metadata_json
FROM session_messages
WHERE device_id = ? AND session_id = ?
ORDER BY timestamp DESC
LIMIT ?`
}
var rows *sql.Rows
var err error
if limit > 0 {
rows, err = db.conn.Query(query, deviceID, sessionID, limit)
} else {
rows, err = db.conn.Query(query, deviceID, sessionID)
}
if err != nil {
return nil, err
}
defer rows.Close()
var messages []*protocol.SessionMessage
for rows.Next() {
msg := &protocol.SessionMessage{}
var metadataJSON sql.NullString
if err := rows.Scan(&msg.ID, &msg.Role, &msg.Content, &msg.Type, &msg.Timestamp, &metadataJSON); err != nil {
return nil, err
}
if metadataJSON.Valid {
msg.Metadata = jsonToMetadata(metadataJSON.String)
}
messages = append(messages, msg)
}
if err := rows.Err(); err != nil {
return nil, err
}
// If we used LIMIT, reverse to get chronological order
if limit > 0 {
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
}
return messages, nil
}
// GetMessageCount returns the number of messages for a session.
func (db *DB) GetMessageCount(deviceID, sessionID string) (int, error) {
var count int
err := db.conn.QueryRow(
`SELECT COUNT(*) FROM session_messages WHERE device_id = ? AND session_id = ?`,
deviceID, sessionID,
).Scan(&count)
return count, err
}
// DeleteOldMessages removes messages older than the given timestamp.
func (db *DB) DeleteOldMessages(before time.Time) (int64, error) {
result, err := db.conn.Exec(
`DELETE FROM session_messages WHERE timestamp < ?`,
before.Unix(),
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// DeleteSessionMessages removes all messages for a session.
func (db *DB) DeleteSessionMessages(deviceID, sessionID string) error {
_, err := db.conn.Exec(
`DELETE FROM session_messages WHERE device_id = ? AND session_id = ?`,
deviceID, sessionID,
)
return err
}
// ListSessionsWithMessages returns session IDs that have stored messages for a device.
func (db *DB) ListSessionsWithMessages(deviceID string) ([]string, error) {
rows, err := db.conn.Query(
`SELECT session_id FROM session_messages WHERE device_id = ? GROUP BY session_id ORDER BY MAX(timestamp) DESC`,
deviceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var sessionIDs []string
for rows.Next() {
var sid string
if err := rows.Scan(&sid); err != nil {
return nil, err
}
sessionIDs = append(sessionIDs, sid)
}
return sessionIDs, nil
}
// metadataToJSON converts metadata map to a JSON string for storage.
func metadataToJSON(m map[string]any) string {
if m == nil {
return "{}"
}
// Simple JSON serialization for metadata
data, err := marshalJSON(m)
if err != nil {
return "{}"
}
return string(data)
}
// jsonToMetadata parses a JSON string back to metadata map.
func jsonToMetadata(s string) map[string]any {
if s == "" || s == "{}" {
return nil
}
var m map[string]any
if err := unmarshalJSON([]byte(s), &m); err != nil {
return nil
}
return m
}
+49
View File
@@ -0,0 +1,49 @@
package tenantstore
import (
"fmt"
"os"
)
const privateDatabaseMode os.FileMode = 0o600
func preparePrivateDatabase(dbPath string) error {
if info, err := os.Lstat(dbPath); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("sqlite database must be a regular file")
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect sqlite database: %w", err)
}
file, err := os.OpenFile(dbPath, os.O_CREATE|os.O_RDWR, privateDatabaseMode)
if err != nil {
return fmt.Errorf("open private sqlite database: %w", err)
}
if err := file.Chmod(privateDatabaseMode); err != nil {
_ = file.Close()
return fmt.Errorf("secure sqlite database: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close private sqlite database: %w", err)
}
return tightenPrivateDatabaseArtifacts(dbPath)
}
func tightenPrivateDatabaseArtifacts(dbPath string) error {
for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
if info, err := os.Lstat(path); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("sqlite artifact must be a regular file")
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect sqlite artifact: %w", err)
}
if err := os.Chmod(path, privateDatabaseMode); err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("secure sqlite artifact: %w", err)
}
}
return nil
}
+337
View File
@@ -0,0 +1,337 @@
package tenantstore
import (
"crypto/subtle"
"database/sql"
"errors"
"fmt"
"strings"
"time"
corestore "github.com/klarkxy/nekonest/relaycore/store"
)
// PhoneIdentity is an independent phone client identity.
type PhoneIdentity = corestore.PhoneIdentity
// PhoneAuth is the result of validating a phone bearer token.
type PhoneAuth = corestore.PhoneAuth
var (
ErrPhoneNotFound = corestore.ErrPhoneNotFound
ErrPhoneRevoked = corestore.ErrPhoneRevoked
ErrPhoneTokenInvalid = corestore.ErrPhoneTokenInvalid
ErrGrantNotFound = errors.New("device grant not found")
ErrGrantRevoked = errors.New("device grant revoked")
)
// CreatePhoneIdentity mints a new phone identity and returns the plaintext token once.
func (db *DB) CreatePhoneIdentity(name string) (phoneID, token string, err error) {
name = strings.TrimSpace(name)
if name == "" {
name = "Phone"
}
token = generateToken()
phoneRandom := generateToken()
if token == "" || phoneRandom == "" {
return "", "", errors.New("secure phone credential generation failed")
}
phoneID = "phone_" + phoneRandom[:16]
now := time.Now().Unix()
_, err = db.conn.Exec(
`INSERT INTO phone_identities (id, name, token_hash, ed25519_public, x25519_public, created_at, last_seen, revoked_at)
VALUES (?, ?, ?, '', '', ?, ?, 0)`,
phoneID, name, hashToken(token), now, now,
)
if err != nil {
return "", "", err
}
return phoneID, token, nil
}
// ValidatePhoneToken returns phone auth for an active token.
func (db *DB) ValidatePhoneToken(token string) (*PhoneAuth, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, ErrPhoneTokenInvalid
}
row := db.conn.QueryRow(
`SELECT id, name, token_hash, revoked_at FROM phone_identities WHERE token_hash = ?`,
hashToken(token),
)
var id, name, tokenHash string
var revokedAt int64
if err := row.Scan(&id, &name, &tokenHash, &revokedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPhoneTokenInvalid
}
return nil, err
}
// Constant-time compare of hashes (already looked up by hash; still guards timing).
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hashToken(token))) != 1 {
return nil, ErrPhoneTokenInvalid
}
if revokedAt > 0 {
return nil, ErrPhoneRevoked
}
_, _ = db.conn.Exec(`UPDATE phone_identities SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
return &PhoneAuth{PhoneID: id, Name: name}, nil
}
// GetPhone returns a phone identity by id.
func (db *DB) GetPhone(id string) (*PhoneIdentity, error) {
row := db.conn.QueryRow(
`SELECT id, name, ed25519_public, x25519_public, created_at, last_seen, revoked_at
FROM phone_identities WHERE id = ?`, id,
)
var p PhoneIdentity
if err := row.Scan(&p.ID, &p.Name, &p.Ed25519Public, &p.X25519Public, &p.CreatedAt, &p.LastSeen, &p.RevokedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPhoneNotFound
}
return nil, err
}
return &p, nil
}
// ListPhones returns all phone identities (including revoked).
func (db *DB) ListPhones() ([]*PhoneIdentity, error) {
rows, err := db.conn.Query(
`SELECT id, name, ed25519_public, x25519_public, created_at, last_seen, revoked_at
FROM phone_identities ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PhoneIdentity
for rows.Next() {
var p PhoneIdentity
if err := rows.Scan(&p.ID, &p.Name, &p.Ed25519Public, &p.X25519Public, &p.CreatedAt, &p.LastSeen, &p.RevokedAt); err != nil {
return nil, err
}
out = append(out, &p)
}
return out, rows.Err()
}
// SetPhonePublicKeys stores E2E public keys for a phone.
func (db *DB) SetPhonePublicKeys(phoneID, ed25519Pub, x25519Pub string) error {
res, err := db.conn.Exec(
`UPDATE phone_identities SET ed25519_public = ?, x25519_public = ? WHERE id = ? AND revoked_at = 0`,
ed25519Pub, x25519Pub, phoneID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrPhoneNotFound
}
return nil
}
// RevokePhone marks a phone revoked and revokes all its device grants.
func (db *DB) RevokePhone(phoneID string) error {
now := time.Now().Unix()
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(
`UPDATE phone_identities SET revoked_at = ? WHERE id = ? AND revoked_at = 0`,
now, phoneID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
// Already revoked or missing — check existence
var exists int
if err := tx.QueryRow(`SELECT 1 FROM phone_identities WHERE id = ?`, phoneID).Scan(&exists); err != nil {
return ErrPhoneNotFound
}
return nil
}
if _, err := tx.Exec(
`UPDATE phone_device_grants SET revoked_at = ? WHERE phone_id = ? AND revoked_at = 0`,
now, phoneID,
); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM key_packages WHERE phone_id = ?`, phoneID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM push_subscriptions WHERE phone_id = ?`, phoneID); err != nil {
return err
}
return tx.Commit()
}
// GrantPhoneDevice creates or reactivates a phone→device grant.
func (db *DB) GrantPhoneDevice(phoneID, deviceID string) error {
if phoneID == "" || deviceID == "" {
return fmt.Errorf("phone_id and device_id required")
}
// Ensure phone is active
p, err := db.GetPhone(phoneID)
if err != nil {
return err
}
if p.RevokedAt > 0 {
return ErrPhoneRevoked
}
if !db.DeviceExists(deviceID) {
return fmt.Errorf("unknown device")
}
now := time.Now().Unix()
_, err = db.conn.Exec(`
INSERT INTO phone_device_grants (phone_id, device_id, paired_at, revoked_at)
VALUES (?, ?, ?, 0)
ON CONFLICT(phone_id, device_id) DO UPDATE SET
paired_at = excluded.paired_at,
revoked_at = 0
`, phoneID, deviceID, now)
return err
}
// RevokePhoneDeviceGrant revokes one phone→device grant.
func (db *DB) RevokePhoneDeviceGrant(phoneID, deviceID string) error {
now := time.Now().Unix()
res, err := db.conn.Exec(
`UPDATE phone_device_grants SET revoked_at = ? WHERE phone_id = ? AND device_id = ? AND revoked_at = 0`,
now, phoneID, deviceID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrGrantNotFound
}
_, _ = db.conn.Exec(
`DELETE FROM key_packages WHERE phone_id = ? AND device_id = ?`,
phoneID, deviceID,
)
_, _ = db.conn.Exec(
`DELETE FROM push_subscriptions WHERE phone_id = ? AND device_id = ?`,
phoneID, deviceID,
)
return nil
}
// PhoneHasDeviceGrant reports whether phone may access device.
func (db *DB) PhoneHasDeviceGrant(phoneID, deviceID string) bool {
if phoneID == "" || deviceID == "" {
return false
}
var n int
err := db.conn.QueryRow(
`SELECT 1 FROM phone_device_grants
WHERE phone_id = ? AND device_id = ? AND revoked_at = 0 LIMIT 1`,
phoneID, deviceID,
).Scan(&n)
return err == nil && n == 1
}
// ListPhoneDeviceIDs returns active device grants for a phone.
func (db *DB) ListPhoneDeviceIDs(phoneID string) ([]string, error) {
rows, err := db.conn.Query(
`SELECT device_id FROM phone_device_grants
WHERE phone_id = ? AND revoked_at = 0 ORDER BY paired_at DESC`,
phoneID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// UpsertKeyPackage stores a wrapped key package for a phone/device/scope.
func (db *DB) UpsertKeyPackage(phoneID, deviceID, scope, sessionID string, epoch uint64, wrappedKey, nonce string) error {
now := time.Now().Unix()
_, err := db.conn.Exec(`
INSERT INTO key_packages (phone_id, device_id, scope, session_id, epoch, wrapped_key, nonce, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(phone_id, device_id, scope, session_id, epoch) DO UPDATE SET
wrapped_key = excluded.wrapped_key,
nonce = excluded.nonce,
created_at = excluded.created_at
`, phoneID, deviceID, scope, sessionID, epoch, wrappedKey, nonce, now)
return err
}
// PhoneGrant describes an active phone→device grant with optional E2E pubs.
type PhoneGrant = corestore.PhoneGrant
// ListPhoneGrantsForDevice returns active grants for a host (daemon key wrap).
func (db *DB) ListPhoneGrantsForDevice(deviceID string) ([]*PhoneGrant, error) {
rows, err := db.conn.Query(`
SELECT g.phone_id, g.device_id, p.ed25519_public, p.x25519_public, g.paired_at
FROM phone_device_grants g
JOIN phone_identities p ON p.id = g.phone_id
WHERE g.device_id = ? AND g.revoked_at = 0 AND p.revoked_at = 0
ORDER BY g.paired_at DESC`, deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PhoneGrant
for rows.Next() {
var g PhoneGrant
if err := rows.Scan(&g.PhoneID, &g.DeviceID, &g.Ed25519Public, &g.X25519Public, &g.PairedAt); err != nil {
return nil, err
}
out = append(out, &g)
}
return out, rows.Err()
}
// ListKeyPackages returns key packages for a phone (optionally filtered by device).
func (db *DB) ListKeyPackages(phoneID, deviceID string) ([]map[string]any, error) {
q := `SELECT phone_id, device_id, scope, session_id, epoch, wrapped_key, nonce, created_at
FROM key_packages WHERE phone_id = ?`
args := []any{phoneID}
if deviceID != "" {
q += ` AND device_id = ?`
args = append(args, deviceID)
}
q += ` ORDER BY created_at DESC`
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var pid, did, scope, sid, wk, nonce string
var epoch uint64
var created int64
if err := rows.Scan(&pid, &did, &scope, &sid, &epoch, &wk, &nonce, &created); err != nil {
return nil, err
}
out = append(out, map[string]any{
"phone_id": pid,
"device_id": did,
"scope": scope,
"session_id": sid,
"epoch": epoch,
"wrapped_key": wk,
"nonce": nonce,
"created_at": created,
})
}
return out, rows.Err()
}
+122
View File
@@ -0,0 +1,122 @@
package tenantstore
import (
"fmt"
"strings"
"time"
"github.com/klarkxy/nekonest/relaycore"
corestore "github.com/klarkxy/nekonest/relaycore/store"
)
var (
_ corestore.Store = (*DB)(nil)
_ relaycore.PrincipalSynchronizer = (*DB)(nil)
)
func credentialDigest(credential relaycore.Credential) (string, error) {
value := strings.TrimSpace(credential.Value)
switch credential.Kind {
case relaycore.CredentialRaw:
if value == "" {
return "", fmt.Errorf("credential is required")
}
return hashToken(value), nil
case relaycore.CredentialSHA256:
value = strings.ToLower(value)
if len(value) != 64 {
return "", fmt.Errorf("invalid sha256 credential digest")
}
for _, char := range value {
if !strings.ContainsRune("0123456789abcdef", char) {
return "", fmt.Errorf("invalid sha256 credential digest")
}
}
return value, nil
default:
return "", fmt.Errorf("unsupported credential kind %q", credential.Kind)
}
}
// SyncDevice installs a control-plane-approved identity without ever requiring
// the plaintext bearer to leave the client/Relay admission boundary.
func (db *DB) SyncDevice(id, name, osName string, credential relaycore.Credential) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("device id is required")
}
digest, err := credentialDigest(credential)
if err != nil {
return err
}
name = strings.TrimSpace(name)
if name == "" {
name = "Cloud host"
}
osName = strings.TrimSpace(osName)
if osName == "" {
osName = "unknown"
}
now := time.Now().Unix()
_, err = db.conn.Exec(
`INSERT INTO devices (id, name, os, token_hash, created_at, last_seen, active_agents, revoked_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
os = excluded.os,
token_hash = excluded.token_hash,
revoked_at = 0`,
id, name, osName, digest, now, now,
)
return err
}
// RevokeDevice invalidates the bearer while retaining ciphertext history and
// audit-relevant local state until the tenant retention policy purges it.
func (db *DB) RevokeDevice(id string) error {
result, err := db.conn.Exec(
`UPDATE devices SET token_hash = ?, revoked_at = ?, active_agents = 0
WHERE id = ? AND revoked_at = 0`,
hashToken("revoked:"+generateToken()), time.Now().Unix(), strings.TrimSpace(id),
)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed == 0 {
return fmt.Errorf("device not found or already revoked")
}
return nil
}
// SyncPhone mirrors an approved phone credential digest into this tenant's
// isolated store. Device grants remain separate and are never created here.
func (db *DB) SyncPhone(id, name string, credential relaycore.Credential) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("phone id is required")
}
digest, err := credentialDigest(credential)
if err != nil {
return err
}
name = strings.TrimSpace(name)
if name == "" {
name = "Cloud phone"
}
now := time.Now().Unix()
_, err = db.conn.Exec(
`INSERT INTO phone_identities
(id, name, token_hash, ed25519_public, x25519_public, created_at, last_seen, revoked_at)
VALUES (?, ?, ?, '', '', ?, ?, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
token_hash = excluded.token_hash,
revoked_at = 0`,
id, name, digest, now, now,
)
return err
}
@@ -0,0 +1,259 @@
package tenantstore
import (
"database/sql"
"errors"
"time"
corestore "github.com/klarkxy/nekonest/relaycore/store"
)
const (
PromptRegistered = corestore.PromptRegistered
PromptPending = corestore.PromptPending
PromptAccepted = corestore.PromptAccepted
PromptFailed = corestore.PromptFailed
PromptIndeterminate = corestore.PromptIndeterminate
)
var ErrPromptCommandConflict = corestore.ErrPromptCommandConflict
// PromptCommand is the durable idempotency record for one phone prompt.
type PromptCommand = corestore.PromptCommand
// RegisterPromptCommand inserts a new registered command. When retryFailed is
// explicitly true, a terminal failed command with the exact same immutable
// payload is atomically claimed for one retry. The bool reports whether the
// caller owns the forward.
func (db *DB) RegisterPromptCommand(cmd *PromptCommand, retryFailed bool) (*PromptCommand, bool, error) {
if cmd == nil || cmd.DeviceID == "" || cmd.ClientMsgID == "" || cmd.SessionID == "" {
return nil, false, errors.New("device_id, client_msg_id and session_id are required")
}
now := time.Now().Unix()
result, err := db.conn.Exec(`
INSERT OR IGNORE INTO prompt_commands
(device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'registered', '', '', 0, 0, ?, ?)`,
cmd.DeviceID, cmd.ClientMsgID, cmd.SessionID, cmd.Prompt, cmd.AttachmentsJSON, cmd.SealedEnvelopeJSON, now, now,
)
if err != nil {
return nil, false, err
}
affected, err := result.RowsAffected()
if err != nil {
return nil, false, err
}
stored, err := db.GetPromptCommand(cmd.DeviceID, cmd.ClientMsgID)
if err != nil {
return nil, false, err
}
if affected == 0 &&
(stored.SessionID != cmd.SessionID ||
stored.Prompt != cmd.Prompt ||
stored.AttachmentsJSON != cmd.AttachmentsJSON ||
stored.SealedEnvelopeJSON != cmd.SealedEnvelopeJSON) {
return stored, false, ErrPromptCommandConflict
}
if affected == 1 {
return stored, true, nil
}
// registered means SQLite durably recorded the command but the server did
// not durably observe a successful websocket write. Re-sending the same ID
// is safe because the daemon journals IDs before external execution.
if stored.Status == PromptRegistered {
return stored, true, nil
}
if stored.Status != PromptFailed || !stored.RetryAllowed || !retryFailed {
return stored, false, nil
}
retry, err := db.conn.Exec(`
UPDATE prompt_commands
SET status = 'registered', error = '', outcome = '', retry_allowed = 0, updated_at = ?
WHERE device_id = ? AND client_msg_id = ?
AND status = 'failed' AND retry_allowed = 1`,
time.Now().Unix(), cmd.DeviceID, cmd.ClientMsgID,
)
if err != nil {
return nil, false, err
}
claimed, err := retry.RowsAffected()
if err != nil {
return nil, false, err
}
stored, err = db.GetPromptCommand(cmd.DeviceID, cmd.ClientMsgID)
if err != nil {
return nil, false, err
}
return stored, claimed == 1, nil
}
// MarkPromptForwarded records that the websocket write completed. From this
// point a replay must query daemon journal state rather than resend execution.
func (db *DB) MarkPromptForwarded(deviceID, clientMsgID string) (*PromptCommand, bool, error) {
result, err := db.conn.Exec(`
UPDATE prompt_commands
SET status = 'pending', updated_at = ?
WHERE device_id = ? AND client_msg_id = ? AND status = 'registered'`,
time.Now().Unix(), deviceID, clientMsgID,
)
return db.promptTransitionResult(deviceID, clientMsgID, result, err)
}
func (db *DB) GetPromptCommand(deviceID, clientMsgID string) (*PromptCommand, error) {
var cmd PromptCommand
err := db.conn.QueryRow(`
SELECT device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at
FROM prompt_commands
WHERE device_id = ? AND client_msg_id = ?`,
deviceID, clientMsgID,
).Scan(
&cmd.DeviceID, &cmd.ClientMsgID, &cmd.SessionID, &cmd.Prompt,
&cmd.AttachmentsJSON, &cmd.SealedEnvelopeJSON, &cmd.Status, &cmd.Error, &cmd.Outcome,
&cmd.RetryAllowed, &cmd.CommitSent, &cmd.CreatedAt, &cmd.UpdatedAt,
)
if err != nil {
return nil, err
}
return &cmd, nil
}
// MarkPromptAccepted records the daemon's authoritative acceptance. It may
// override a transport-level failure because websocket writes can fail after a
// complete frame reached the daemon. Accepted itself is terminal.
func (db *DB) MarkPromptAccepted(deviceID, clientMsgID string) (*PromptCommand, bool, error) {
now := time.Now().Unix()
result, err := db.conn.Exec(`
UPDATE prompt_commands
SET status = 'accepted', error = '', outcome = 'accepted',
retry_allowed = 0, commit_sent = 0, updated_at = ?
WHERE device_id = ? AND client_msg_id = ?
AND status IN ('registered', 'pending', 'failed', 'indeterminate')`,
now, deviceID, clientMsgID,
)
return db.promptTransitionResult(deviceID, clientMsgID, result, err)
}
func (db *DB) MarkPromptCommitted(deviceID, clientMsgID string) error {
_, err := db.conn.Exec(`
UPDATE prompt_commands
SET commit_sent = 1, updated_at = ?
WHERE device_id = ? AND client_msg_id = ? AND status = 'accepted'`,
time.Now().Unix(), deviceID, clientMsgID,
)
return err
}
func (db *DB) ListUncommittedAcceptedPrompts(deviceID string, limit int) ([]*PromptCommand, error) {
if limit < 1 || limit > 500 {
limit = 100
}
rows, err := db.conn.Query(`
SELECT device_id, client_msg_id, session_id, prompt, attachments_json, sealed_envelope_json,
status, error, outcome, retry_allowed, commit_sent, created_at, updated_at
FROM prompt_commands
WHERE device_id = ? AND status = 'accepted' AND commit_sent = 0
ORDER BY updated_at ASC, client_msg_id ASC
LIMIT ?`,
deviceID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var commands []*PromptCommand
for rows.Next() {
cmd := &PromptCommand{}
if err := rows.Scan(
&cmd.DeviceID, &cmd.ClientMsgID, &cmd.SessionID, &cmd.Prompt,
&cmd.AttachmentsJSON, &cmd.SealedEnvelopeJSON, &cmd.Status, &cmd.Error, &cmd.Outcome,
&cmd.RetryAllowed, &cmd.CommitSent, &cmd.CreatedAt, &cmd.UpdatedAt,
); err != nil {
return nil, err
}
commands = append(commands, cmd)
}
if err := rows.Err(); err != nil {
return nil, err
}
return commands, nil
}
// MarkPromptFailed transitions a pending command to a terminal result. An
// indeterminate outcome is deliberately never eligible for ordinary retry.
func (db *DB) MarkPromptFailed(
deviceID, clientMsgID, message, outcome string,
retryAllowed bool,
) (*PromptCommand, bool, error) {
status := PromptFailed
if outcome == PromptIndeterminate {
status = PromptIndeterminate
retryAllowed = false
}
if outcome == "" {
outcome = PromptFailed
}
now := time.Now().Unix()
retryInt := 0
if retryAllowed {
retryInt = 1
}
var (
result sql.Result
err error
)
if status == PromptIndeterminate {
// A late daemon journal result must be able to tighten an earlier
// retryable transport failure.
result, err = db.conn.Exec(`
UPDATE prompt_commands
SET status = 'indeterminate', error = ?, outcome = 'indeterminate',
retry_allowed = 0, updated_at = ?
WHERE device_id = ? AND client_msg_id = ?
AND status IN ('registered', 'pending', 'failed')`,
message, now, deviceID, clientMsgID,
)
} else {
result, err = db.conn.Exec(`
UPDATE prompt_commands
SET status = ?, error = ?, outcome = ?, retry_allowed = ?, updated_at = ?
WHERE device_id = ? AND client_msg_id = ?
AND status IN ('registered', 'pending')`,
status, message, outcome, retryInt, now, deviceID, clientMsgID,
)
}
return db.promptTransitionResult(deviceID, clientMsgID, result, err)
}
func (db *DB) promptTransitionResult(
deviceID, clientMsgID string,
result sql.Result,
err error,
) (*PromptCommand, bool, error) {
if err != nil {
return nil, false, err
}
affected, err := result.RowsAffected()
if err != nil {
return nil, false, err
}
cmd, err := db.GetPromptCommand(deviceID, clientMsgID)
if err != nil {
return nil, false, err
}
return cmd, affected == 1, nil
}
// PromptCommandCount is intentionally small and primarily useful for
// diagnostics and focused idempotency tests.
func (db *DB) PromptCommandCount(deviceID, clientMsgID string) (int, error) {
var count int
err := db.conn.QueryRow(`
SELECT COUNT(*) FROM prompt_commands
WHERE device_id = ? AND client_msg_id = ?`,
deviceID, clientMsgID,
).Scan(&count)
return count, err
}
+76
View File
@@ -0,0 +1,76 @@
package tenantstore
import (
"time"
corestore "github.com/klarkxy/nekonest/relaycore/store"
)
const maxPushSubscriptionsPerDevice = 32
// PushSubscription represents a Web Push subscription.
type PushSubscription = corestore.PushSubscription
// SavePushSubscription stores one endpoint mapping per device. A browser reuses
// the same endpoint while the user subscribes to multiple devices.
func (db *DB) SavePushSubscription(sub *PushSubscription) error {
now := time.Now().Unix()
tx, err := db.conn.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(`
INSERT INTO push_subscriptions (device_id, phone_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(endpoint, device_id) DO UPDATE SET
phone_id = excluded.phone_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
created_at = excluded.created_at`,
sub.DeviceID, sub.PhoneID, sub.Endpoint, sub.P256DH, sub.Auth, now,
); err != nil {
return err
}
if _, err = tx.Exec(`
DELETE FROM push_subscriptions
WHERE id IN (
SELECT id FROM push_subscriptions
WHERE device_id = ?
ORDER BY created_at DESC, id DESC
LIMIT -1 OFFSET ?
)`,
sub.DeviceID, maxPushSubscriptionsPerDevice,
); err != nil {
return err
}
return tx.Commit()
}
// GetPushSubscriptions returns all subscriptions for a device.
func (db *DB) GetPushSubscriptions(deviceID string) ([]*PushSubscription, error) {
rows, err := db.conn.Query(
`SELECT id, device_id, phone_id, endpoint, p256dh, auth FROM push_subscriptions WHERE device_id = ?`,
deviceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []*PushSubscription
for rows.Next() {
sub := &PushSubscription{}
if err := rows.Scan(&sub.ID, &sub.DeviceID, &sub.PhoneID, &sub.Endpoint, &sub.P256DH, &sub.Auth); err != nil {
return nil, err
}
subs = append(subs, sub)
}
return subs, nil
}
// DeletePushSubscription removes a subscription by endpoint.
func (db *DB) DeletePushSubscription(endpoint string) error {
_, err := db.conn.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
return err
}
+29
View File
@@ -0,0 +1,29 @@
package tenantstore
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
)
func generateToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func marshalJSON(v any) ([]byte, error) {
return json.Marshal(v)
}
func unmarshalJSON(data []byte, v any) error {
return json.Unmarshal(data, v)
}