This commit is contained in:
@@ -32,6 +32,10 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
winner_client_id TEXT REFERENCES clients(id),
|
||||
winner_worker_client_id TEXT REFERENCES clients(id),
|
||||
winner_beacon_path TEXT NOT NULL DEFAULT '',
|
||||
winner_beacon_boosted_path TEXT NOT NULL DEFAULT '',
|
||||
winner_beacon_round INTEGER NOT NULL DEFAULT 0,
|
||||
winner_signature TEXT,
|
||||
winning_guess TEXT,
|
||||
artifact_status TEXT NOT NULL DEFAULT 'none' CHECK (artifact_status IN ('none','pending','generating','ready','error')),
|
||||
@@ -128,3 +132,44 @@ CREATE TABLE IF NOT EXISTS artifact_api_usage (
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_created_idx ON artifact_api_usage(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_kind_created_idx ON artifact_api_usage(kind, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_task_idx ON artifact_api_usage(task_id);
|
||||
|
||||
-- Publicly auditable Beacon Hunt draws. Each row records the externally
|
||||
-- sourced drand reveal used for the weighted path/draw decision.
|
||||
CREATE TABLE IF NOT EXISTS beacon_draws (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
window_end INTEGER NOT NULL,
|
||||
beacon_id TEXT NOT NULL,
|
||||
beacon_round INTEGER NOT NULL,
|
||||
randomness TEXT NOT NULL,
|
||||
signature TEXT NOT NULL DEFAULT '',
|
||||
boosted_path TEXT NOT NULL,
|
||||
ticket_count INTEGER NOT NULL,
|
||||
selected_count INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(task_id, window_end)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS beacon_draws_task_idx ON beacon_draws(task_id, window_end DESC);
|
||||
|
||||
|
||||
-- A hosted worker uses its own cryptographic identity/presence, while prizes can
|
||||
-- be delegated to a durable customer-owned reward identity. The worker remains
|
||||
-- auditable on the completed task through winner_worker_client_id.
|
||||
CREATE TABLE IF NOT EXISTS identity_delegations (
|
||||
worker_client_id TEXT PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE,
|
||||
owner_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS identity_delegations_owner_idx ON identity_delegations(owner_client_id);
|
||||
|
||||
-- One-shot pairing codes let a logged-in owner prove control of the reward
|
||||
-- identity to Customer Service without sharing the P-256 private key. Only the
|
||||
-- SHA-256 token hash is stored and codes expire quickly.
|
||||
CREATE TABLE IF NOT EXISTS customer_link_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS customer_link_tokens_exp_idx ON customer_link_tokens(expires_at);
|
||||
|
||||
@@ -109,6 +109,10 @@ func OpenSQLite(ctx context.Context, path string) (*sql.DB, error) {
|
||||
{"nft_prompt_instructions", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"nft_negative_prompt", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"nft_style_reference", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"winner_worker_client_id", "TEXT REFERENCES clients(id)"},
|
||||
{"winner_beacon_path", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"winner_beacon_boosted_path", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"winner_beacon_round", "INTEGER NOT NULL DEFAULT 0"},
|
||||
} {
|
||||
if err := ensureColumn(ctx, db, "tasks", m.name, m.def); err != nil {
|
||||
db.Close()
|
||||
@@ -220,22 +224,23 @@ type Task struct {
|
||||
CreatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
WinnerClientID *string
|
||||
WinnerWorkerClientID *string
|
||||
ArtifactStatus string
|
||||
ArtifactURI *string
|
||||
ArtifactManifestURI *string
|
||||
}
|
||||
|
||||
const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,created_at,completed_at,winner_client_id,artifact_status,artifact_uri,artifact_manifest_uri`
|
||||
const taskColumns = `id,public_seed,range_bits,status,paused,guess_min_interval_sec,client_submit_interval_sec,revision,parent_task_id,display_name,description,nft_prompt_instructions,nft_negative_prompt,nft_style_reference,created_at,completed_at,winner_client_id,winner_worker_client_id,artifact_status,artifact_uri,artifact_manifest_uri`
|
||||
|
||||
func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, string, error) {
|
||||
var t Task
|
||||
var created int64
|
||||
var completed sql.NullInt64
|
||||
var winner, artifactURI, manifestURI, parent sql.NullString
|
||||
var winner, winnerWorker, artifactURI, manifestURI, parent sql.NullString
|
||||
var guessMin, clientSubmit sql.NullInt64
|
||||
var paused int
|
||||
var secret string
|
||||
args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &t.ArtifactStatus, &artifactURI, &manifestURI}
|
||||
args := []any{&t.ID, &t.PublicSeed, &t.RangeBits, &t.Status, &paused, &guessMin, &clientSubmit, &t.Revision, &parent, &t.DisplayName, &t.Description, &t.NFTPromptInstructions, &t.NFTNegativePrompt, &t.NFTStyleReference, &created, &completed, &winner, &winnerWorker, &t.ArtifactStatus, &artifactURI, &manifestURI}
|
||||
if withSecret {
|
||||
args = append(args, &secret)
|
||||
}
|
||||
@@ -264,6 +269,10 @@ func scanTask(scanner interface{ Scan(...any) error }, withSecret bool) (Task, s
|
||||
v := winner.String
|
||||
t.WinnerClientID = &v
|
||||
}
|
||||
if winnerWorker.Valid {
|
||||
v := winnerWorker.String
|
||||
t.WinnerWorkerClientID = &v
|
||||
}
|
||||
if artifactURI.Valid {
|
||||
v := artifactURI.String
|
||||
t.ArtifactURI = &v
|
||||
@@ -864,6 +873,62 @@ func (s *Store) PublicArtifacts(ctx context.Context, limit int, winner string) (
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OwnedArtifact is returned only to the authenticated winner. Unlike the
|
||||
// public gallery it includes a private download URL for the original artifact.
|
||||
type OwnedArtifact struct {
|
||||
TaskID string `json:"task_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
DownloadURI string `json:"download_uri"`
|
||||
}
|
||||
|
||||
func (s *Store) OwnedArtifacts(ctx context.Context, cid string, limit int) ([]OwnedArtifact, error) {
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 48
|
||||
}
|
||||
cid = strings.TrimSpace(cid)
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id,COALESCE(display_name,''),range_bits,COALESCE(completed_at,created_at)
|
||||
FROM tasks
|
||||
WHERE status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id=?
|
||||
ORDER BY COALESCE(completed_at,created_at) DESC LIMIT ?`, cid, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]OwnedArtifact, 0)
|
||||
for rows.Next() {
|
||||
var a OwnedArtifact
|
||||
var completed int64
|
||||
if err := rows.Scan(&a.TaskID, &a.DisplayName, &a.RangeBits, &completed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CompletedAt = fromUnixMS(completed)
|
||||
escaped := url.PathEscape(a.TaskID)
|
||||
a.PreviewURI = "/api/public/artifacts/" + escaped + "/preview"
|
||||
a.DownloadURI = "/api/me/artifacts/" + escaped + "/download"
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OwnedArtifactSource returns the original artifact only when taskID belongs to
|
||||
// cid. Keeping the ownership check in the database query makes it difficult for
|
||||
// future handlers to accidentally turn the private download endpoint into an
|
||||
// insecure direct object reference.
|
||||
func (s *Store) OwnedArtifactSource(ctx context.Context, taskID, cid string) (artifactURI string, ok bool, err error) {
|
||||
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri FROM tasks
|
||||
WHERE id=? AND winner_client_id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL`, taskID, cid).Scan(&artifactURI)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return artifactURI, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) PublicArtifactSource(ctx context.Context, taskID string) (artifactURI, winner string, ok bool, err error) {
|
||||
err = s.DB.QueryRowContext(ctx, `SELECT artifact_uri,winner_client_id FROM tasks
|
||||
WHERE id=? AND status='completed' AND artifact_status='ready' AND artifact_uri IS NOT NULL AND winner_client_id IS NOT NULL`, taskID).Scan(&artifactURI, &winner)
|
||||
@@ -1351,7 +1416,8 @@ func (s *Store) InactiveNonWinnerClients(ctx context.Context, cutoffMS int64) ([
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT c.id,c.last_seen
|
||||
FROM clients c
|
||||
WHERE c.last_seen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=c.id OR d.owner_client_id=c.id)
|
||||
ORDER BY c.last_seen ASC`, cutoffMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1374,7 +1440,7 @@ func (s *Store) OldWinnerCount(ctx context.Context, cutoffMS int64) (int64, erro
|
||||
var n int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM clients c
|
||||
WHERE c.last_seen < ?
|
||||
AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id)`, cutoffMS).Scan(&n)
|
||||
AND EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=c.id OR t.winner_worker_client_id=c.id)`, cutoffMS).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -1401,7 +1467,8 @@ func (s *Store) DeleteInactiveNonWinnerClients(ctx context.Context, cutoffMS int
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM presence_leases WHERE client_id=? AND expires_at<=?`, id, time.Now().UTC().UnixMilli())
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM clients
|
||||
WHERE id=? AND last_seen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id)`, id, cutoffMS)
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.winner_client_id=clients.id OR t.winner_worker_client_id=clients.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM identity_delegations d WHERE d.worker_client_id=clients.id OR d.owner_client_id=clients.id)`, id, cutoffMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1516,7 +1583,7 @@ func (s *Store) LoadGuessState(ctx context.Context, taskID, cid string) (GuessSt
|
||||
// sequence/count values include all losing guesses that happened in memory
|
||||
// since the previous checkpoint, so a restart resumes from the latest durable
|
||||
// improvement rather than writing every false guess.
|
||||
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool) (Point, error) {
|
||||
func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid, rewardOwner string, nextSeq, guessCount int64, lastGuess time.Time, score float64, guess, sig string, correct bool, beaconPath, beaconBoostedPath string, beaconRound uint64) (Point, error) {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Point{}, err
|
||||
@@ -1543,7 +1610,10 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid string
|
||||
return Point{}, err
|
||||
}
|
||||
if correct {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, cid, sig, guess, t.ID)
|
||||
if rewardOwner == "" {
|
||||
rewardOwner = cid
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status='completed',completed_at=?,winner_client_id=?,winner_worker_client_id=?,winner_beacon_path=?,winner_beacon_boosted_path=?,winner_beacon_round=?,winner_signature=?,winning_guess=?,artifact_status='pending',revision=revision+1 WHERE id=? AND status='active'`, lastMS, rewardOwner, cid, beaconPath, beaconBoostedPath, int64(beaconRound), sig, guess, t.ID)
|
||||
if err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
@@ -1551,7 +1621,7 @@ func (s *Store) PersistImprovement(ctx context.Context, t SecretTask, cid string
|
||||
if n != 1 {
|
||||
return Point{}, ErrTaskCompleted
|
||||
}
|
||||
if err := s.unlocksTx(ctx, tx, cid, t.ID, lastMS); err != nil {
|
||||
if err := s.unlocksTx(ctx, tx, rewardOwner, t.ID, lastMS); err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
}
|
||||
@@ -1608,3 +1678,109 @@ func (s *Store) PointsForClient(ctx context.Context, taskID, cid string, limit i
|
||||
}
|
||||
return append(ps, own), nil
|
||||
}
|
||||
|
||||
// RecordBeaconDraw stores the externally auditable randomness used by an
|
||||
// optional Beacon Hunt lottery window. Duplicate callbacks are idempotent.
|
||||
func (s *Store) RecordBeaconDraw(ctx context.Context, taskID string, windowEnd time.Time, beaconID string, round uint64, randomness, signature, boostedPath string, tickets, selected int) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO beacon_draws(task_id,window_end,beacon_id,beacon_round,randomness,signature,boosted_path,ticket_count,selected_count,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(task_id,window_end) DO UPDATE SET beacon_id=excluded.beacon_id,beacon_round=excluded.beacon_round,randomness=excluded.randomness,signature=excluded.signature,boosted_path=excluded.boosted_path,ticket_count=excluded.ticket_count,selected_count=excluded.selected_count`,
|
||||
taskID, windowEnd.UTC().UnixMilli(), beaconID, int64(round), randomness, signature, boostedPath, tickets, selected, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
type BeaconDraw struct {
|
||||
TaskID string `json:"task_id"`
|
||||
WindowEnd time.Time `json:"window_end"`
|
||||
BeaconID string `json:"beacon_id"`
|
||||
BeaconRound uint64 `json:"beacon_round"`
|
||||
Randomness string `json:"randomness"`
|
||||
Signature string `json:"signature"`
|
||||
BoostedPath string `json:"boosted_path"`
|
||||
TicketCount int `json:"ticket_count"`
|
||||
SelectedCount int `json:"selected_count"`
|
||||
}
|
||||
|
||||
func (s *Store) LatestBeaconDraw(ctx context.Context, taskID string) (BeaconDraw, error) {
|
||||
var d BeaconDraw
|
||||
var endMS int64
|
||||
var round int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT task_id,window_end,beacon_id,beacon_round,randomness,signature,boosted_path,ticket_count,selected_count FROM beacon_draws WHERE task_id=? ORDER BY window_end DESC LIMIT 1`, taskID).
|
||||
Scan(&d.TaskID, &endMS, &d.BeaconID, &round, &d.Randomness, &d.Signature, &d.BoostedPath, &d.TicketCount, &d.SelectedCount)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
d.WindowEnd = time.UnixMilli(endMS).UTC()
|
||||
d.BeaconRound = uint64(round)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetIdentityDelegation(ctx context.Context, workerClientID, ownerClientID string) error {
|
||||
workerClientID = strings.TrimSpace(workerClientID)
|
||||
ownerClientID = strings.TrimSpace(ownerClientID)
|
||||
if workerClientID == "" {
|
||||
return errors.New("worker_client_id required")
|
||||
}
|
||||
if ownerClientID == "" {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM identity_delegations WHERE worker_client_id=?`, workerClientID)
|
||||
return err
|
||||
}
|
||||
if workerClientID == ownerClientID {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM identity_delegations WHERE worker_client_id=?`, workerClientID)
|
||||
return err
|
||||
}
|
||||
if !s.ClientExists(ctx, workerClientID) || !s.ClientExists(ctx, ownerClientID) {
|
||||
return errors.New("worker and owner identities must already exist")
|
||||
}
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO identity_delegations(worker_client_id,owner_client_id,created_at,updated_at) VALUES(?,?,?,?)
|
||||
ON CONFLICT(worker_client_id) DO UPDATE SET owner_client_id=excluded.owner_client_id,updated_at=excluded.updated_at`, workerClientID, ownerClientID, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RewardOwnerForWorker(ctx context.Context, workerClientID string) string {
|
||||
var owner string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT owner_client_id FROM identity_delegations WHERE worker_client_id=?`, workerClientID).Scan(&owner); err == nil && owner != "" {
|
||||
return owner
|
||||
}
|
||||
return workerClientID
|
||||
}
|
||||
|
||||
// CreateCustomerLinkToken stores only a SHA-256 hash of the short-lived pairing
|
||||
// code. A customer can therefore prove control of a Neural Hunt identity to the
|
||||
// private hosted-service control plane without ever uploading its private key.
|
||||
func (s *Store) CreateCustomerLinkToken(ctx context.Context, tokenHash, clientID string, expiresAt time.Time) error {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM customer_link_tokens WHERE expires_at<=? OR client_id=?`, now, clientID)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customer_link_tokens(token_hash,client_id,expires_at,created_at) VALUES(?,?,?,?)`, tokenHash, clientID, expiresAt.UTC().UnixMilli(), now); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ConsumeCustomerLinkToken is intentionally one-shot. The delete happens in
|
||||
// the same transaction as the lookup so the code cannot be replayed by a
|
||||
// second Customer Service request.
|
||||
func (s *Store) ConsumeCustomerLinkToken(ctx context.Context, tokenHash string) (string, error) {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var clientID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT client_id FROM customer_link_tokens WHERE token_hash=? AND expires_at>?`, tokenHash, now).Scan(&clientID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM customer_link_tokens WHERE token_hash=?`, tokenHash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return clientID, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user