carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package console is the platform console behind
2// https://platform.carlosframework.com: sign up / sign in with Keymail, see
3// your apps and their live platform state. Slice 1 is strictly READ-ONLY
4// against platform state — the console reads the registry and the release
5// bucket, and mutates nothing but its own little SQLite.
6package console
7
8import (
9 "context"
10 "crypto/ecdsa"
11 "crypto/elliptic"
12 "crypto/rand"
13 "crypto/sha256"
14 "crypto/x509"
15 "database/sql"
16 "encoding/base64"
17 "encoding/hex"
18 "errors"
19 "fmt"
20 "log"
21 "os"
22 "path/filepath"
23 "strings"
24 "sync/atomic"
25 "time"
26
27 "github.com/carlosframework/platform/internal/release"
28 _ "modernc.org/sqlite"
29)
30
31const (
32 // SessionTTL is how long a console session cookie stays valid.
33 SessionTTL = 30 * 24 * time.Hour
34 // ConfirmationTTL bounds a returning-sign-in confirmation link.
35 ConfirmationTTL = 15 * time.Minute
36)
37
38// Store is the console's own SQLite: who exists, who owns what, who is
39// signed in, and the console's Keymail credentials. Platform state (routes,
40// channels) is never in here — that is read live from the registry and the
41// deployment bucket.
42type Store struct {
43 claimBucket release.Store
44 claimSQLAdmit func(context.Context, string, string, string, func(context.Context) error) error
45
46 // db is what every query runs on: the plain pool, or, inside
47 // WithAppMutation, the bound view (store_gate.go).
48 db sqlHandle
49 // rawPool and rawBoot are the raw handles, private to Open, Close,
50 // withClaimSQL and the boot seam (claim_sql_census_test.go keeps them
51 // there). Flag-off, both are db itself.
52 rawPool *sql.DB
53 rawBoot *sql.DB
54 // gate is the structural SQL gate; nil flag-off. bound is set on the
55 // narrow view withClaimSQL hands its callback.
56 gate *claimGate
57 bound *claimBinding
58
59 // mailEventsPruned is when mail_events was last trimmed to its
60 // retention window, as unix seconds (mailevents.go). Zero means not yet
61 // this process, which is what makes the first event after a restart
62 // prune. The stamps are pointers so a Store view shares them with the
63 // Store it was cut from.
64 mailEventsPruned *atomic.Int64
65 // mailPushPruned is the same stamp for the push queue (mailpush.go).
66 mailPushPruned *atomic.Int64
67
68 // codeKey is the in-memory HMAC key for emailed sign-in codes
69 // (emailsignin_store.go). Minted per process and never written down, so a
70 // database read cannot recover a code. A Store view shares it.
71 codeKey []byte
72 // codeEpoch is a random per-process label stored in front of each
73 // code_mac, so a code minted by an earlier process is recognised as such.
74 codeEpoch string
75}
76
77func newStore(db *sql.DB) *Store {
78 key := make([]byte, 32)
79 // crypto/rand.Read does not return an error (it aborts the process if the
80 // system source fails), so there is no weaker key to fall back to.
81 _, _ = rand.Read(key)
82 ep := make([]byte, 8)
83 _, _ = rand.Read(ep)
84 return &Store{codeEpoch: hex.EncodeToString(ep), db: db, rawPool: db, rawBoot: db, mailEventsPruned: new(atomic.Int64), mailPushPruned: new(atomic.Int64), codeKey: key}
85}
86
87// Open opens (creating if needed) the console DB at path.
88func Open(path string) (*Store, error) {
89 if dir := filepath.Dir(path); dir != "." && dir != "" {
90 if err := os.MkdirAll(dir, 0o755); err != nil {
91 return nil, err
92 }
93 }
94 db, err := sql.Open("sqlite", path)
95 if err != nil {
96 return nil, err
97 }
98 // One writer at a time keeps WAL happy.
99 db.SetMaxOpenConns(1)
100 // busy_timeout FIRST: journal_mode=WAL takes a lock, and with no timeout
101 // set yet a concurrent opener fails immediately with SQLITE_BUSY.
102 for _, p := range []string{
103 "PRAGMA busy_timeout=5000",
104 "PRAGMA journal_mode=WAL",
105 "PRAGMA synchronous=NORMAL",
106 } {
107 if _, err := db.Exec(p); err != nil {
108 db.Close()
109 return nil, err
110 }
111 }
112 s := newStore(db)
113 if err := s.migrate(); err != nil {
114 db.Close()
115 return nil, err
116 }
117 return s, nil
118}
119
120// Close releases the underlying database handles. A WithAppMutation view
121// cannot close the store it was cut from.
122func (s *Store) Close() error {
123 if s.bound != nil {
124 return fmt.Errorf("%w: a claim SQL view cannot close the store", ErrClaimSQLScope)
125 }
126 err := s.rawPool.Close()
127 if s.rawBoot != s.rawPool {
128 if berr := s.rawBoot.Close(); err == nil {
129 err = berr
130 }
131 }
132 return err
133}
134
135// migrate is additive-only (CREATE TABLE IF NOT EXISTS); future columns go
136// in the ALTER list, whose "duplicate column" error is swallowed so the list
137// stays idempotent.
138func (s *Store) migrate() error {
139 for _, stmt := range []string{
140 `CREATE TABLE IF NOT EXISTS owners (
141 address TEXT NOT NULL,
142 app TEXT NOT NULL,
143 created_at INTEGER NOT NULL,
144 UNIQUE(address, app)
145);`,
146 `CREATE TABLE IF NOT EXISTS sessions (
147 token_hash TEXT PRIMARY KEY,
148 address TEXT NOT NULL,
149 keymail_token TEXT NOT NULL DEFAULT '',
150 created_at INTEGER NOT NULL,
151 expires_at INTEGER NOT NULL
152);`,
153 `CREATE TABLE IF NOT EXISTS users (
154 address TEXT PRIMARY KEY,
155 created_at INTEGER NOT NULL
156);`,
157 `CREATE TABLE IF NOT EXISTS grants (
158 address TEXT PRIMARY KEY,
159 token TEXT NOT NULL,
160 created_at INTEGER NOT NULL,
161 updated_at INTEGER NOT NULL
162);`,
163 `CREATE TABLE IF NOT EXISTS confirmations (
164 token_hash TEXT PRIMARY KEY,
165 address TEXT NOT NULL,
166 created_at INTEGER NOT NULL,
167 expires_at INTEGER NOT NULL
168);`,
169 `CREATE TABLE IF NOT EXISTS keys (
170 name TEXT PRIMARY KEY,
171 pkcs8 TEXT NOT NULL,
172 created_at INTEGER NOT NULL
173);`,
174 `CREATE TABLE IF NOT EXISTS apps (
175 app TEXT PRIMARY KEY,
176 created_by TEXT NOT NULL,
177 created_at INTEGER NOT NULL
178);`,
179 `CREATE TABLE IF NOT EXISTS audit (
180 id INTEGER PRIMARY KEY AUTOINCREMENT,
181 app TEXT NOT NULL,
182 address TEXT NOT NULL,
183 action TEXT NOT NULL,
184 detail TEXT NOT NULL DEFAULT '',
185 created_at INTEGER NOT NULL
186);`,
187 // ---- multi-account -------------------------------------------------
188 // The account is the tenancy primitive: it owns apps and has members.
189 // id is MINTED into the bucket (domains.MintAccount) and materialized
190 // here, never autoincrement — see account.go.
191 // name is deliberately NOT unique: identity is the sqid, so there is
192 // nothing to squat.
193 `CREATE TABLE IF NOT EXISTS accounts (
194 id INTEGER PRIMARY KEY,
195 sqid TEXT NOT NULL UNIQUE,
196 name TEXT NOT NULL,
197 created_at INTEGER NOT NULL
198);`,
199 // Membership replaces the per-app owners grant: app access follows
200 // from account membership. Exactly two roles, owner and member.
201 `CREATE TABLE IF NOT EXISTS memberships (
202 account_id INTEGER NOT NULL,
203 address TEXT NOT NULL,
204 role TEXT NOT NULL,
205 added_at INTEGER NOT NULL,
206 PRIMARY KEY (account_id, address)
207);`,
208 // Invites are single-use, expiring, revocable. The token is stored in
209 // the clear (unlike sessions/confirmations): it is delivered into the
210 // addressee's own Keymail inbox and is worthless without also being
211 // signed in as that address, and an owner has to be able to LIST and
212 // revoke a specific pending invite.
213 `CREATE TABLE IF NOT EXISTS invites (
214 token TEXT PRIMARY KEY,
215 account_id INTEGER NOT NULL,
216 address TEXT NOT NULL,
217 invited_by TEXT NOT NULL,
218 created_at INTEGER NOT NULL,
219 expires_at INTEGER NOT NULL,
220 accepted_at INTEGER NOT NULL DEFAULT 0
221);`,
222 // The alert watcher's memory (alertwatch.go): the firing state the
223 // console last told ONE operator about, per (box, rule, address). A
224 // row's absence means "never told them", which is why AlertSeen
225 // reports known separately — a first sighting of a firing rule is
226 // news, a first sighting of a quiet one is not.
227 //
228 // Per-recipient rather than per-rule because delivery fails per
229 // recipient: one operator's revoked keymail grant must not re-page
230 // every healthy operator on the next pass. The empty address is a
231 // synthetic row meaning "nobody was mailable when this transition
232 // happened" (see alertwatch.go).
233 `CREATE TABLE IF NOT EXISTS alert_seen (
234 box TEXT NOT NULL,
235 rule TEXT NOT NULL,
236 address TEXT NOT NULL,
237 firing INTEGER NOT NULL,
238 PRIMARY KEY (box, rule, address)
239);`,
240 // Per-tenant SES send/bounce/complaint/reject counts, one row per
241 // (account, app, day) — the abuse signal and the only source of a
242 // per-tenant send count (tenant-email spec §9, Task 16). Keyed by
243 // account (the sqid, matching mail.Record's own acct string), not
244 // account_id: this table is written from handleSESEvents, which
245 // resolves straight off mail.ListDomains and never looks up a
246 // console account row to get one.
247 `CREATE TABLE IF NOT EXISTS mail_counts (
248 account TEXT NOT NULL,
249 app TEXT NOT NULL,
250 day TEXT NOT NULL, -- UTC YYYY-MM-DD
251 sends INTEGER NOT NULL DEFAULT 0,
252 bounces INTEGER NOT NULL DEFAULT 0,
253 complaints INTEGER NOT NULL DEFAULT 0,
254 rejects INTEGER NOT NULL DEFAULT 0,
255 PRIMARY KEY (account, app, day)
256);`,
257 // Per-SENDER counts (senders design §8). A new table rather than a
258 // widened key, because SQLite cannot ALTER a primary key. Every count
259 // is written here from now on; mail_counts stays, unwritten, and is
260 // dropped a release later by DropLegacyMailCounts.
261 //
262 // day is a UTC date string, not a timestamp, exactly as mail_counts'
263 // is — which is why this table cannot join mailAppTables blindly: the
264 // rename, purge and reap sweeps key on mailAppTimeColumn and there is
265 // no such column here.
266 `CREATE TABLE IF NOT EXISTS mail_sender_counts (
267 account TEXT NOT NULL,
268 app TEXT NOT NULL,
269 sender TEXT NOT NULL,
270 day TEXT NOT NULL, -- UTC YYYY-MM-DD
271 sends INTEGER NOT NULL DEFAULT 0,
272 bounces INTEGER NOT NULL DEFAULT 0,
273 complaints INTEGER NOT NULL DEFAULT 0,
274 rejects INTEGER NOT NULL DEFAULT 0,
275 deliveries INTEGER NOT NULL DEFAULT 0,
276 PRIMARY KEY (account, app, sender, day)
277);`,
278 // What happened to each message, per (account, app) — one row per
279 // recipient per SES notification (mailevents.go). The counts above
280 // say how many bounced today; this says which message, to whom, and
281 // what the receiving server answered, so that a member can find out
282 // and an app can be told. Keyed like mail_counts, by the account
283 // sqid, for the same reason.
284 //
285 // notice is SNS's own MessageId for the notification: SNS delivers
286 // at least once, and a notification delivered twice is one row, not
287 // two. by_sender records HOW the row was attributed — 1 when SES
288 // named this app's own sending identity, 0 when the sending domain
289 // was all there was to go on and every app sharing it got a copy.
290 // It is kept because the difference matters to anything that hands
291 // a row on: only a row SES attributed to the app itself is that
292 // app's to be told about.
293 //
294 // Recipient addresses are personal data, so the table is bounded in
295 // time (mailEventsRetention) rather than kept for ever.
296 `CREATE TABLE IF NOT EXISTS mail_events (
297 account TEXT NOT NULL,
298 app TEXT NOT NULL,
299 notice TEXT NOT NULL,
300 recipient TEXT NOT NULL,
301 message_id TEXT NOT NULL,
302 type TEXT NOT NULL,
303 status TEXT NOT NULL DEFAULT '',
304 diagnostic TEXT NOT NULL DEFAULT '',
305 detail TEXT NOT NULL DEFAULT '',
306 by_sender INTEGER NOT NULL DEFAULT 0,
307 at INTEGER NOT NULL,
308 received_at INTEGER NOT NULL,
309 PRIMARY KEY (account, app, notice, recipient)
310);`,
311 `CREATE INDEX IF NOT EXISTS mail_events_by_time ON mail_events (account, app, at);`,
312 `CREATE INDEX IF NOT EXISTS mail_events_by_message ON mail_events (account, app, message_id);`,
313 `CREATE INDEX IF NOT EXISTS mail_events_by_age ON mail_events (received_at);`,
314 // Where an app wants to be told about its email (mailpush.go): one
315 // path per app, POSTed on the app's own platform host. Absent row =
316 // the app is not told, which is every app until it asks.
317 //
318 // failing_since is when the app's current run of failed deliveries
319 // began (0: none), and suspended_at when the push was suspended for
320 // a day of them (0: not suspended) — see mailPushSuspendAfter. Both
321 // clear when the push is turned on again.
322 `CREATE TABLE IF NOT EXISTS mail_event_hooks (
323 account TEXT NOT NULL,
324 app TEXT NOT NULL,
325 path TEXT NOT NULL,
326 set_by TEXT NOT NULL,
327 set_at INTEGER NOT NULL,
328 failing_since INTEGER NOT NULL DEFAULT 0,
329 suspended_at INTEGER NOT NULL DEFAULT 0,
330 suspended_why TEXT NOT NULL DEFAULT '',
331 PRIMARY KEY (account, app)
332);`,
333 // The queue of those notifications, one row per SES notification per
334 // app, retried until delivered or given up on. The payload carries
335 // recipients' addresses, so the row lives no longer than the log it
336 // was cut from (mailEventsRetention), delivered or not. notice is
337 // UNIQUE with the app so SNS redelivering a notification queues it
338 // once.
339 `CREATE TABLE IF NOT EXISTS mail_event_deliveries (
340 id INTEGER PRIMARY KEY,
341 account TEXT NOT NULL,
342 app TEXT NOT NULL,
343 notice TEXT NOT NULL,
344 payload TEXT NOT NULL,
345 attempts INTEGER NOT NULL DEFAULT 0,
346 next_at INTEGER NOT NULL,
347 created_at INTEGER NOT NULL,
348 delivered_at INTEGER NOT NULL DEFAULT 0,
349 abandoned_at INTEGER NOT NULL DEFAULT 0,
350 last_attempt_at INTEGER NOT NULL DEFAULT 0,
351 last_status INTEGER NOT NULL DEFAULT 0,
352 last_error TEXT NOT NULL DEFAULT '',
353 abandoned_why TEXT NOT NULL DEFAULT '',
354 UNIQUE (account, app, notice)
355);`,
356 // A pass seeks each app's oldest due rows through this index, so its
357 // cost follows the number of apps asking, not the size of anyone's
358 // backlog.
359 `CREATE INDEX IF NOT EXISTS mail_event_deliveries_app_due ON mail_event_deliveries (account, app, delivered_at, abandoned_at, next_at);`,
360 `CREATE INDEX IF NOT EXISTS mail_event_deliveries_by_age ON mail_event_deliveries (created_at);`,
361 // One row, written the first time a request arrives carrying a valid
362 // edge forwarded-for proof (edgeproof.go). From then on a loopback
363 // peer's X-Forwarded-For is believed only with a proof beside it.
364 `CREATE TABLE IF NOT EXISTS edge_proof_seen (
365 id INTEGER PRIMARY KEY CHECK (id = 1),
366 seen_at INTEGER NOT NULL
367);`,
368 // The console's move pass pages (movesrecovery.go): the last
369 // attempted page per finding, so a repeating page keeps a full
370 // hour between attempts across restarts, and a once-only page is
371 // never repeated.
372 `CREATE TABLE IF NOT EXISTS move_pages (
373 rule TEXT PRIMARY KEY,
374 attempted_at INTEGER NOT NULL
375);`,
376 // The move pass's outbox (movesrecovery.go, Plan 6 Task 3): move log
377 // entries the pass owes and has not yet written, with their exact
378 // bytes and the instance incarnation their move's evidence carried,
379 // until the recovery pass's drainer writes them, finds them written,
380 // or finds the incarnation gone. An unconfirmed row waits for the
381 // write it records to be confirmed; next_at is the earliest next
382 // attempt, and warned marks the one journal warning an hour-old row
383 // gets. This is the table's only definition: it exists only on this
384 // unmerged branch, so no console database has an older shape.
385 `CREATE TABLE IF NOT EXISTS move_log_outbox (
386 host TEXT NOT NULL,
387 op TEXT NOT NULL,
388 name TEXT NOT NULL,
389 incarnation TEXT NOT NULL,
390 body BLOB NOT NULL,
391 queued_at INTEGER NOT NULL,
392 confirmed INTEGER NOT NULL,
393 attempts INTEGER NOT NULL DEFAULT 0,
394 next_at INTEGER NOT NULL DEFAULT 0,
395 warned INTEGER NOT NULL DEFAULT 0,
396 held INTEGER NOT NULL DEFAULT 0,
397 PRIMARY KEY (host, op, name)
398);`,
399 // Plans (plans-caps-packs spec §4, §7): one row per account naming
400 // its plan and packs. revision moves on every change;
401 // published_revision/published_catalog say which revision, under
402 // which catalog, the bucket's limits record already carries.
403 `CREATE TABLE IF NOT EXISTS account_plans (
404 account_id INTEGER PRIMARY KEY,
405 plan TEXT NOT NULL,
406 packs TEXT NOT NULL DEFAULT '{}',
407 source TEXT NOT NULL,
408 sponsored TEXT NOT NULL DEFAULT '',
409 reason TEXT NOT NULL DEFAULT '',
410 revision INTEGER NOT NULL,
411 published_revision INTEGER NOT NULL DEFAULT 0,
412 published_catalog TEXT NOT NULL DEFAULT '',
413 updated_by TEXT NOT NULL,
414 updated_at INTEGER NOT NULL
415);`,
416 } {
417 if _, err := s.db.Exec(stmt); err != nil {
418 return err
419 }
420 }
421 // Additive column changes land here, forgiving "duplicate column" so
422 // re-running is always safe.
423 for _, stmt := range []string{
424 `ALTER TABLE users ADD COLUMN operator INTEGER NOT NULL DEFAULT 0;`,
425 // deliveries (tenant-email, ruling 2a): SES already publishes
426 // Delivery to the event destination and the ingest already
427 // verifies and parses it — this stops throwing the answer away.
428 // Display-only: the cap and the bounce/complaint thresholds are
429 // deliberately NOT rebased on it, because SES's own account-review
430 // math is bounces over SENDS, and a limiter that measures something
431 // else from what SES reviews on is a limiter that trips at the
432 // wrong time.
433 `ALTER TABLE mail_counts ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0;`,
434 // Where a returning user lands: the account they were last in.
435 `ALTER TABLE users ADD COLUMN last_account INTEGER NOT NULL DEFAULT 0;`,
436 // Apps belong to an account. The column lands with default 1 (ops), so
437 // every already-claimed app keeps working; the PRIMARY KEY stays `app`
438 // until the migration verb rebuilds the table, because changing a PK
439 // is not an additive migration.
440 `ALTER TABLE apps ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1;`,
441 // The audit trail is per (account, app) too. Existing rows default to
442 // ops, which is where every pre-accounts app lived — so no history is
443 // lost and none of it lands in somebody else's account.
444 `ALTER TABLE audit ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1;`,
445 // Structured activity is additive to the original prose-only detail.
446 // Empty fields mean an old row; Detail remains its lossless fallback.
447 `ALTER TABLE audit ADD COLUMN target_type TEXT NOT NULL DEFAULT '';`,
448 `ALTER TABLE audit ADD COLUMN target TEXT NOT NULL DEFAULT '';`,
449 `ALTER TABLE audit ADD COLUMN message TEXT NOT NULL DEFAULT '';`,
450 // Trash state for apps (30-day cooloff; the row IS the name
451 // reservation, so it survives trash and dies at purge) and tombstone
452 // MEMORY for accounts: a marked account row is never re-materialized,
453 // which is what stops a rewritten bucket manifest resurrecting a
454 // deleted account — see MaterializeAccounts.
455 `ALTER TABLE apps ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
456 `ALTER TABLE accounts ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
457 // Placement (customer-fleets, 2026-08-06 ruling): the STORE OF RECORD
458 // for which fleet (if any) an app's routes/instances should run on —
459 // "" = unplaced. Route-derivation (fleets.go's appPlacement) used to
460 // be the only source, which left a freshly-created app with zero
461 // routes unable to carry a placement at all (`carlos apps create
462 // -place` would be a silent no-op). This column is what `carlos apps
463 // place` and apps-create's optional place field both write; every
464 // existing row defaults to "", identical to "never placed" under the
465 // old route-only reading.
466 `ALTER TABLE apps ADD COLUMN placement TEXT NOT NULL DEFAULT '';`,
467 // Fleet pool assignment (economics-v2, Task 4): the operator-set
468 // pricing/capacity tier driving Task 5's placement into a fleet pool.
469 // "" means "never assigned" — every existing row defaults to it,
470 // identical to "unassigned" under the old tier-less reading. See
471 // SetAccountTier for the fixed set of non-empty values.
472 `ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT '';`,
473 // Control-plane apps (console-as-an-app spec §6): the flag REFUSES
474 // destruction — trash, purge, rename, transfer — and refuses a
475 // service principal's promote. It deliberately does NOT gate a
476 // person's promote: `stable`'s fresh-auth 403 has no API-side
477 // step-up and `carlos deploy` log.Fatalfs on a failed promote AFTER
478 // the ship has landed (deploy.go:206-208), so gating promotion would
479 // break `carlos deploy -app console` — the command the flag exists to
480 // make safe. Deploying the console should be easy; destroying it
481 // should be hard.
482 `ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;`,
483 // The path `carlos deploy` polls for the build the app's own PROCESS
484 // reports, instead of X-Carlos-Version — which the edge stamps from
485 // the route row (edge.go:1336) and which therefore lies from the
486 // moment adoption swaps the symlink until something restarts the
487 // process. "" keeps today's header behaviour byte for byte.
488 `ALTER TABLE apps ADD COLUMN build_probe TEXT NOT NULL DEFAULT '';`,
489 // Temporary apps (temporary static sites spec §2). The lifecycle lives
490 // HERE, never in the bucket, so bucket write access cannot make an app
491 // temporary. expires_at 0 = permanent. restoring 1 = between a
492 // restore's "about to delete the marker" and "row is live" (§6), and
493 // counts against the cap. purge_after/purge_for are fixed at trash
494 // time from whether the row was temporary then; nothing re-derives
495 // them.
496 `ALTER TABLE apps ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;`,
497 `ALTER TABLE apps ADD COLUMN restoring INTEGER NOT NULL DEFAULT 0;`,
498 `ALTER TABLE apps ADD COLUMN purge_after INTEGER NOT NULL DEFAULT 0;`,
499 `ALTER TABLE apps ADD COLUMN purge_for INTEGER NOT NULL DEFAULT 0;`,
500 // App region (nearest-region spec §5.1): the placement code new
501 // instances default to, in the RECORD vocabulary — "" is the
502 // deployment's home, which is also what every existing row reads, so
503 // the column default IS the backfill (§5.4). region_source is a label
504 // for people and logs: chosen | timezone | ip | default | "" (the row
505 // predates the feature). No timezone, IP or coordinate is stored.
506 `ALTER TABLE apps ADD COLUMN region TEXT NOT NULL DEFAULT '';`,
507 `ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';`,
508 // How a session was proved (email sign-in spec §5.4). Every session
509 // that predates the column was made through Keymail, so the default
510 // is the backfill.
511 `ALTER TABLE sessions ADD COLUMN method TEXT NOT NULL DEFAULT 'keymail';`,
512 } {
513 if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
514 return err
515 }
516 }
517 // The one-shot backfill of mail_sender_counts from mail_counts (senders
518 // design §8). It runs AFTER the ALTER list because mail_counts.deliveries
519 // is added there, and it runs at most once for the life of the database:
520 // a backfill repeated at every start would resurrect rows a purge or a
521 // rename had since removed, since mail_counts is not swept by anything.
522 // PRAGMA user_version is the marker because the console has no other
523 // place to keep one and this is the first thing that has needed it.
524 if err := s.backfillMailSenderCounts(); err != nil {
525 return err
526 }
527 if err := s.migrateAppsKey(); err != nil {
528 return err
529 }
530 // CLI identity's own tables (authstore.go), kept out of the list above so
531 // the feature is one file to read and one file to remove.
532 if err := s.migrateCLIAuth(); err != nil {
533 return err
534 }
535 // Passkey step-up's own tables (stepup.go), for the same reason.
536 if err := s.migrateStepUp(); err != nil {
537 return err
538 }
539 // Email sign-in's challenge table (emailsignin_store.go), for the same reason.
540 if err := s.migrateEmailSignin(); err != nil {
541 return err
542 }
543 // Pending-change quorum's own tables (pending.go), for the same reason.
544 if err := s.migratePendingChanges(); err != nil {
545 return err
546 }
547 // Door keys' own tables (doorkeys_store.go), for the same reason.
548 return s.migrateDoorKeys()
549}
550
551// mailCountsBackfillVersion is the user_version this migration stamps. The
552// next migration that needs a one-shot marker takes 2 and reads this
553// constant rather than the literal.
554const mailCountsBackfillVersion = 1
555
556func (s *Store) backfillMailSenderCounts() error {
557 var version int
558 if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
559 return err
560 }
561 if version >= mailCountsBackfillVersion {
562 return nil
563 }
564 if _, err := s.db.Exec(`INSERT OR IGNORE INTO mail_sender_counts
565 (account, app, sender, day, sends, bounces, complaints, rejects, deliveries)
566SELECT account, app, 'app', day, sends, bounces, complaints, rejects, deliveries FROM mail_counts`); err != nil {
567 return err
568 }
569 // PRAGMA takes no bound parameter, and the value is a constant in this
570 // file rather than anything a caller supplies.
571 _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, mailCountsBackfillVersion))
572 return err
573}
574
575// DropLegacyMailCounts removes the pre-senders counts table. Guarded by a
576// flag that defaults off and driven by an operator a release after the
577// backfill, for the same reason mail.SweepLegacyRecords is: the rollback
578// window has to close before the old shape goes, and dropping it early would
579// leave a rolled-back console counting into a table that is not there.
580func (s *Store) DropLegacyMailCounts(enabled bool) error {
581 if !enabled {
582 return nil
583 }
584 _, err := s.db.Exec(`DROP TABLE IF EXISTS mail_counts`)
585 return err
586}
587
588// migrateAppsKey moves the apps table from PRIMARY KEY(app) — a GLOBAL name
589// land-grab — to PRIMARY KEY(account_id, app), which is what "app names are
590// unique within an account, never globally" actually means. SQLite cannot
591// ALTER a primary key, so this is the one place in the console that rebuilds
592// a table rather than appending to the ALTER list.
593//
594// It is safe to do here rather than behind an operator verb because every row
595// is carried across unchanged (account_id already defaulted to 1 = ops), the
596// table is small, and the whole thing runs in one transaction. Leaving it to
597// a verb would mean a console that silently refuses a second account's "blog"
598// until someone remembers to run it.
599//
600// Idempotent: it does nothing once the new key is in place.
601func (s *Store) migrateAppsKey() error {
602 var ddl string
603 err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='apps'`).Scan(&ddl)
604 if err != nil {
605 return err
606 }
607 if strings.Contains(ddl, "PRIMARY KEY (account_id, app)") {
608 return nil // already rebuilt
609 }
610 tx, err := s.db.Begin()
611 if err != nil {
612 return err
613 }
614 defer tx.Rollback()
615 for _, stmt := range []string{
616 `CREATE TABLE apps_new (
617 app TEXT NOT NULL,
618 account_id INTEGER NOT NULL DEFAULT 1,
619 created_by TEXT NOT NULL,
620 created_at INTEGER NOT NULL,
621 deleted_at INTEGER NOT NULL DEFAULT 0,
622 placement TEXT NOT NULL DEFAULT '',
623 control_plane INTEGER NOT NULL DEFAULT 0,
624 build_probe TEXT NOT NULL DEFAULT '',
625 region TEXT NOT NULL DEFAULT '',
626 region_source TEXT NOT NULL DEFAULT '',
627 expires_at INTEGER NOT NULL DEFAULT 0,
628 restoring INTEGER NOT NULL DEFAULT 0,
629 purge_after INTEGER NOT NULL DEFAULT 0,
630 purge_for INTEGER NOT NULL DEFAULT 0,
631 PRIMARY KEY (account_id, app)
632);`,
633 // The ALTERs above run before migrateAppsKey in migrate(), so
634 // deleted_at, placement, control_plane, build_probe, region,
635 // region_source and the temporary columns already exist on the old
636 // table by the time this rebuild copies them — a row already
637 // trashed, already placed, already the control plane, already
638 // carrying a build probe or an app region, or already temporary,
639 // when the rebuild runs must not have that state silently dropped
640 // (nearest-region spec §5.2, temporary static sites spec §2).
641 `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source, expires_at, restoring, purge_after, purge_for)
642 SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source, expires_at, restoring, purge_after, purge_for FROM apps;`,
643 `DROP TABLE apps;`,
644 `ALTER TABLE apps_new RENAME TO apps;`,
645 } {
646 if _, err := tx.Exec(stmt); err != nil {
647 return err
648 }
649 }
650 return tx.Commit()
651}
652
653// ---- owners -----------------------------------------------------------------
654
655// Grant makes address an owner of app (idempotent).
656func (s *Store) Grant(address, app string) error {
657 return s.GrantAt(address, app, time.Now())
658}
659
660// GrantAt is Grant with an explicit timestamp — the backfill promotes the
661// EARLIEST grant holder when there is no operator, so that ordering has to be
662// settable by a test rather than by sleeping.
663func (s *Store) GrantAt(address, app string, at time.Time) error {
664 if release.IsTemporaryName(app) {
665 return ErrTemporaryNameReserved
666 }
667 _, err := s.db.Exec(`INSERT OR IGNORE INTO owners (address, app, created_at) VALUES (?, ?, ?)`,
668 address, app, at.Unix())
669 return err
670}
671
672// Revoke removes address's ownership of app.
673func (s *Store) Revoke(address, app string) error {
674 _, err := s.db.Exec(`DELETE FROM owners WHERE address = ? AND app = ?`, address, app)
675 return err
676}
677
678// AppsFor returns the apps address owns, sorted.
679func (s *Store) AppsFor(address string) ([]string, error) {
680 rows, err := s.db.Query(`SELECT app FROM owners WHERE address = ? ORDER BY app`, address)
681 if err != nil {
682 return nil, err
683 }
684 defer rows.Close()
685 var out []string
686 for rows.Next() {
687 var a string
688 if err := rows.Scan(&a); err != nil {
689 return nil, err
690 }
691 out = append(out, a)
692 }
693 return out, rows.Err()
694}
695
696// Owners returns every grant, keyed by owner address, apps sorted.
697func (s *Store) Owners() (map[string][]string, error) {
698 rows, err := s.db.Query(`SELECT address, app FROM owners ORDER BY address, app`)
699 if err != nil {
700 return nil, err
701 }
702 defer rows.Close()
703 out := map[string][]string{}
704 for rows.Next() {
705 var addr, app string
706 if err := rows.Scan(&addr, &app); err != nil {
707 return nil, err
708 }
709 out[addr] = append(out[addr], app)
710 }
711 return out, rows.Err()
712}
713
714// ---- accounts, memberships, invites -----------------------------------------
715
716// GrantHolder is one pre-accounts app-grant holder that the backfill
717// deliberately left OUT of ops, with the apps they held grants for. The
718// backfill cannot decide where these people belong — that is an account per
719// person or per project, which is an operator's judgement — so it reports them
720// instead of guessing.
721type GrantHolder struct {
722 Address string
723 Apps []string
724}
725
726// BackfillOpsMemberships maps the pre-accounts world onto the new one: the
727// built-in ops account exists, every OPERATOR is an owner of it, and every
728// granted app name is claimed in it (before accounts, every app was in ops by
729// definition). It returns the grant holders it left out, so the caller can say
730// who still needs an account of their own.
731//
732// WHY non-operators are excluded — this widened access in production once
733// already, and the reasoning has to survive the next person to read it:
734//
735// Authorization is "Role(account, address) != "" AND HasAppIn(account, app)".
736// A membership is therefore access to EVERY app in the account, not to the app
737// somebody once held a grant for. The first version of this function swept all
738// grant holders into ops as members, on the theory that a member is the
739// harmless role. It is not harmless in ops: ops holds console, platform-site,
740// status, and every app that has not yet been migrated out. On the flagship
741// that handed a user who held ONE grant — for a toy app — the ability to
742// promote and roll back kass, keymail and the console itself. Nobody granted
743// them that; the migration invented it, and it took hand-edited SQL to undo.
744//
745// A per-app grant was never deployment-wide authority. What IS deployment-wide
746// authority is the operator flag, so that — and only that — makes an ops
747// membership here, as owner.
748//
749// There is deliberately no "promote somebody so ops has an owner" fallback any
750// more. The old code promoted the earliest grant holder when no operator
751// existed, reasoning that an unadministrable account is worse than an
752// unexpected owner. But that is the same widening by a quieter route, and it
753// is no longer the only way out: an ownerless ops is recoverable box-side with
754// `console member -add <addr> bab -role owner` — the tool that did not exist
755// when that fallback was written.
756//
757// Strictly ADDITIVE and idempotent, so it can run on every console start: it
758// only ever INSERT OR IGNOREs, so no existing membership is ever demoted,
759// re-roled or removed. That matters concretely — the flagship has already run
760// this once under the old semantics and been cleaned up by hand, and a re-run
761// must not resurrect what was removed. It also deliberately does NOT delete the
762// owners table: the grants stay readable while the migration is verified.
763func (s *Store) BackfillOpsMemberships(now time.Time) ([]GrantHolder, error) {
764 tx, err := s.db.Begin()
765 if err != nil {
766 return nil, err
767 }
768 defer tx.Rollback()
769 if _, err := tx.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at)
770VALUES (?, ?, 'ops', ?)`, OpsAccountID, opsSqid, now.Unix()); err != nil {
771 return nil, err
772 }
773 // OR IGNORE, never OR REPLACE or UPDATE: an address that is already a
774 // member keeps whatever role it has. This is the only membership write in
775 // the whole function.
776 if _, err := tx.Exec(`INSERT OR IGNORE INTO memberships (account_id, address, role, added_at)
777SELECT ?, address, ?, ? FROM users WHERE operator != 0`,
778 OpsAccountID, RoleOwner, now.Unix()); err != nil {
779 return nil, err
780 }
781 // The app NAMES are still claimed in ops: that is where every pre-accounts
782 // app physically lives, and claiming them keeps two accounts from minting
783 // the same name before the migration moves them. `console migrate-app` is
784 // what re-homes them afterwards.
785 //
786 // The NOT IN is not a tidy-up — it is the fix for a bug that was live on the
787 // flagship, and it was found by running the migration in production, not by
788 // reading this code. `carlos accounts migrate` + `console migrate-app` moved
789 // jelly out of ops into account bac. The migration deliberately leaves the
790 // legacy grant readable (see the note above about not deleting the owners
791 // table). The next console RESTART ran this statement, found a grant for
792 // "jelly", found no jelly row in OPS — because it had just been moved out —
793 // and inserted one. The same app then existed in two accounts at once, and
794 // kass and keymail were lined up for the same on the following restart. The
795 // migration was being undone, indefinitely, by restarting the console; it
796 // took a hand-deleted row and hand-revoked grants to stop it.
797 //
798 // ON CONFLICT could never have caught this: the primary key is
799 // (account_id, app), so an ops row and a bac row for the same app do not
800 // conflict — they are, by the schema's own rules, two different apps. The
801 // claim has to be conditioned on the app name being unclaimed EVERYWHERE,
802 // which is what the backfill actually means: take the names nothing has
803 // placed yet. An app that already has a row somewhere has been placed, and
804 // the row that placed it is the authority — never this sweep.
805 if _, err := tx.Exec(`INSERT OR IGNORE INTO apps (app, account_id, created_by, created_at)
806SELECT o.app, ?, MIN(o.address), ? FROM owners o
807WHERE o.app NOT IN (SELECT app FROM apps) AND o.app NOT LIKE 'tmp-%'
808GROUP BY o.app`, OpsAccountID, now.Unix()); err != nil {
809 return nil, err
810 }
811 // A tmp- grant never becomes a row: the namespace is reserved for
812 // temporary apps, and only /new-temporary may create one (spec §3,
813 // astra 7.2). Said at every start, because only an operator can revoke it.
814 skipped, err := tx.Query(`SELECT DISTINCT app FROM owners WHERE app LIKE 'tmp-%' AND app NOT IN (SELECT app FROM apps) ORDER BY app`)
815 if err != nil {
816 return nil, err
817 }
818 for skipped.Next() {
819 var app string
820 if err := skipped.Scan(&app); err != nil {
821 skipped.Close()
822 return nil, err
823 }
824 log.Printf("console: not claiming %s for ops: names starting with tmp- belong to temporary apps. Revoke the grant with: console revoke <address> %s", app, app)
825 }
826 skipped.Close()
827 if err := skipped.Err(); err != nil {
828 return nil, err
829 }
830 // Who is left over: grant holders who are not operators and hold no ops
831 // membership. The membership exclusion is what makes the report quiet once
832 // an operator has dealt with someone — deliberately admitting them, or
833 // giving them their own account — rather than nagging on every restart.
834 rows, err := tx.Query(`SELECT o.address, o.app FROM owners o
835WHERE o.address NOT IN (SELECT address FROM users WHERE operator != 0)
836 AND o.address NOT IN (SELECT address FROM memberships WHERE account_id = ?)
837ORDER BY o.address, o.app`, OpsAccountID)
838 if err != nil {
839 return nil, err
840 }
841 var out []GrantHolder
842 for rows.Next() {
843 var addr, app string
844 if err := rows.Scan(&addr, &app); err != nil {
845 rows.Close()
846 return nil, err
847 }
848 if n := len(out); n > 0 && out[n-1].Address == addr {
849 out[n-1].Apps = append(out[n-1].Apps, app)
850 continue
851 }
852 out = append(out, GrantHolder{Address: addr, Apps: []string{app}})
853 }
854 rows.Close()
855 if err := rows.Err(); err != nil {
856 return nil, err
857 }
858 if err := tx.Commit(); err != nil {
859 return nil, err
860 }
861 return out, nil
862}
863
864// grantHolderAdvice renders the leftover grant holders as something an
865// operator can act on: who, which apps, and the exact three commands that give
866// one of them an account. A count and a list would be a to-do; this is a
867// runbook, and the difference decides whether it gets done.
868//
869// Returns "" when there is nobody, so the caller logs nothing rather than a
870// line saying nothing happened.
871func grantHolderAdvice(holders []GrantHolder) string {
872 if len(holders) == 0 {
873 return ""
874 }
875 var b strings.Builder
876 fmt.Fprintf(&b, "console: %d grant holders need accounts of their own — they hold pre-accounts "+
877 "app grants and were deliberately NOT made members of ops (a membership there is access to "+
878 "console, platform-site and every unmigrated app): ", len(holders))
879 for i, h := range holders {
880 if i > 0 {
881 b.WriteString("; ")
882 }
883 fmt.Fprintf(&b, "%s (%s)", h.Address, strings.Join(h.Apps, " "))
884 }
885 // Two commands, not three: `accounts create -owner` now records the owner in
886 // the account manifest and the console materializes it into an owner
887 // membership, so the `console member -add … -role owner` step this advice
888 // used to carry is gone. It was never optional — an operator who skipped it
889 // got an account nobody could open, which is exactly what happened on the
890 // flagship — and the surest way to stop a step being skipped is to not need
891 // it. The -owner flag is REQUIRED, so it has to appear here or the copied
892 // command fails.
893 b.WriteString(". Give each one their own with: carlos accounts create --name <name> --owner <address> " +
894 "&& console migrate-app --app <app> --to <sqid>")
895 return b.String()
896}
897
898// Roles. Two, not a matrix: enough authority to make invites safe, without
899// inventing a permissions model before there is a second user to test it
900// against.
901const (
902 RoleOwner = "owner"
903 RoleMember = "member"
904)
905
906// InviteTTL bounds a pending invite.
907const InviteTTL = 7 * 24 * time.Hour
908
909// opsSqid is the ops account's frozen public identity. Spelled out rather
910// than imported from release so the store layer stays dependency-free; the
911// two are pinned equal by TestMintedSqidIsTheFrozenEncoding.
912const opsSqid = "bab"
913
914// Account is one tenancy root as the console sees it. Role is filled only by
915// AccountsFor (it is the viewer's role, not a property of the account).
916type Account struct {
917 ID int64
918 Sqid string
919 Name string
920 Role string
921 CreatedAt time.Time
922
923 // Seg is the path segment the console generates for this account in the
924 // viewer's chrome (preferredSeg) — display state, never stored.
925 Seg string
926}
927
928// Invite is one pending invitation.
929type Invite struct {
930 Token string
931 AccountID int64
932 Address string
933 InvitedBy string
934 CreatedAt time.Time
935 ExpiresAt time.Time
936}
937
938// CreateAccount records a minted account and makes its creator the first
939// owner, in ONE transaction: an account with no owner is unadministrable —
940// nobody left who can invite, rename, or delete it — so the two writes must
941// never be separable by a crash.
942func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now time.Time) error {
943 tx, err := s.db.Begin()
944 if err != nil {
945 return err
946 }
947 defer tx.Rollback()
948 if _, err := tx.Exec(`INSERT INTO accounts (id, sqid, name, created_at) VALUES (?, ?, ?, ?)`,
949 id, sqid, name, now.Unix()); err != nil {
950 return err
951 }
952 if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at) VALUES (?, ?, ?, ?)`,
953 id, ownerAddress, RoleOwner, now.Unix()); err != nil {
954 return err
955 }
956 return tx.Commit()
957}
958
959// EnsureAccount records an account the console did NOT mint — one that already
960// exists in the bucket (MaterializeAccounts). added reports whether this call
961// created the row.
962//
963// Unlike CreateAccount it deliberately creates no membership, and that is not
964// an oversight: this call knows only what a manifest's identity fields say.
965// Inventing an owner — the invoking operator, the first user, anyone — would be
966// minting authority out of thin air over an account this box knows nothing
967// about. An ownerless row is honest and harmless (no membership means no
968// access).
969//
970// Where the owner DOES come from is the manifest's own `owner` field, applied
971// separately by AdoptManifestOwner, so identity and authority stay two distinct
972// decisions: a manifest with a broken owner still materializes an account, and
973// an old manifest with no owner at all still materializes exactly as it did
974// before the field existed.
975//
976// INSERT OR IGNORE, never REPLACE: an account the console already knows must
977// keep its id, its name and its members.
978func (s *Store) EnsureAccount(id int64, sqid, name string, created time.Time) (added bool, err error) {
979 res, err := s.db.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at) VALUES (?, ?, ?, ?)`,
980 id, sqid, name, created.Unix())
981 if err != nil {
982 return false, err
983 }
984 n, err := res.RowsAffected()
985 return n > 0, err
986}
987
988// AdoptManifestOwner turns an account manifest's `owner` field into the owner
989// membership — the console half of the fix for a PRODUCTION failure, found by
990// running the migration on the flagship and not by reading this code.
991//
992// What happened: `carlos accounts create` recorded an owner nowhere at all. The
993// bucket manifest and the registry row both landed, and the account had no
994// members. Membership IS authorization here, so after kass and keymail were
995// migrated into fresh accounts the operator could not open either of them in
996// the console — the account existed, held the apps, and had no door. The only
997// way in was a `console member -add … -role owner` that nothing told anybody to
998// run.
999//
1000// The carlos binary still does not open this database — that boundary is
1001// deliberate and was enforced in an earlier review. Instead the manifest, which
1002// is already the one place account identity crosses between the CLI, the
1003// console and every box, now carries the intended owner, and this is where the
1004// console reads it.
1005//
1006// It fills a VACANCY and nothing else. The manifest is re-read on every console
1007// start, so treating it as an instruction would make it a standing grant: an
1008// address removed by an operator would come back on the next restart, which is
1009// precisely the shape of the app-backfill bug this same production run turned
1010// up. So: an account that already has ANY owner is left completely alone, and
1011// only an ownerless one is filled. Returns whether it wrote anything.
1012//
1013// This is the SECOND of two independent guards, and deliberately not the load
1014// bearing one. The manifest is not write-once — it can be rewritten by anyone
1015// with bucket write access — so the caller only ever invokes this on the pass
1016// that first creates the account row (see materializeOwner). That structural
1017// rule is what stops a rewritten manifest granting authority; this vacancy
1018// check is what stops a race between the row insert and this write, and what
1019// keeps the function safe to reason about on its own.
1020//
1021// The named address may already be a plain member (an accepted invite always
1022// grants member) — then they are promoted, rather than the account gaining a
1023// second row for the same person.
1024func (s *Store) AdoptManifestOwner(accountID int64, address string, now time.Time) (bool, error) {
1025 tx, err := s.db.Begin()
1026 if err != nil {
1027 return false, err
1028 }
1029 defer tx.Rollback()
1030 // Re-counted INSIDE the transaction rather than trusting HasOwner: that one
1031 // is an optimization, this one is the rule, and a concurrent `console member
1032 // -add … -role owner` must not be able to slip between the two.
1033 var owners int
1034 if err := tx.QueryRow(`SELECT COUNT(*) FROM memberships WHERE account_id = ? AND role = ?`,
1035 accountID, RoleOwner).Scan(&owners); err != nil {
1036 return false, err
1037 }
1038 if owners > 0 {
1039 return false, nil
1040 }
1041 if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at)
1042VALUES (?, ?, ?, ?)
1043ON CONFLICT(account_id, address) DO UPDATE SET role = excluded.role`,
1044 accountID, address, RoleOwner, now.Unix()); err != nil {
1045 return false, err
1046 }
1047 if err := tx.Commit(); err != nil {
1048 return false, err
1049 }
1050 return true, nil
1051}
1052
1053// AccountBySqid returns the account with that public identity, or (nil, nil)
1054// when there is none — "no such account" is an answer, not an error, and the
1055// caller renders it as the same 404 a nonexistent page gets.
1056//
1057// A marked (deleted) row is filtered out here: a tombstoned account must be
1058// invisible everywhere a console door looks one up, exactly like a row that
1059// was never inserted. AccountAnyBySqid is the one exception, and it exists
1060// for a different question — "has this sqid ever been seen" — not this one.
1061func (s *Store) AccountBySqid(sqid string) (*Account, error) {
1062 var a Account
1063 var created int64
1064 err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE sqid = ? AND deleted_at = 0`, sqid).
1065 Scan(&a.ID, &a.Sqid, &a.Name, &created)
1066 if err == sql.ErrNoRows {
1067 return nil, nil
1068 }
1069 if err != nil {
1070 return nil, err
1071 }
1072 a.CreatedAt = time.Unix(created, 0).UTC()
1073 return &a, nil
1074}
1075
1076// AccountByID is AccountBySqid keyed the other way, for the MRU landing.
1077func (s *Store) AccountByID(id int64) (*Account, error) {
1078 var a Account
1079 var created int64
1080 err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE id = ? AND deleted_at = 0`, id).
1081 Scan(&a.ID, &a.Sqid, &a.Name, &created)
1082 if err == sql.ErrNoRows {
1083 return nil, nil
1084 }
1085 if err != nil {
1086 return nil, err
1087 }
1088 a.CreatedAt = time.Unix(created, 0).UTC()
1089 return &a, nil
1090}
1091
1092// AccountAnyBySqid is AccountBySqid WITHOUT the deleted_at filter — it sees a
1093// marked row instead of pretending it does not exist. deleted reports which
1094// case this was, so the account struct itself needs no new field for it.
1095//
1096// The callers are materialization and the /domains doors' namespace check
1097// (otherAccountsNamespace, which reserves a deleted account's namespace too).
1098// Materialization is why it exists: the whole point of keeping a tombstoned
1099// row (see DeleteAccount) is that the pass which decides whether to re-birth
1100// an account from its bucket manifest must be able to tell "never seen" from
1101// "seen, and deleted". Every other door in the console asks the filtered
1102// question instead.
1103func (s *Store) AccountAnyBySqid(sqid string) (*Account, bool, error) {
1104 var a Account
1105 var created, deletedAt int64
1106 err := s.db.QueryRow(`SELECT id, sqid, name, created_at, deleted_at FROM accounts WHERE sqid = ?`, sqid).
1107 Scan(&a.ID, &a.Sqid, &a.Name, &created, &deletedAt)
1108 if err == sql.ErrNoRows {
1109 return nil, false, nil
1110 }
1111 if err != nil {
1112 return nil, false, err
1113 }
1114 a.CreatedAt = time.Unix(created, 0).UTC()
1115 return &a, deletedAt != 0, nil
1116}
1117
1118// AccountsFor returns the accounts address is a member of, in creation order
1119// — the switcher's list, and the "your first membership" fallback when there
1120// is no MRU history. Ordered by created_at then id so two accounts created in
1121// the same second still order deterministically.
1122func (s *Store) AccountsFor(address string) ([]Account, error) {
1123 rows, err := s.db.Query(`SELECT a.id, a.sqid, a.name, a.created_at, m.role
1124FROM accounts a JOIN memberships m ON m.account_id = a.id
1125WHERE m.address = ? AND a.deleted_at = 0 ORDER BY a.created_at, a.id`, address)
1126 if err != nil {
1127 return nil, err
1128 }
1129 defer rows.Close()
1130 var out []Account
1131 for rows.Next() {
1132 var a Account
1133 var created int64
1134 if err := rows.Scan(&a.ID, &a.Sqid, &a.Name, &created, &a.Role); err != nil {
1135 return nil, err
1136 }
1137 a.CreatedAt = time.Unix(created, 0).UTC()
1138 out = append(out, a)
1139 }
1140 return out, rows.Err()
1141}
1142
1143// Accounts lists every live account — the economics pass's iteration set (an
1144// account with no statements yet must still appear in the census). Filtered
1145// by deleted_at like every other account reader (AccountBySqid, AccountsFor):
1146// a retired account stops accruing usage the same tick it stops billing.
1147func (s *Store) Accounts() ([]Account, error) {
1148 rows, err := s.db.Query(`SELECT id, sqid, name, created_at FROM accounts
1149WHERE deleted_at = 0 ORDER BY name`)
1150 if err != nil {
1151 return nil, err
1152 }
1153 defer rows.Close()
1154 var out []Account
1155 for rows.Next() {
1156 var a Account
1157 var created int64
1158 if err := rows.Scan(&a.ID, &a.Sqid, &a.Name, &created); err != nil {
1159 return nil, err
1160 }
1161 a.CreatedAt = time.Unix(created, 0).UTC()
1162 out = append(out, a)
1163 }
1164 return out, rows.Err()
1165}
1166
1167// validAccountTiers is the fixed set SetAccountTier accepts — "" (never
1168// assigned) plus economics-v2's fleet-pool tiers. Kept as a package-level
1169// set rather than inlined so a future consumer (Task 5's placement pass) can
1170// range over the same set without duplicating it.
1171var validAccountTiers = map[string]bool{
1172 "": true,
1173 "try": true,
1174 "pool": true,
1175 "hosted": true,
1176 "fleet": true,
1177 "dedicated": true,
1178}
1179
1180// SetAccountTier records accountID's fleet-pool tier (economics-v2, Task 4)
1181// — Task 5's placement pass reads it back via AccountTiers. Rejects any
1182// value outside validAccountTiers with a clear error rather than writing an
1183// unrecognized string a downstream placement pass would silently ignore. An
1184// accountID matching no row is a silent no-op (the UPDATE simply touches
1185// zero rows) — this store's existing convention for a keyed UPDATE with no
1186// distinguishable "not found" case (see ReapAccount's deleted_at-guarded
1187// UPDATE).
1188func (s *Store) SetAccountTier(accountID int64, tier string) error {
1189 if !validAccountTiers[tier] {
1190 return fmt.Errorf("console: invalid account tier %q", tier)
1191 }
1192 _, err := s.db.Exec(`UPDATE accounts SET tier = ? WHERE id = ?`, tier, accountID)
1193 return err
1194}
1195
1196// AccountTiers returns every live account's tier keyed by sqid — Task 5's
1197// placement pass input. Filtered by deleted_at like Accounts(): a retired
1198// account carries no tier into fleet-pool placement, same posture as it
1199// carrying no usage into the economics pass.
1200func (s *Store) AccountTiers() (map[string]string, error) {
1201 rows, err := s.db.Query(`SELECT sqid, tier FROM accounts WHERE deleted_at = 0`)
1202 if err != nil {
1203 return nil, err
1204 }
1205 defer rows.Close()
1206 out := map[string]string{}
1207 for rows.Next() {
1208 var sqid, tier string
1209 if err := rows.Scan(&sqid, &tier); err != nil {
1210 return nil, err
1211 }
1212 out[sqid] = tier
1213 }
1214 return out, rows.Err()
1215}
1216
1217// Role is address's role in the account, "" when they are not a member. It is
1218// the one permission check every account-scoped route makes.
1219func (s *Store) Role(accountID int64, address string) (string, error) {
1220 var role string
1221 err := s.db.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`,
1222 accountID, address).Scan(&role)
1223 if err == sql.ErrNoRows {
1224 return "", nil
1225 }
1226 if err != nil {
1227 return "", err
1228 }
1229 return role, nil
1230}
1231
1232// Members lists an account's memberships, owners first then by address.
1233func (s *Store) Members(accountID int64) ([]Account, error) {
1234 rows, err := s.db.Query(`SELECT address, role FROM memberships WHERE account_id = ?
1235ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, address`, accountID)
1236 if err != nil {
1237 return nil, err
1238 }
1239 defer rows.Close()
1240 var out []Account
1241 for rows.Next() {
1242 var a Account
1243 if err := rows.Scan(&a.Name, &a.Role); err != nil { // Name carries the address here
1244 return nil, err
1245 }
1246 a.ID = accountID
1247 out = append(out, a)
1248 }
1249 return out, rows.Err()
1250}
1251
1252// AddMember adds or re-roles a member. Demoting the LAST owner is refused for
1253// the same reason removing them is: it leaves an account nobody can
1254// administer. Transfer ownership first.
1255func (s *Store) AddMember(accountID int64, address, role string, now time.Time) error {
1256 if role != RoleOwner && role != RoleMember {
1257 return fmt.Errorf("console: invalid role %q", role)
1258 }
1259 tx, err := s.db.Begin()
1260 if err != nil {
1261 return err
1262 }
1263 defer tx.Rollback()
1264 if role != RoleOwner {
1265 if err := lastOwnerGuard(tx, accountID, address); err != nil {
1266 return err
1267 }
1268 }
1269 if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at)
1270VALUES (?, ?, ?, ?)
1271ON CONFLICT(account_id, address) DO UPDATE SET role = excluded.role`,
1272 accountID, address, role, now.Unix()); err != nil {
1273 return err
1274 }
1275 return tx.Commit()
1276}
1277
1278// RemoveMember drops a membership, refusing to remove the last owner.
1279func (s *Store) RemoveMember(accountID int64, address string) error {
1280 tx, err := s.db.Begin()
1281 if err != nil {
1282 return err
1283 }
1284 defer tx.Rollback()
1285 if err := lastOwnerGuard(tx, accountID, address); err != nil {
1286 return err
1287 }
1288 if _, err := tx.Exec(`DELETE FROM memberships WHERE account_id = ? AND address = ?`,
1289 accountID, address); err != nil {
1290 return err
1291 }
1292 return tx.Commit()
1293}
1294
1295// lastOwnerGuard refuses a change that would leave an account with no owner.
1296// Inside the caller's transaction, so the count and the write cannot be
1297// separated by a concurrent second removal.
1298func lastOwnerGuard(tx *sql.Tx, accountID int64, address string) error {
1299 var role string
1300 err := tx.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`,
1301 accountID, address).Scan(&role)
1302 if err == sql.ErrNoRows {
1303 return nil // not a member: nothing to protect
1304 }
1305 if err != nil {
1306 return err
1307 }
1308 if role != RoleOwner {
1309 return nil
1310 }
1311 var owners int
1312 if err := tx.QueryRow(`SELECT COUNT(*) FROM memberships WHERE account_id = ? AND role = ?`,
1313 accountID, RoleOwner).Scan(&owners); err != nil {
1314 return err
1315 }
1316 if owners <= 1 {
1317 return fmt.Errorf("console: %s is the last owner of this account — transfer ownership first", address)
1318 }
1319 return nil
1320}
1321
1322// SetLastAccount records where a user was last, so signing in lands them
1323// back there. Upserts the users row: an invited address that has never
1324// signed in still gets a landing spot.
1325func (s *Store) SetLastAccount(address string, accountID int64) error {
1326 _, err := s.db.Exec(`INSERT INTO users (address, created_at, last_account) VALUES (?, ?, ?)
1327ON CONFLICT(address) DO UPDATE SET last_account = excluded.last_account`,
1328 address, time.Now().Unix(), accountID)
1329 return err
1330}
1331
1332// LastAccount is the account id a user was last in, 0 when there is no
1333// history (a fresh user, or one whose only account was deleted).
1334func (s *Store) LastAccount(address string) (int64, error) {
1335 var id int64
1336 err := s.db.QueryRow(`SELECT last_account FROM users WHERE address = ?`, address).Scan(&id)
1337 if err == sql.ErrNoRows {
1338 return 0, nil
1339 }
1340 if err != nil {
1341 return 0, err
1342 }
1343 return id, nil
1344}
1345
1346// CreateInvite mints a single-use, 7-day invitation for address, returning
1347// the token the sealed Keymail link carries.
1348func (s *Store) CreateInvite(accountID int64, address, invitedBy string, now time.Time) (string, error) {
1349 tok, err := randomToken()
1350 if err != nil {
1351 return "", err
1352 }
1353 _, err = s.db.Exec(`INSERT INTO invites (token, account_id, address, invited_by, created_at, expires_at)
1354VALUES (?, ?, ?, ?, ?, ?)`,
1355 tok, accountID, address, invitedBy, now.Unix(), now.Add(InviteTTL).Unix())
1356 if err != nil {
1357 return "", err
1358 }
1359 return tok, nil
1360}
1361
1362// PendingInviteFor reads an invite without consuming it, behind the same
1363// three gates AcceptInvite applies: unaccepted, addressed to address, and
1364// unexpired. Any failure is one error, so the confirm page that shows it can
1365// answer every refusal the same way.
1366func (s *Store) PendingInviteFor(token, address string, now time.Time) (*Invite, error) {
1367 var i Invite
1368 var created, expires, accepted int64
1369 err := s.db.QueryRow(`SELECT token, account_id, address, invited_by, created_at, expires_at, accepted_at
1370FROM invites WHERE token = ?`, token).Scan(&i.Token, &i.AccountID, &i.Address, &i.InvitedBy, &created, &expires, &accepted)
1371 if err != nil {
1372 return nil, err
1373 }
1374 if accepted != 0 || i.Address != address || now.Unix() > expires {
1375 return nil, fmt.Errorf("console: that invite cannot be used by %s", address)
1376 }
1377 i.CreatedAt = time.Unix(created, 0).UTC()
1378 i.ExpiresAt = time.Unix(expires, 0).UTC()
1379 return &i, nil
1380}
1381
1382// PendingInviteAddress is the address an invite was sent to, read without
1383// consuming it, and only while the invite is unaccepted and unexpired: the
1384// signed-out invite page fills it into the sign-in form.
1385func (s *Store) PendingInviteAddress(token string, now time.Time) (string, bool) {
1386 var addr string
1387 var expires, accepted int64
1388 err := s.db.QueryRow(`SELECT address, expires_at, accepted_at FROM invites WHERE token = ?`,
1389 token).Scan(&addr, &expires, &accepted)
1390 if err != nil || accepted != 0 || now.Unix() > expires {
1391 return "", false
1392 }
1393 return addr, true
1394}
1395
1396// AcceptInvite consumes an invite and creates the membership, in one
1397// transaction. Three gates, all inside it: the token must be unaccepted, the
1398// address must be the one it was addressed to (an invite is not a bearer
1399// token — the link lands in ONE keymail inbox and only that identity can use
1400// it), and it must not have expired.
1401func (s *Store) AcceptInvite(token, address string, now time.Time) (int64, error) {
1402 tx, err := s.db.Begin()
1403 if err != nil {
1404 return 0, err
1405 }
1406 defer tx.Rollback()
1407 var accountID, expires, accepted int64
1408 var want string
1409 err = tx.QueryRow(`SELECT account_id, address, expires_at, accepted_at FROM invites WHERE token = ?`,
1410 token).Scan(&accountID, &want, &expires, &accepted)
1411 if err == sql.ErrNoRows {
1412 return 0, fmt.Errorf("console: no such invite")
1413 }
1414 if err != nil {
1415 return 0, err
1416 }
1417 if accepted != 0 {
1418 return 0, fmt.Errorf("console: that invite has already been used")
1419 }
1420 if want != address {
1421 return 0, fmt.Errorf("console: that invite was sent to a different address")
1422 }
1423 if now.Unix() > expires {
1424 return 0, fmt.Errorf("console: that invite has expired")
1425 }
1426 if _, err := tx.Exec(`UPDATE invites SET accepted_at = ? WHERE token = ?`, now.Unix(), token); err != nil {
1427 return 0, err
1428 }
1429 // An invite always grants member; owners are promoted deliberately.
1430 if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at)
1431VALUES (?, ?, ?, ?) ON CONFLICT(account_id, address) DO NOTHING`,
1432 accountID, address, RoleMember, now.Unix()); err != nil {
1433 return 0, err
1434 }
1435 if err := tx.Commit(); err != nil {
1436 return 0, err
1437 }
1438 return accountID, nil
1439}
1440
1441// RevokeInvite deletes a pending invite. Deleting rather than flagging is
1442// deliberate: a revoked invite must be indistinguishable from one that never
1443// existed, so a leaked link reveals nothing about the account.
1444func (s *Store) RevokeInvite(token string) error {
1445 _, err := s.db.Exec(`DELETE FROM invites WHERE token = ?`, token)
1446 return err
1447}
1448
1449// CountPendingInvites counts an account's unaccepted invitations that have
1450// not expired at now: the ones handleInvite's cap is about.
1451func (s *Store) CountPendingInvites(accountID int64, now time.Time) (int, error) {
1452 var n int
1453 err := s.db.QueryRow(`SELECT COUNT(*) FROM invites WHERE account_id = ? AND accepted_at = 0 AND expires_at >= ?`,
1454 accountID, now.Unix()).Scan(&n)
1455 return n, err
1456}
1457
1458// PendingInvites lists an account's unaccepted, unexpired invitations.
1459func (s *Store) PendingInvites(accountID int64) ([]Invite, error) {
1460 rows, err := s.db.Query(`SELECT token, account_id, address, invited_by, created_at, expires_at
1461FROM invites WHERE account_id = ? AND accepted_at = 0 ORDER BY created_at, address`, accountID)
1462 if err != nil {
1463 return nil, err
1464 }
1465 defer rows.Close()
1466 var out []Invite
1467 for rows.Next() {
1468 var i Invite
1469 var created, expires int64
1470 if err := rows.Scan(&i.Token, &i.AccountID, &i.Address, &i.InvitedBy, &created, &expires); err != nil {
1471 return nil, err
1472 }
1473 i.CreatedAt = time.Unix(created, 0).UTC()
1474 i.ExpiresAt = time.Unix(expires, 0).UTC()
1475 out = append(out, i)
1476 }
1477 return out, rows.Err()
1478}
1479
1480// ReapAccount is materialization's verb for a tombstoned bucket manifest
1481// found on a LATER pass than the one that first materialized the account
1482// (see MaterializeAccounts §8 of the deletion-policy spec): memberships,
1483// invites and apps rows are deleted outright — this account's authority and
1484// its name-reservations are gone — but the accounts ROW itself is marked,
1485// never removed, for the identical reason DeleteAccount marks it: deleting
1486// the row forgets the sqid was ever seen, and a console that forgets is one
1487// where a rewritten manifest un-tombstoning itself would re-birth the
1488// account, same id, same sqid, with whoever wrote the manifest as owner. A
1489// marked row closes that: AccountAnyBySqid always sees it, and nothing ever
1490// re-materializes over it.
1491//
1492// An account tombstoned while still holding app rows was tombstoned by
1493// something other than the console's own guarded path (DeleteAccount refuses
1494// while apps remain) — so those rows are reaped with it rather than left
1495// stranded pointing at a dead account.
1496//
1497// One transaction: a crash partway through must never leave memberships live
1498// against an account whose row is already marked, which would be access with
1499// nothing left to be a member OF.
1500func (s *Store) ReapAccount(accountID int64, now time.Time) error {
1501 if accountID == OpsAccountID {
1502 return fmt.Errorf("console: the ops account cannot be reaped")
1503 }
1504 tx, err := s.db.Begin()
1505 if err != nil {
1506 return err
1507 }
1508 defer tx.Rollback()
1509 for _, stmt := range []string{
1510 `DELETE FROM memberships WHERE account_id = ?`,
1511 `DELETE FROM invites WHERE account_id = ?`,
1512 `DELETE FROM apps WHERE account_id = ?`,
1513 } {
1514 if _, err := tx.Exec(stmt, accountID); err != nil {
1515 return err
1516 }
1517 }
1518 // Email history and settings are keyed by sqid (mailevents.go).
1519 var sqid string
1520 switch err := tx.QueryRow(`SELECT sqid FROM accounts WHERE id = ?`, accountID).Scan(&sqid); {
1521 case err == nil:
1522 if err := forgetMailAccount(context.Background(), tx, sqid); err != nil {
1523 return err
1524 }
1525 case err != sql.ErrNoRows:
1526 return err
1527 }
1528 if _, err := tx.Exec(`UPDATE users SET last_account = 0 WHERE last_account = ?`, accountID); err != nil {
1529 return err
1530 }
1531 if _, err := tx.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ? AND deleted_at = 0`,
1532 now.Unix(), accountID); err != nil {
1533 return err
1534 }
1535 return tx.Commit()
1536}
1537
1538// ---- apps (claim-once names) ------------------------------------------------
1539
1540// ErrTemporaryNameReserved refuses a permanent name in the tmp- namespace
1541// (temporary static sites spec ruling 7). `console grant` prints it to an
1542// operator, so it is written as a sentence.
1543var ErrTemporaryNameReserved = errors.New("names starting with tmp- belong to temporary apps. A grant, a rename or a permanent claim cannot create one")
1544
1545// OpsAccountID is the built-in operator account's id, pinned by registry
1546// migration on every deployment (its sqid is release.OpsAccount).
1547const OpsAccountID = 1
1548
1549// CreateApp claims an app name in the built-in ops account — the shape the
1550// account-less /apps routes still use. CreateAppIn is the account-aware verb.
1551func (s *Store) CreateApp(app, createdBy string, now time.Time) (created bool, err error) {
1552 return s.CreateAppIn(OpsAccountID, app, createdBy, now)
1553}
1554
1555// CreateAppIn claims an app name WITHIN an account, once. created is false
1556// when that account already has the name — another account holding it is not
1557// a conflict, because app names are unique per account, never globally.
1558func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Time) (created bool, err error) {
1559 return s.CreateAppInRegion(accountID, app, createdBy, "", "", now)
1560}
1561
1562// CreateAppInRegion claims an app name with its region in the SAME insert:
1563// an app region has no repair verb, so a separate best-effort write that
1564// failed would leave the app at the home for good, silently (spec §5.3).
1565func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error) {
1566 res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source)
1567VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
1568 app, accountID, createdBy, now.Unix(), region, source)
1569 if err != nil {
1570 return false, err
1571 }
1572 n, err := res.RowsAffected()
1573 return n > 0, err
1574}
1575
1576// AppRegion reads an app's region and its source label. A missing row
1577// reads as the home with no source.
1578func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error) {
1579 err = s.db.QueryRow(`SELECT region, region_source FROM apps WHERE account_id = ? AND app = ?`, accountID, app).
1580 Scan(&region, &source)
1581 if errors.Is(err, sql.ErrNoRows) {
1582 return "", "", nil
1583 }
1584 return region, source, err
1585}
1586
1587// nonHomeAppRegion is one live app holding a non-home region, for the
1588// roll-forward discrepancy log (spec §5.7).
1589type nonHomeAppRegion struct {
1590 Sqid, App, Region string
1591 CreatedAt time.Time
1592}
1593
1594func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error) {
1595 rows, err := s.db.Query(`SELECT a.sqid, p.app, p.region, p.created_at FROM apps p
1596JOIN accounts a ON a.id = p.account_id
1597WHERE p.region != '' AND p.deleted_at = 0 ORDER BY a.sqid, p.app`)
1598 if err != nil {
1599 return nil, err
1600 }
1601 defer rows.Close()
1602 var out []nonHomeAppRegion
1603 for rows.Next() {
1604 var r nonHomeAppRegion
1605 var created int64
1606 if err := rows.Scan(&r.Sqid, &r.App, &r.Region, &created); err != nil {
1607 return nil, err
1608 }
1609 r.CreatedAt = time.Unix(created, 0)
1610 out = append(out, r)
1611 }
1612 return out, rows.Err()
1613}
1614
1615// AppsIn lists an account's claimed app names, sorted. Trashed apps are
1616// filtered out — every door this feeds (API resolution, ship claim, account
1617// page listings) is a door a trashed app must vanish from automatically,
1618// without each caller having to remember to ask. TrashedAppsIn is the
1619// dedicated listing for the Trash page itself.
1620func (s *Store) AppsIn(accountID int64) ([]string, error) {
1621 rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND deleted_at = 0 ORDER BY app`, accountID)
1622 if err != nil {
1623 return nil, err
1624 }
1625 defer rows.Close()
1626 var out []string
1627 for rows.Next() {
1628 var a string
1629 if err := rows.Scan(&a); err != nil {
1630 return nil, err
1631 }
1632 out = append(out, a)
1633 }
1634 return out, rows.Err()
1635}
1636
1637// HasAppIn reports whether an account already claims a LIVE app name.
1638// Filtered the same way as AppsIn: a trashed name still occupies the row (see
1639// CreateAppIn / the name-reservation rule) but must read as absent to a caller
1640// asking "is this name in use right now".
1641func (s *Store) HasAppIn(accountID int64, app string) (bool, error) {
1642 var n int
1643 err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ? AND deleted_at = 0`,
1644 accountID, app).Scan(&n)
1645 return n > 0, err
1646}
1647
1648// SetAppPlacement stamps an app's placement fact — the store-of-record
1649// fleets.go's appPlacement consults FIRST, ahead of the older route-derived
1650// fallback (2026-08-06 ruling). target "" clears it back to unplaced. The
1651// row is guaranteed to exist here: every caller reaches this only after
1652// apiAccountApp's HasAppIn gate (place) or a just-succeeded CreateAppIn
1653// (create-with-place), so this is a plain UPDATE, never an upsert.
1654func (s *Store) claimRawSetAppPlacement(accountID int64, app, target string) error {
1655 _, err := s.db.Exec(`UPDATE apps SET placement = ? WHERE account_id = ? AND app = ?`,
1656 target, accountID, app)
1657 return err
1658}
1659
1660// AppFlags reads the two per-app deployment facts the control-plane work
1661// added: whether this app IS the control plane, and the path `carlos deploy`
1662// should poll for a build the app's own process reports.
1663//
1664// One query for both because both doors that read them read them together
1665// (the ship gate and the deploy watch), and a second round trip to the same
1666// row buys nothing.
1667func (s *Store) AppFlags(accountID int64, app string) (controlPlane bool, buildProbe string, err error) {
1668 err = s.db.QueryRow(`SELECT control_plane, build_probe FROM apps WHERE account_id = ? AND app = ?`,
1669 accountID, app).Scan(&controlPlane, &buildProbe)
1670 if errors.Is(err, sql.ErrNoRows) {
1671 // No row is not an error here: every caller has already been through
1672 // apiAccountApp's byte-identical 404, so reaching this with no row
1673 // means a race with a purge, and "not the control plane, no probe" is
1674 // the right reading of an app that no longer exists.
1675 return false, "", nil
1676 }
1677 return controlPlane, buildProbe, err
1678}
1679
1680// SetAppControlPlane marks (or unmarks) an app as the control plane. Marking
1681// refuses a temporary row (ErrTemporaryApp): the flag refuses destruction,
1682// and a temporary app exists to be destroyed (temporary static sites §4).
1683// The row is guaranteed to exist: both callers reach this straight after a
1684// successful CreateAppIn or behind apiAccountApp's gate.
1685func (s *Store) claimRawSetAppControlPlane(accountID int64, app string, on bool) error {
1686 if !on {
1687 _, err := s.db.Exec(`UPDATE apps SET control_plane = 0 WHERE account_id = ? AND app = ?`, accountID, app)
1688 return err
1689 }
1690 res, err := s.db.Exec(`UPDATE apps SET control_plane = 1 WHERE account_id = ? AND app = ? AND expires_at = 0`, accountID, app)
1691 if err != nil {
1692 return err
1693 }
1694 if n, _ := res.RowsAffected(); n == 0 {
1695 if l, err := s.AppLife(accountID, app); err == nil && l != nil && l.Temporary() {
1696 return ErrTemporaryApp
1697 }
1698 }
1699 return nil
1700}
1701
1702// SetAppBuildProbe stamps the deploy watch's probe path. "" clears it back to
1703// header-watching.
1704func (s *Store) claimRawSetAppBuildProbe(accountID int64, app, path string) error {
1705 _, err := s.db.Exec(`UPDATE apps SET build_probe = ? WHERE account_id = ? AND app = ?`,
1706 path, accountID, app)
1707 return err
1708}
1709
1710// AppPlacement reads an app's stored placement fact: "" for unplaced (or a
1711// row this column predates — the ALTER above defaults every existing row to
1712// "", indistinguishable from "never placed", which is exactly right: the
1713// caller's route-derived fallback is what recovers a real historical
1714// placement for those rows). A missing row (an app appPlacement is asked
1715// about that this store has no claim for at all) also reads as "", not an
1716// error — the same "absence is un-placed" answer, for a caller that has
1717// already proven the row exists as much as one that hasn't.
1718func (s *Store) AppPlacement(accountID int64, app string) (string, error) {
1719 var placement string
1720 err := s.db.QueryRow(`SELECT placement FROM apps WHERE account_id = ? AND app = ?`, accountID, app).
1721 Scan(&placement)
1722 if errors.Is(err, sql.ErrNoRows) {
1723 return "", nil
1724 }
1725 return placement, err
1726}
1727
1728// TrashAppRow marks an app trashed. Trashing twice keeps the FIRST timestamp
1729// (guarded by `deleted_at = 0`) — the cooloff clock starts once, and a second
1730// trash call (racing UI, a retried request) must not push the purge deadline
1731// out again.
1732//
1733// now is the MARKER's clock at every caller. A temporary row (expires_at > 0)
1734// also gets its early-purge window in the same statement: purge_for is the
1735// marker's deleted_at and purge_after is 3 days later (temporary static sites
1736// spec §5). The window is bound to the marker's clock and to whether the row
1737// was temporary when the marker was found; a permanent row gets 0 for both.
1738func (s *Store) claimRawTrashAppRow(accountID int64, app string, now time.Time) error {
1739 at := now.Unix()
1740 _, err := s.db.Exec(`UPDATE apps SET deleted_at = ?,
1741 purge_for = CASE WHEN expires_at > 0 THEN ? ELSE 0 END,
1742 purge_after = CASE WHEN expires_at > 0 THEN ? ELSE 0 END
1743WHERE account_id = ? AND app = ? AND deleted_at = 0`,
1744 at, at, at+temporaryCooloffSeconds, accountID, app)
1745 return err
1746}
1747
1748// RestoreAppRow un-trashes an app and clears everything that only means
1749// something while trashed (restoring, the early-purge window). Restoring
1750// something that was never trashed is harmless.
1751func (s *Store) RestoreAppRow(accountID int64, app string) error {
1752 _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0
1753WHERE account_id = ? AND app = ?`, accountID, app)
1754 return err
1755}
1756
1757// PurgeAppRow deletes the row outright — the one place trash state actually
1758// ends, and the only irreversible verb in this set. The row IS the name
1759// reservation (see CreateAppIn), so this is also the moment the name frees
1760// for reuse.
1761//
1762// Guarded by `deleted_at != 0`: a live row must be structurally unpurgeable,
1763// because the console row and the bucket marker can diverge (a restore wrote
1764// the row back while a marker delete failed elsewhere, or raced it) and the
1765// auto-purge pass this feeds must never be the thing that frees a serving
1766// app's name out from under it.
1767func (s *Store) PurgeAppRow(accountID int64, app string) error {
1768 ctx := context.Background()
1769 tx, err := s.db.BeginTx(ctx, nil)
1770 if err != nil {
1771 return err
1772 }
1773 defer tx.Rollback()
1774 res, err := tx.ExecContext(ctx, `DELETE FROM apps WHERE account_id = ? AND app = ? AND deleted_at != 0`, accountID, app)
1775 if err != nil {
1776 return err
1777 }
1778 if n, _ := res.RowsAffected(); n > 0 {
1779 // The name is free from here, so nothing keyed by it may outlive the
1780 // row (mailevents.go): a later app of the same name starts clean.
1781 var sqid string
1782 if err := tx.QueryRowContext(ctx, `SELECT sqid FROM accounts WHERE id = ?`, accountID).Scan(&sqid); err == nil {
1783 if err := forgetMailApp(ctx, tx, sqid, app); err != nil {
1784 return err
1785 }
1786 } else if err != sql.ErrNoRows {
1787 return err
1788 }
1789 // The same for the app's credentials (discussion #219, item 1). App
1790 // tokens of both kinds are bound (account, name), so a later app of
1791 // this name would inherit them unminted; they are DELETED rather than
1792 // revoked, because RenameAppIn and MoveApp refuse a name that still
1793 // carries any such row, revoked or not. Door keys are revoked rather
1794 // than deleted: the revision has to keep climbing past what boxes
1795 // already admitted, and the re-sign pass publishes the now-empty
1796 // record (apidoorkeys.go).
1797 if _, err := tx.ExecContext(ctx, `DELETE FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`,
1798 cliKindApp, cliKindService, accountID, app); err != nil {
1799 return err
1800 }
1801 if err := revokeAllDoorKeys(tx, accountID, app); err != nil {
1802 return err
1803 }
1804 }
1805 return tx.Commit()
1806}
1807
1808// TrashedApp is one app in an account's Trash: past-tense on purpose, since a
1809// trashed row is inert until restored or purged.
1810type TrashedApp struct {
1811 App string
1812 DeletedAt time.Time
1813 // PurgeAfter is the row's early-purge deadline (Unix seconds); 0 means
1814 // the app was not temporary when trashed, so effectivePurgeAt falls
1815 // back to the ordinary 30-day cooloff.
1816 PurgeAfter int64
1817}
1818
1819// TrashedAppsIn lists an account's trashed apps, sorted by name — the Trash
1820// page's listing, and the mirror image of AppsIn's "live only" filter.
1821func (s *Store) TrashedAppsIn(accountID int64) ([]TrashedApp, error) {
1822 rows, err := s.db.Query(`SELECT app, deleted_at, purge_after FROM apps WHERE account_id = ? AND deleted_at != 0 ORDER BY app`,
1823 accountID)
1824 if err != nil {
1825 return nil, err
1826 }
1827 defer rows.Close()
1828 var out []TrashedApp
1829 for rows.Next() {
1830 var t TrashedApp
1831 var deletedAt int64
1832 if err := rows.Scan(&t.App, &deletedAt, &t.PurgeAfter); err != nil {
1833 return nil, err
1834 }
1835 t.DeletedAt = time.Unix(deletedAt, 0).UTC()
1836 out = append(out, t)
1837 }
1838 return out, rows.Err()
1839}
1840
1841// AppNameState reports, in one read, whether an account holds app live, in
1842// the trash, or not at all — for a caller that must tell the three apart
1843// without a restore or a trash landing between two separate questions.
1844func (s *Store) AppNameState(accountID int64, app string) (live, trashed bool, err error) {
1845 var deletedAt int64
1846 err = s.db.QueryRow(`SELECT deleted_at FROM apps WHERE account_id = ? AND app = ?`, accountID, app).Scan(&deletedAt)
1847 if errors.Is(err, sql.ErrNoRows) {
1848 return false, false, nil
1849 }
1850 if err != nil {
1851 return false, false, err
1852 }
1853 return deletedAt == 0, deletedAt != 0, nil
1854}
1855
1856// HasTrashedAppIn reports whether an account holds a TRASHED row for app —
1857// the check the claim-once door uses to explain a refusal ("that name is in
1858// the trash") rather than just saying no.
1859func (s *Store) HasTrashedAppIn(accountID int64, app string) (bool, error) {
1860 var n int
1861 err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ? AND deleted_at != 0`,
1862 accountID, app).Scan(&n)
1863 return n > 0, err
1864}
1865
1866// AppRow is one apps row carrying its account's SQID — the coordinate the
1867// bucket is keyed by. The console's own tables key apps by account id, but
1868// every object belonging to an app lives under apps/<sqid>/<app>/, so a pass
1869// that compares rows against the control plane needs both.
1870type AppRow struct {
1871 AccountID int64
1872 Sqid string
1873 App string
1874 Deleted bool // trashed here (deleted_at != 0), not purged
1875 // The lifecycle columns (temporary static sites spec §2), Unix seconds.
1876 DeletedAt int64
1877 ExpiresAt int64 // 0 = permanent
1878 Restoring bool
1879 PurgeAfter int64 // 0 = no early purge
1880 PurgeFor int64
1881}
1882
1883// appRowCols is the SELECT list every AppRow query uses, in scanAppRows'
1884// order: p is apps, a is accounts.
1885const appRowCols = `p.account_id, a.sqid, p.app, p.deleted_at, p.expires_at, p.restoring, p.purge_after, p.purge_for`
1886
1887func scanAppRows(rows *sql.Rows) ([]AppRow, error) {
1888 defer rows.Close()
1889 var out []AppRow
1890 for rows.Next() {
1891 var r AppRow
1892 if err := rows.Scan(&r.AccountID, &r.Sqid, &r.App, &r.DeletedAt, &r.ExpiresAt, &r.Restoring, &r.PurgeAfter, &r.PurgeFor); err != nil {
1893 return nil, err
1894 }
1895 r.Deleted = r.DeletedAt != 0
1896 out = append(out, r)
1897 }
1898 return out, rows.Err()
1899}
1900
1901// AppRows lists every app row of every LIVE account, trashed ones included —
1902// the input to MaterializeApps, which is the one caller.
1903//
1904// It has to be row-driven rather than bucket-driven: a purged app has no keys
1905// left to list, so the only evidence it ever existed is the row still holding
1906// its name. The join drops rows belonging to a marked account, which ReapAccount
1907// already deletes — a straggler must never be converged against a dead
1908// account's prefix, and freeing its name is that account's business, not this
1909// pass's.
1910func (s *Store) AppRows() ([]AppRow, error) {
1911 rows, err := s.db.Query(`SELECT ` + appRowCols + `
1912FROM apps p JOIN accounts a ON a.id = p.account_id
1913WHERE a.deleted_at = 0 ORDER BY a.sqid, p.app`)
1914 if err != nil {
1915 return nil, err
1916 }
1917 return scanAppRows(rows)
1918}
1919
1920// AppAccounts lists the accounts that claim an app NAME, ascending. Usually
1921// one; several is legitimate (names are unique per account, not globally) and
1922// is what makes MoveApp require an explicit source.
1923func (s *Store) AppAccounts(app string) ([]int64, error) {
1924 rows, err := s.db.Query(`SELECT account_id FROM apps WHERE app = ? ORDER BY account_id`, app)
1925 if err != nil {
1926 return nil, err
1927 }
1928 defer rows.Close()
1929 var out []int64
1930 for rows.Next() {
1931 var id int64
1932 if err := rows.Scan(&id); err != nil {
1933 return nil, err
1934 }
1935 out = append(out, id)
1936 }
1937 return out, rows.Err()
1938}
1939
1940// MoveApp re-homes a claimed app from one account to another — the console
1941// half of `carlos accounts migrate`, which moves the bucket objects and the
1942// registry routes but deliberately never opens this database.
1943//
1944// Idempotent: if the row is already at the destination there is nothing to
1945// do, so an operator who is unsure whether the move landed can simply run it
1946// again. Moving onto a name the destination already claims is REFUSED rather
1947// than merged — those are two different apps, and one of them would vanish.
1948func (s *Store) MoveApp(app string, from, to int64) error {
1949 tx, err := s.db.Begin()
1950 if err != nil {
1951 return err
1952 }
1953 defer tx.Rollback()
1954 var atDest, atSrc int
1955 if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ? AND account_id = ?`, app, to).Scan(&atDest); err != nil {
1956 return err
1957 }
1958 if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ? AND account_id = ?`, app, from).Scan(&atSrc); err != nil {
1959 return err
1960 }
1961 if atDest > 0 {
1962 if atSrc > 0 {
1963 return fmt.Errorf("console: account %d already claims %q — moving onto it would merge two different apps", to, app)
1964 }
1965 return nil // already moved
1966 }
1967 if atSrc == 0 {
1968 return fmt.Errorf("console: account %d does not claim %q", from, app)
1969 }
1970 var srcExpires int64
1971 if err := tx.QueryRow(`SELECT expires_at FROM apps WHERE app = ? AND account_id = ?`, app, from).Scan(&srcExpires); err != nil {
1972 return err
1973 }
1974 if srcExpires > 0 {
1975 return ErrTemporaryApp
1976 }
1977 // App-scoped CLI tokens — legacy kind='app' AND kind='service' — are
1978 // bound (account, name), so they must follow the app or the running
1979 // deployment's credential 404s while retaining authority over the name
1980 // in the SOURCE account. Tokens already at the destination under this
1981 // name are a purged predecessor's leftovers — re-homing onto them would
1982 // hand this app a credential nobody minted for it, so that refuses,
1983 // exactly as RenameAppIn refuses. Both kinds must be counted here: a
1984 // service principal the guard cannot see is an orphaned credential a
1985 // LATER app of the same name inherits unminted (see
1986 // TestRenameAppMovesServicePrincipalToo for the RenameAppIn twin of this
1987 // hazard).
1988 var staleTokens int
1989 if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`,
1990 cliKindApp, cliKindService, to, app).Scan(&staleTokens); err != nil {
1991 return err
1992 }
1993 if staleTokens > 0 {
1994 return fmt.Errorf("console: the destination account still has app credentials for %q from a previous app — revoke them first", app)
1995 }
1996 // A second, narrower collision the guard above cannot see:
1997 // cli_tokens_service_name only requires a live kind='service' row's NAME
1998 // to be unique ACCOUNT-WIDE — it does not care whether the row is bound
1999 // to this app or to none at all (NewServicePrincipal's own uniqueness
2000 // check is the same shape). So a service principal travelling with `app`
2001 // can collide with an ACCOUNT-SCOPED credential already sitting at the
2002 // destination under the same name, and the guard above cannot catch that
2003 // — it only counts rows still bound to `app` there, and the colliding
2004 // row is not one of them. Left uncaught, the UPDATE below would trip the
2005 // unique index inside this transaction — safe (it rolls back) but with
2006 // SQLite's raw constraint-violation text rather than a sentence in this
2007 // guard's own voice — and a generic name like "hub" or "ci" makes it
2008 // reachable in practice.
2009 var collidingName string
2010 err = tx.QueryRow(`SELECT dst.name FROM cli_tokens dst
2011WHERE dst.kind = ? AND dst.account_id = ? AND dst.revoked_at = 0
2012AND dst.name IN (
2013 SELECT src.name FROM cli_tokens src
2014 WHERE src.kind = ? AND src.account_id = ? AND src.app = ? AND src.revoked_at = 0
2015)
2016LIMIT 1`, cliKindService, to, cliKindService, from, app).Scan(&collidingName)
2017 if err != nil && err != sql.ErrNoRows {
2018 return err
2019 }
2020 if err == nil {
2021 return fmt.Errorf("console: the destination account already holds a live service credential named %q — rename or revoke it first", collidingName)
2022 }
2023 var createdAt int64
2024 if err := tx.QueryRow(`SELECT created_at FROM apps WHERE app = ? AND account_id = ?`, app, from).Scan(&createdAt); err != nil {
2025 return err
2026 }
2027 if _, err := tx.Exec(`UPDATE apps SET account_id = ? WHERE app = ? AND account_id = ?`,
2028 to, app, from); err != nil {
2029 return err
2030 }
2031 if _, err := tx.Exec(`UPDATE cli_tokens SET account_id = ? WHERE kind IN (?, ?) AND account_id = ? AND app = ?`,
2032 to, cliKindApp, cliKindService, from, app); err != nil {
2033 return err
2034 }
2035 // Door keys cannot follow the app: each key names its account's sqid
2036 // (doorkey.Token), and the edge admits it only for routes of that account,
2037 // so the moved app's processes need new keys either way. What the move
2038 // must not leave behind is a LIVE key on either side (discussion #219,
2039 // item 1): the source's would be inherited by a later app of this name
2040 // there, and any at the destination are a purged predecessor's, which the
2041 // moved-in app would otherwise answer to. The destination has no row for
2042 // this name (checked above), so nothing live owns those keys.
2043 if err := revokeAllDoorKeys(tx, from, app); err != nil {
2044 return err
2045 }
2046 if err := revokeAllDoorKeys(tx, to, app); err != nil {
2047 return err
2048 }
2049 // Email history is keyed by the account's sqid, not its id, so it is
2050 // carried by sqid (mailevents.go).
2051 var fromSqid, toSqid string
2052 if err := tx.QueryRow(`SELECT sqid FROM accounts WHERE id = ?`, from).Scan(&fromSqid); err != nil {
2053 return err
2054 }
2055 if err := tx.QueryRow(`SELECT sqid FROM accounts WHERE id = ?`, to).Scan(&toSqid); err != nil {
2056 return err
2057 }
2058 if err := carryMailApp(context.Background(), tx, fromSqid, app, toSqid, app, createdAt); err != nil {
2059 return err
2060 }
2061 return tx.Commit()
2062}
2063
2064// RenameAppIn renames an app that has never deployed. The CALLER holds the
2065// wider refusals — nothing under the app's bucket prefix, no registry route
2066// wearing the name — because an app name is a literal key segment in both,
2067// and this database is the only store a rename may touch. Here the refusals
2068// are this database's own:
2069//
2070// - The new name must be free in this account, live or trashed — a trashed
2071// row still owns its name until purged, exactly as CreateAppIn treats it.
2072// - Neither name may appear in the legacy owners table. Those grants are
2073// keyed by bare name across ALL accounts, so rewriting them could hand
2074// one account's grant to another, and renaming ONTO one would let the new
2075// name inherit a grant nobody issued it. Claim-proof stays claim-proof.
2076// - The new name must carry no app-scoped CLI tokens — legacy kind='app'
2077// OR kind='service'. A purged app's tokens outlive its row, and a
2078// credential that suddenly binds to a NEW app is authority nobody
2079// minted.
2080//
2081// What follows the app: its audit rows — but only those written since THIS
2082// app's row was created, because a purged predecessor of the same name left
2083// its rows behind and its history is not this app's; and its app-scoped CLI
2084// tokens (both kinds), which are bound (account, name) and would otherwise
2085// 404 the running app while retaining authority over the freed name. The
2086// rename itself is the caller's audit line.
2087func (s *Store) RenameAppIn(accountID int64, from, to string) error {
2088 return s.renameAppIn(accountID, from, to, false)
2089}
2090
2091// UndoRenameAppIn reverses a rename that just landed (handleAppRename's
2092// post-commit re-check). It is the one path that may rename INTO the tmp-
2093// namespace, because it only ever puts back the name the app already had: a
2094// kept temporary app keeps its tmp- name, and renaming it away and then
2095// undoing must be able to land.
2096func (s *Store) UndoRenameAppIn(accountID int64, renamed, original string) error {
2097 return s.renameAppIn(accountID, renamed, original, true)
2098}
2099
2100func (s *Store) renameAppIn(accountID int64, from, to string, undo bool) error {
2101 if !undo && release.IsTemporaryName(to) {
2102 return ErrTemporaryNameReserved
2103 }
2104 tx, err := s.db.Begin()
2105 if err != nil {
2106 return err
2107 }
2108 defer tx.Rollback()
2109 var createdAt, expiresAt int64
2110 err = tx.QueryRow(`SELECT created_at, expires_at FROM apps WHERE account_id = ? AND app = ? AND deleted_at = 0`,
2111 accountID, from).Scan(&createdAt, &expiresAt)
2112 if err == sql.ErrNoRows {
2113 return fmt.Errorf("console: this account does not claim %q", from)
2114 } else if err != nil {
2115 return err
2116 }
2117 if expiresAt > 0 {
2118 return ErrTemporaryApp
2119 }
2120 var legacy int
2121 if err := tx.QueryRow(`SELECT COUNT(*) FROM owners WHERE app IN (?, ?)`, from, to).Scan(&legacy); err != nil {
2122 return err
2123 }
2124 if legacy > 0 {
2125 return fmt.Errorf("console: %q or %q carries a legacy owner grant — renaming across it is an operator act", from, to)
2126 }
2127 var taken int
2128 if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`, accountID, to).Scan(&taken); err != nil {
2129 return err
2130 }
2131 if taken > 0 {
2132 return fmt.Errorf("console: this account already has an app (or a trashed app) named %q", to)
2133 }
2134 var staleTokens int
2135 if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind IN (?, ?) AND account_id = ? AND app = ?`,
2136 cliKindApp, cliKindService, accountID, to).Scan(&staleTokens); err != nil {
2137 return err
2138 }
2139 if staleTokens > 0 {
2140 return fmt.Errorf("console: %q still has app credentials from a previous app of that name — revoke them first", to)
2141 }
2142 if _, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at = 0`,
2143 to, accountID, from); err != nil {
2144 return err
2145 }
2146 if _, err := tx.Exec(`UPDATE audit SET app = ? WHERE account_id = ? AND app = ? AND created_at >= ?`,
2147 to, accountID, from, createdAt); err != nil {
2148 return err
2149 }
2150 if _, err := tx.Exec(`UPDATE cli_tokens SET app = ? WHERE kind IN (?, ?) AND account_id = ? AND app = ?`,
2151 to, cliKindApp, cliKindService, accountID, from); err != nil {
2152 return err
2153 }
2154 var sqid string
2155 if err := tx.QueryRow(`SELECT sqid FROM accounts WHERE id = ?`, accountID).Scan(&sqid); err != nil {
2156 return err
2157 }
2158 if err := carryMailApp(context.Background(), tx, sqid, from, sqid, to, createdAt); err != nil {
2159 return err
2160 }
2161 return tx.Commit()
2162}
2163
2164// HasApp reports whether an app name is claimed in the apps table.
2165func (s *Store) HasApp(app string) (bool, error) {
2166 var n int
2167 err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ?`, app).Scan(&n)
2168 return n > 0, err
2169}
2170
2171// HasOwners reports whether ANY owner exists for app — CLI-granted apps
2172// predate the apps table and must stay claim-proof.
2173func (s *Store) HasOwners(app string) (bool, error) {
2174 var n int
2175 err := s.db.QueryRow(`SELECT COUNT(*) FROM owners WHERE app = ?`, app).Scan(&n)
2176 return n > 0, err
2177}
2178
2179// ---- users ------------------------------------------------------------------
2180
2181// EnsureUser records address as a console user if it isn't one yet. created
2182// reports whether this call was the sign-up.
2183func (s *Store) EnsureUser(address string, now time.Time) (created bool, err error) {
2184 res, err := s.db.Exec(`INSERT INTO users (address, created_at) VALUES (?, ?)
2185ON CONFLICT(address) DO NOTHING`, address, now.Unix())
2186 if err != nil {
2187 return false, err
2188 }
2189 n, err := res.RowsAffected()
2190 return n > 0, err
2191}
2192
2193// SetOperator flips address's fleet-operator bit — box-side only, exactly
2194// like an app grant: it works whether or not address has ever signed in,
2195// upserting a users row if none exists yet (leaving created_at at "now" for
2196// a brand-new row, untouched for an existing one).
2197func (s *Store) SetOperator(address string, on bool) error {
2198 v := 0
2199 if on {
2200 v = 1
2201 }
2202 _, err := s.db.Exec(`INSERT INTO users (address, created_at, operator) VALUES (?, ?, ?)
2203ON CONFLICT(address) DO UPDATE SET operator = excluded.operator`, address, time.Now().Unix(), v)
2204 return err
2205}
2206
2207// IsOperator reports whether address holds the fleet-operator bit. An
2208// address with no users row (never signed in) is not an operator.
2209func (s *Store) IsOperator(address string) (bool, error) {
2210 var v int
2211 err := s.db.QueryRow(`SELECT operator FROM users WHERE address = ?`, address).Scan(&v)
2212 if err == sql.ErrNoRows {
2213 return false, nil
2214 }
2215 if err != nil {
2216 return false, err
2217 }
2218 return v != 0, nil
2219}
2220
2221// Operators lists every address holding the fleet-operator bit, sorted —
2222// the alert watcher's recipient list. Sorted so a pass mails people in a
2223// stable order and a test can assert on it.
2224func (s *Store) Operators() ([]string, error) {
2225 rows, err := s.db.Query(`SELECT address FROM users WHERE operator != 0 ORDER BY address`)
2226 if err != nil {
2227 return nil, err
2228 }
2229 defer rows.Close()
2230 var out []string
2231 for rows.Next() {
2232 var a string
2233 if err := rows.Scan(&a); err != nil {
2234 return nil, err
2235 }
2236 out = append(out, a)
2237 }
2238 return out, rows.Err()
2239}
2240
2241// ---- alert watcher state (alertwatch.go) ------------------------------------
2242
2243// AlertSeen reports the firing state the console last NOTIFIED address
2244// about for (box, rule), and whether it has ever told them anything about
2245// that rule. known=false is not the same as firing=false: a rule this
2246// operator has never been told about fires as news, while one they were
2247// last told was quiet stays quiet.
2248//
2249// The empty address is the watcher's synthetic "nobody was mailable"
2250// recipient (alertwatch.go), not a real user.
2251func (s *Store) AlertSeen(box, rule, address string) (firing bool, known bool, err error) {
2252 var v int
2253 err = s.db.QueryRow(`SELECT firing FROM alert_seen WHERE box = ? AND rule = ? AND address = ?`,
2254 box, rule, address).Scan(&v)
2255 if err == sql.ErrNoRows {
2256 return false, false, nil
2257 }
2258 if err != nil {
2259 return false, false, err
2260 }
2261 return v != 0, true, nil
2262}
2263
2264// SetAlertSeen records that address has been told (box, rule) is in this
2265// state. Written per recipient, only after THAT recipient's send succeeded,
2266// so one dead grant leaves one row unrecorded and the next pass retries
2267// that operator alone.
2268func (s *Store) SetAlertSeen(box, rule, address string, firing bool) error {
2269 v := 0
2270 if firing {
2271 v = 1
2272 }
2273 _, err := s.db.Exec(`INSERT INTO alert_seen (box, rule, address, firing) VALUES (?, ?, ?, ?)
2274ON CONFLICT(box, rule, address) DO UPDATE SET firing = excluded.firing`, box, rule, address, v)
2275 return err
2276}
2277
2278// ---- move pass pages (movesrecovery.go) -------------------------------------
2279
2280// MovePageAttempt is when the move pass last attempted the page for rule,
2281// to the second, and whether it ever did.
2282func (s *Store) MovePageAttempt(rule string) (time.Time, bool, error) {
2283 var at int64
2284 err := s.db.QueryRow(`SELECT attempted_at FROM move_pages WHERE rule = ?`, rule).Scan(&at)
2285 if err == sql.ErrNoRows {
2286 return time.Time{}, false, nil
2287 }
2288 if err != nil {
2289 return time.Time{}, false, err
2290 }
2291 return time.Unix(at, 0).UTC(), true, nil
2292}
2293
2294// SetMovePageAttempt records an attempt at the page for rule at.
2295func (s *Store) SetMovePageAttempt(rule string, at time.Time) error {
2296 _, err := s.db.Exec(`INSERT INTO move_pages (rule, attempted_at) VALUES (?, ?)
2297ON CONFLICT(rule) DO UPDATE SET attempted_at = excluded.attempted_at`, rule, at.Unix())
2298 return err
2299}
2300
2301// MoveLogPendingEntry is one move log entry the move pass owes.
2302type MoveLogPendingEntry struct {
2303 Host, Op, Name, Incarnation string
2304 Body []byte
2305 QueuedAt time.Time
2306 Confirmed, Warned, Held bool
2307 Attempts int
2308}
2309
2310// QueueMoveLog keeps an entry for the drainer. An entry already queued under
2311// the name is kept as it was. An unconfirmed entry is not delivered until
2312// ConfirmMoveLog.
2313func (s *Store) QueueMoveLog(host, op, name, incarnation string, body []byte, confirmed bool, at time.Time) error {
2314 _, err := s.db.Exec(`INSERT INTO move_log_outbox (host, op, name, incarnation, body, queued_at, confirmed) VALUES (?, ?, ?, ?, ?, ?, ?)
2315ON CONFLICT(host, op, name) DO NOTHING`, host, op, name, incarnation, body, at.Unix(), confirmed)
2316 return err
2317}
2318
2319// ConfirmMoveLog marks a queued entry deliverable, due at once.
2320func (s *Store) ConfirmMoveLog(host, op, name string) error {
2321 _, err := s.db.Exec(`UPDATE move_log_outbox SET confirmed = 1, next_at = 0 WHERE host = ? AND op = ? AND name = ?`, host, op, name)
2322 return err
2323}
2324
2325func scanMoveLogRows(rows *sql.Rows) ([]MoveLogPendingEntry, error) {
2326 defer rows.Close()
2327 var out []MoveLogPendingEntry
2328 for rows.Next() {
2329 var e MoveLogPendingEntry
2330 var at int64
2331 if err := rows.Scan(&e.Host, &e.Op, &e.Name, &e.Incarnation, &e.Body, &at, &e.Confirmed, &e.Attempts, &e.Warned, &e.Held); err != nil {
2332 return nil, err
2333 }
2334 e.QueuedAt = time.Unix(at, 0).UTC()
2335 out = append(out, e)
2336 }
2337 return out, rows.Err()
2338}
2339
2340const moveLogColumns = `host, op, name, incarnation, body, queued_at, confirmed, attempts, warned, held`
2341
2342// MoveLogPending is one move's queued entries, oldest first.
2343func (s *Store) MoveLogPending(host, op string) ([]MoveLogPendingEntry, error) {
2344 rows, err := s.db.Query(`SELECT `+moveLogColumns+` FROM move_log_outbox WHERE host = ? AND op = ? ORDER BY rowid`, host, op)
2345 if err != nil {
2346 return nil, err
2347 }
2348 return scanMoveLogRows(rows)
2349}
2350
2351// MoveLogDue is up to limit queued entries whose next attempt is due at now,
2352// the longest due first: every attempt that does not settle a row moves its
2353// next attempt on, so due rows rotate through the batch.
2354func (s *Store) MoveLogDue(now time.Time, limit int) ([]MoveLogPendingEntry, error) {
2355 rows, err := s.db.Query(`SELECT `+moveLogColumns+` FROM move_log_outbox WHERE held = 0 AND next_at <= ? ORDER BY next_at, rowid LIMIT ?`, now.Unix(), limit)
2356 if err != nil {
2357 return nil, err
2358 }
2359 return scanMoveLogRows(rows)
2360}
2361
2362// RetryMoveLog records a failed attempt: the attempt count, the next
2363// attempt's time and whether the row's warning has been given.
2364func (s *Store) RetryMoveLog(host, op, name string, attempts int, next time.Time, warned bool) error {
2365 _, err := s.db.Exec(`UPDATE move_log_outbox SET attempts = ?, next_at = ?, warned = ? WHERE host = ? AND op = ? AND name = ?`,
2366 attempts, next.Unix(), warned, host, op, name)
2367 return err
2368}
2369
2370// HoldMoveLog stops a row's delivery for good, keeping its bytes: the writer
2371// refused it (it can never be written as it stands), and only its
2372// incarnation's purge removes it. Its warning has been given.
2373func (s *Store) HoldMoveLog(host, op, name string) error {
2374 _, err := s.db.Exec(`UPDATE move_log_outbox SET held = 1, warned = 1 WHERE host = ? AND op = ? AND name = ?`, host, op, name)
2375 return err
2376}
2377
2378// PurgeMoveLogIncarnation drops every row of one instance incarnation: the
2379// incarnation is retired or purged, so none of them can ever be written.
2380func (s *Store) PurgeMoveLogIncarnation(host, incarnation string) error {
2381 _, err := s.db.Exec(`DELETE FROM move_log_outbox WHERE host = ? AND incarnation = ?`, host, incarnation)
2382 return err
2383}
2384
2385// SettleMoveLog drops an entry once it is written, found written, or can
2386// never be.
2387func (s *Store) SettleMoveLog(host, op, name string) error {
2388 _, err := s.db.Exec(`DELETE FROM move_log_outbox WHERE host = ? AND op = ? AND name = ?`, host, op, name)
2389 return err
2390}
2391
2392// DropUnconfirmedMoveLog deletes a queued entry only while it is still
2393// unconfirmed: a confirmation that landed since the caller read the row
2394// keeps it.
2395func (s *Store) DropUnconfirmedMoveLog(host, op, name string) error {
2396 _, err := s.db.Exec(`DELETE FROM move_log_outbox WHERE host = ? AND op = ? AND name = ? AND confirmed = 0`, host, op, name)
2397 return err
2398}
2399
2400// ---- grants (the console's Keymail access tokens) ---------------------------
2401
2402// SetGrantToken stores the Keymail access token the user's OAuth approval
2403// minted for the console — the instrument of future confirmation-link
2404// sign-ins. Server-side only; it never reaches a browser.
2405func (s *Store) SetGrantToken(address, token string, now time.Time) error {
2406 _, err := s.db.Exec(`INSERT INTO grants (address, token, created_at, updated_at)
2407VALUES (?, ?, ?, ?)
2408ON CONFLICT(address) DO UPDATE SET token = excluded.token, updated_at = excluded.updated_at`,
2409 address, token, now.Unix(), now.Unix())
2410 return err
2411}
2412
2413// GrantToken returns the stored Keymail token for address, "" when none.
2414func (s *Store) GrantToken(address string) (string, error) {
2415 var tok string
2416 err := s.db.QueryRow(`SELECT token FROM grants WHERE address = ?`, address).Scan(&tok)
2417 if err == sql.ErrNoRows {
2418 return "", nil
2419 }
2420 return tok, err
2421}
2422
2423// ---- sessions ---------------------------------------------------------------
2424
2425func hashToken(t string) string {
2426 h := sha256.Sum256([]byte(t))
2427 return hex.EncodeToString(h[:])
2428}
2429
2430func randomToken() (string, error) {
2431 b := make([]byte, 32)
2432 if _, err := rand.Read(b); err != nil {
2433 return "", err
2434 }
2435 return base64.RawURLEncoding.EncodeToString(b), nil
2436}
2437
2438// CreateSession mints a console session for address, returning the raw
2439// token the cookie carries. Only the sha256 of it is stored.
2440func (s *Store) CreateSession(address, keymailToken string, now time.Time) (string, error) {
2441 return s.CreateSessionMethod(address, keymailToken, MethodKeymail, now)
2442}
2443
2444// CreateSessionMethod is CreateSession with the way the session was proved
2445// recorded: MethodKeymail or MethodEmail.
2446func (s *Store) CreateSessionMethod(address, keymailToken, method string, now time.Time) (string, error) {
2447 raw, err := randomToken()
2448 if err != nil {
2449 return "", err
2450 }
2451 _, err = s.db.Exec(`INSERT INTO sessions (token_hash, address, keymail_token, method, created_at, expires_at)
2452VALUES (?, ?, ?, ?, ?, ?)`,
2453 hashToken(raw), address, keymailToken, method, now.Unix(), now.Add(SessionTTL).Unix())
2454 if err != nil {
2455 return "", err
2456 }
2457 return raw, nil
2458}
2459
2460// SessionAddress resolves a raw session token to its address; ok is false
2461// for unknown or expired tokens.
2462func (s *Store) SessionAddress(rawToken string, now time.Time) (string, bool) {
2463 var addr string
2464 var exp int64
2465 err := s.db.QueryRow(`SELECT address, expires_at FROM sessions WHERE token_hash = ?`,
2466 hashToken(rawToken)).Scan(&addr, &exp)
2467 if err != nil || now.Unix() > exp {
2468 return "", false
2469 }
2470 return addr, true
2471}
2472
2473// SessionInfo resolves a raw session token to its address and the method that
2474// proved it; ok is false for unknown or expired tokens.
2475func (s *Store) SessionInfo(rawToken string, now time.Time) (address, method string, ok bool) {
2476 var exp int64
2477 err := s.db.QueryRow(`SELECT address, method, expires_at FROM sessions WHERE token_hash = ?`,
2478 hashToken(rawToken)).Scan(&address, &method, &exp)
2479 if err != nil || now.Unix() > exp {
2480 return "", "", false
2481 }
2482 return address, method, true
2483}
2484
2485// DeleteSession removes a session by its raw token.
2486func (s *Store) DeleteSession(rawToken string) error {
2487 _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, hashToken(rawToken))
2488 return err
2489}
2490
2491// PurgeExpired drops expired sessions, confirmation tokens and email challenges — and, on the
2492// same pass, the CLI login requests and tokens that have aged out (throttled
2493// inside PurgeCLIAuth, which is also called from the login path).
2494func (s *Store) PurgeExpired(now time.Time) error {
2495 if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.Unix()); err != nil {
2496 return err
2497 }
2498 if _, err := s.db.Exec(`DELETE FROM confirmations WHERE expires_at < ?`, now.Unix()); err != nil {
2499 return err
2500 }
2501 if _, err := s.db.Exec(`DELETE FROM email_challenges WHERE expires_at < ?`, now.Unix()); err != nil {
2502 return err
2503 }
2504 s.PurgeCLIAuth(now)
2505 return nil
2506}
2507
2508// ---- confirmations (returning sign-in links) --------------------------------
2509
2510// CreateConfirmation mints a single-use sign-in confirmation token for
2511// address (15 min TTL), returning the raw token for the emailed link. Only
2512// its hash is stored.
2513func (s *Store) CreateConfirmation(address string, now time.Time) (string, error) {
2514 raw, err := randomToken()
2515 if err != nil {
2516 return "", err
2517 }
2518 _, err = s.db.Exec(`INSERT INTO confirmations (token_hash, address, created_at, expires_at)
2519VALUES (?, ?, ?, ?)`,
2520 hashToken(raw), address, now.Unix(), now.Add(ConfirmationTTL).Unix())
2521 if err != nil {
2522 return "", err
2523 }
2524 return raw, nil
2525}
2526
2527// TakeConfirmation consumes a confirmation token: the row is deleted before
2528// the expiry verdict (burn-then-check, keymail takeOAuthCode manners), so a
2529// token can never be retried into validity.
2530func (s *Store) TakeConfirmation(rawToken string, now time.Time) (string, bool) {
2531 tx, err := s.db.Begin()
2532 if err != nil {
2533 return "", false
2534 }
2535 defer tx.Rollback()
2536 var addr string
2537 var exp int64
2538 err = tx.QueryRow(`SELECT address, expires_at FROM confirmations WHERE token_hash = ?`,
2539 hashToken(rawToken)).Scan(&addr, &exp)
2540 if err != nil {
2541 return "", false
2542 }
2543 if _, err := tx.Exec(`DELETE FROM confirmations WHERE token_hash = ?`, hashToken(rawToken)); err != nil {
2544 return "", false
2545 }
2546 if err := tx.Commit(); err != nil {
2547 return "", false
2548 }
2549 if now.Unix() > exp {
2550 return "", false
2551 }
2552 return addr, true
2553}
2554
2555// ---- audit (recent activity) ------------------------------------------------
2556
2557// Event is one audit row: something an owner did to an app through the
2558// console, in the browser or through its API. Platform state stays in the
2559// registry and the bucket; direct-bucket mutations do not enter this trail.
2560type Event struct {
2561 App, Address, Action, TargetType, Target, Message, Detail string
2562 TargetURL string
2563 CreatedAt time.Time
2564}
2565
2566// Activity is the structured half of an audit row. Action and target are
2567// chosen by the server after it has authorised and applied the mutation;
2568// Message is optional context supplied by the person making it. Detail is
2569// retained for rows and callers which predate the structured shape.
2570type Activity struct {
2571 Action, TargetType, Target, Message, Detail string
2572}
2573
2574// RecordEvent appends one audit row. Callers treat it as best-effort:
2575// losing an audit line must never fail the action it records.
2576//
2577// Keyed on (account, app), never the app name alone: two accounts can both
2578// own a "blog", and a shared activity stream would show each of them the
2579// other's actions — including the other account's member addresses, which is
2580// a membership leak dressed up as a convenience.
2581func (s *Store) claimRawRecordEvent(accountID int64, app, address, action, detail string, now time.Time) error {
2582 return s.RecordActivity(accountID, app, address, Activity{Action: action, Detail: detail}, now)
2583}
2584
2585// RecordActivity appends a structured audit row. The account and app remain
2586// separate arguments so callers cannot accidentally move either into
2587// user-controlled metadata.
2588func (s *Store) claimRawRecordActivity(accountID int64, app, address string, activity Activity, now time.Time) error {
2589 _, err := s.db.Exec(`INSERT INTO audit
2590 (account_id, app, address, action, target_type, target, message, detail, created_at)
2591VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, accountID, app, address, activity.Action,
2592 activity.TargetType, activity.Target, activity.Message, activity.Detail, now.Unix())
2593 return err
2594}
2595
2596// Events returns one account's audit rows for an app, newest first, capped at
2597// limit.
2598func (s *Store) Events(accountID int64, app string, limit int) ([]Event, error) {
2599 rows, err := s.db.Query(`SELECT app, address, action, target_type, target, message, detail, created_at
2600FROM audit WHERE account_id = ? AND app = ? ORDER BY id DESC LIMIT ?`, accountID, app, limit)
2601 if err != nil {
2602 return nil, err
2603 }
2604 defer rows.Close()
2605 var out []Event
2606 for rows.Next() {
2607 var e Event
2608 var ts int64
2609 if err := rows.Scan(&e.App, &e.Address, &e.Action, &e.TargetType, &e.Target,
2610 &e.Message, &e.Detail, &ts); err != nil {
2611 return nil, err
2612 }
2613 e.CreatedAt = time.Unix(ts, 0).UTC()
2614 out = append(out, e)
2615 }
2616 return out, rows.Err()
2617}
2618
2619// AppRef names one app by its account's sqid, as the bucket keys it.
2620type AppRef struct{ Sqid, App string }
2621
2622// AppsWithActionSince answers, once each, every (account, app) with an audit
2623// row of action stamped at or after since. The run sweep's orphan
2624// housekeeping lists runs/ only for apps with a recent run-start (S3
2625// change-index design §4.6). A plain read, like Events.
2626func (s *Store) AppsWithActionSince(action string, since time.Time) ([]AppRef, error) {
2627 rows, err := s.db.Query(`SELECT DISTINCT accounts.sqid, audit.app
2628FROM audit JOIN accounts ON accounts.id = audit.account_id
2629WHERE audit.action = ? AND audit.created_at >= ?
2630ORDER BY accounts.sqid, audit.app`, action, since.Unix())
2631 if err != nil {
2632 return nil, err
2633 }
2634 defer rows.Close()
2635 var out []AppRef
2636 for rows.Next() {
2637 var r AppRef
2638 if err := rows.Scan(&r.Sqid, &r.App); err != nil {
2639 return nil, err
2640 }
2641 out = append(out, r)
2642 }
2643 return out, rows.Err()
2644}
2645
2646// ---- keys -------------------------------------------------------------------
2647
2648// SigningKey returns the console's long-lived envelope-signing key (ECDSA
2649// P-256 — the curve keymail's app API pins in a grant), generating and
2650// persisting it on first use.
2651func (s *Store) SigningKey() (*ecdsa.PrivateKey, error) {
2652 load := func() (*ecdsa.PrivateKey, error) {
2653 var b64 string
2654 if err := s.db.QueryRow(`SELECT pkcs8 FROM keys WHERE name = 'keymail-sign'`).Scan(&b64); err != nil {
2655 return nil, err
2656 }
2657 der, err := base64.StdEncoding.DecodeString(b64)
2658 if err != nil {
2659 return nil, fmt.Errorf("console: stored signing key: %v", err)
2660 }
2661 key, err := x509.ParsePKCS8PrivateKey(der)
2662 if err != nil {
2663 return nil, fmt.Errorf("console: stored signing key: %v", err)
2664 }
2665 ec, ok := key.(*ecdsa.PrivateKey)
2666 if !ok {
2667 return nil, fmt.Errorf("console: stored signing key is not ECDSA")
2668 }
2669 return ec, nil
2670 }
2671 if k, err := load(); err == nil {
2672 return k, nil
2673 } else if err != sql.ErrNoRows {
2674 return nil, err
2675 }
2676 priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
2677 if err != nil {
2678 return nil, err
2679 }
2680 der, err := x509.MarshalPKCS8PrivateKey(priv)
2681 if err != nil {
2682 return nil, err
2683 }
2684 // INSERT OR IGNORE + re-load: if a racing opener won, everyone converges
2685 // on the row that stuck.
2686 if _, err := s.db.Exec(`INSERT OR IGNORE INTO keys (name, pkcs8, created_at) VALUES ('keymail-sign', ?, ?)`,
2687 base64.StdEncoding.EncodeToString(der), time.Now().Unix()); err != nil {
2688 return nil, err
2689 }
2690 return load()
2691}
2692func (s *Store) RecordActivity(accountID int64, app, address string, activity Activity, now time.Time) (claimResult0 error) {
2693 return s.RecordActivityContext(context.Background(), accountID, app, address, activity, now)
2694}
2695func (s *Store) RecordActivityContext(claimContext context.Context, accountID int64, app, address string, activity Activity, now time.Time) (claimResult0 error) {
2696 claimResult0 = s.withAutoAppSQL(claimContext, accountID, app, "sql.RecordActivity", func(claimView *Store) error {
2697 claimResult0 = claimView.claimRawRecordActivity(accountID, app, address, activity, now)
2698 return claimResult0
2699 })
2700 return
2701}
2702func (s *Store) RecordEvent(accountID int64, app, address, action, detail string, now time.Time) (claimResult0 error) {
2703 return s.RecordEventContext(context.Background(), accountID, app, address, action, detail, now)
2704}
2705func (s *Store) RecordEventContext(claimContext context.Context, accountID int64, app, address, action, detail string, now time.Time) (claimResult0 error) {
2706 claimResult0 = s.withAutoAppSQL(claimContext, accountID, app, "sql.RecordEvent", func(claimView *Store) error {
2707 claimResult0 = claimView.claimRawRecordEvent(accountID, app, address, action, detail, now)
2708 return claimResult0
2709 })
2710 return
2711}
2712func (s *Store) SetAppBuildProbe(accountID int64, app, path string) (claimResult0 error) {
2713 return s.SetAppBuildProbeContext(context.Background(), accountID, app, path)
2714}
2715func (s *Store) SetAppBuildProbeContext(claimContext context.Context, accountID int64, app, path string) (claimResult0 error) {
2716 claimResult0 = s.withAutoAppSQL(claimContext, accountID, app, "sql.SetAppBuildProbe", func(claimView *Store) error {
2717 claimResult0 = claimView.claimRawSetAppBuildProbe(accountID, app, path)
2718 return claimResult0
2719 })
2720 return
2721}
2722func (s *Store) SetAppControlPlane(accountID int64, app string, on bool) (claimResult0 error) {
2723 return s.SetAppControlPlaneContext(context.Background(), accountID, app, on)
2724}
2725func (s *Store) SetAppControlPlaneContext(claimContext context.Context, accountID int64, app string, on bool) (claimResult0 error) {
2726 claimResult0 = s.withAutoAppSQL(claimContext, accountID, app, "sql.SetAppControlPlane", func(claimView *Store) error {
2727 claimResult0 = claimView.claimRawSetAppControlPlane(accountID, app, on)
2728 return claimResult0
2729 })
2730 return
2731}
2732func (s *Store) SetAppPlacement(accountID int64, app, target string) (claimResult0 error) {
2733 return s.SetAppPlacementContext(context.Background(), accountID, app, target)
2734}
2735func (s *Store) SetAppPlacementContext(claimContext context.Context, accountID int64, app, target string) (claimResult0 error) {
2736 claimResult0 = s.withAutoAppSQL(claimContext, accountID, app, "sql.SetAppPlacement", func(claimView *Store) error {
2737 claimResult0 = claimView.claimRawSetAppPlacement(accountID, app, target)
2738 return claimResult0
2739 })
2740 return
2741}
2742func (s *Store) TrashAppRow(accountID int64, app string, now time.Time) (claimResult0 error) {
2743 return s.TrashAppRowContext(context.Background(), accountID, app, now)
2744}
2745func (s *Store) TrashAppRowContext(claimContext context.Context, accountID int64, app string, now time.Time) (claimResult0 error) {
2746 claimResult0 = s.withAutoAppSQL(claimContext, accountID, app, "sql.TrashAppRow", func(claimView *Store) error {
2747 claimResult0 = claimView.claimRawTrashAppRow(accountID, app, now)
2748 return claimResult0
2749 })
2750 return
2751}
2752