feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user