0001_init.sql
| 1 | -- opentracker initial schema. |
| 2 | -- |
| 3 | -- Every table is STRICT: SQLite's default type affinity would happily store the |
| 4 | -- string 'north' in an INTEGER latitude column, and this database is written to |
| 5 | -- by a hot path that must never be the place a type error is discovered. |
| 6 | -- |
| 7 | -- The central decision here: **the position stream belongs to the account.** A |
| 8 | -- token is a credential that authorises writing into its owner's stream. It is |
| 9 | -- never an identity — it does not appear in a point's key, in a share, or on the |
| 10 | -- map. One person is one dot, regardless of which pocket the phone is in. |
| 11 | -- |
| 12 | -- Coordinates are integers (degrees × 1e7) end to end — protocol, database, JSON, |
| 13 | -- UI — so there is no float-formatting drift anywhere in the system. |
| 14 | |
| 15 | CREATE TABLE users ( |
| 16 | id INTEGER PRIMARY KEY, |
| 17 | -- NOCASE so 'Marc' and 'marc' are the same account, which is what users |
| 18 | -- assume, and which closes a whole class of impersonation confusion. |
| 19 | username TEXT NOT NULL COLLATE NOCASE UNIQUE, |
| 20 | -- argon2id PHC string; carries its own parameters so the policy can change |
| 21 | -- without invalidating existing hashes. |
| 22 | pw_hash TEXT NOT NULL, |
| 23 | display_name TEXT NOT NULL, |
| 24 | is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)), |
| 25 | -- Set rather than deleting the row: points and shares reference it. |
| 26 | disabled_at INTEGER, |
| 27 | created_at INTEGER NOT NULL, |
| 28 | -- Sessions and tokens issued before this are treated as revoked, which is |
| 29 | -- how "changing my password logs everything else out" is enforced without |
| 30 | -- having to enumerate them. |
| 31 | pw_changed_at INTEGER NOT NULL |
| 32 | ) STRICT; |
| 33 | |
| 34 | -- One row per login. Purely a credential plus per-phone telemetry. |
| 35 | CREATE TABLE tokens ( |
| 36 | -- The u64 that travels in every datagram header. Random, not sequential: |
| 37 | -- guessing one must not be easier than guessing a key. |
| 38 | token_id INTEGER PRIMARY KEY, |
| 39 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 40 | -- The 32-byte token secret, wrapped with the server key from OT_SECRET_KEY |
| 41 | -- (AAD = token_id), so a stolen .db alone yields no working keys. |
| 42 | key_wrapped BLOB NOT NULL, |
| 43 | name TEXT NOT NULL, |
| 44 | platform TEXT NOT NULL DEFAULT '', |
| 45 | app_version INTEGER, |
| 46 | os_api_level INTEGER, |
| 47 | config_version INTEGER NOT NULL DEFAULT 1, |
| 48 | config_json TEXT NOT NULL DEFAULT '{}', |
| 49 | last_seen_at INTEGER, |
| 50 | last_src_ip TEXT, |
| 51 | last_src_port INTEGER, |
| 52 | last_transport TEXT, |
| 53 | created_at INTEGER NOT NULL, |
| 54 | created_ip TEXT, |
| 55 | revoked_at INTEGER |
| 56 | ) STRICT; |
| 57 | |
| 58 | CREATE INDEX tokens_user ON tokens(user_id); |
| 59 | -- Drives the 30-day staleness sweep. |
| 60 | CREATE INDEX tokens_last_seen ON tokens(last_seen_at); |
| 61 | |
| 62 | CREATE TABLE points ( |
| 63 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 64 | -- Unix seconds from the client's wall clock, stored as sent. |
| 65 | ts INTEGER NOT NULL, |
| 66 | lat INTEGER NOT NULL, |
| 67 | lon INTEGER NOT NULL, |
| 68 | acc_dm INTEGER, |
| 69 | alt_m INTEGER, |
| 70 | spd_cms INTEGER, |
| 71 | brg_cdeg INTEGER, |
| 72 | bat_pct INTEGER, |
| 73 | flags INTEGER NOT NULL DEFAULT 0, |
| 74 | recv_at INTEGER NOT NULL, |
| 75 | -- Provenance only: nullable, never in a key, never read by the UI. It exists |
| 76 | -- so "which phone sent this?" is answerable while debugging. Deliberately |
| 77 | -- NOT a foreign key with CASCADE — a token being deleted by the staleness |
| 78 | -- sweep must not take history with it. |
| 79 | src_token_id INTEGER, |
| 80 | |
| 81 | -- The dedup that makes retries and replays both harmless. A replayed |
| 82 | -- datagram carries a ts that already exists and collapses into the row |
| 83 | -- already there, which is why this schema needs no replay window. |
| 84 | PRIMARY KEY (user_id, ts) |
| 85 | ) STRICT, WITHOUT ROWID; |
| 86 | |
| 87 | -- For the retention sweep, which scans by age across all users. |
| 88 | CREATE INDEX points_ts ON points(ts); |
| 89 | |
| 90 | -- Current position per *account*, in its own table so the retention GC can never |
| 91 | -- delete the live marker. |
| 92 | CREATE TABLE user_latest ( |
| 93 | user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, |
| 94 | ts INTEGER NOT NULL, |
| 95 | lat INTEGER NOT NULL, |
| 96 | lon INTEGER NOT NULL, |
| 97 | acc_dm INTEGER, |
| 98 | alt_m INTEGER, |
| 99 | spd_cms INTEGER, |
| 100 | brg_cdeg INTEGER, |
| 101 | bat_pct INTEGER, |
| 102 | flags INTEGER NOT NULL DEFAULT 0, |
| 103 | recv_at INTEGER NOT NULL, |
| 104 | src_token_id INTEGER |
| 105 | ) STRICT; |
| 106 | |
| 107 | CREATE TABLE groups ( |
| 108 | id INTEGER PRIMARY KEY, |
| 109 | owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 110 | name TEXT NOT NULL, |
| 111 | created_at INTEGER NOT NULL, |
| 112 | UNIQUE (owner_user_id, name) |
| 113 | ) STRICT; |
| 114 | |
| 115 | CREATE TABLE group_members ( |
| 116 | group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, |
| 117 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 118 | added_at INTEGER NOT NULL, |
| 119 | PRIMARY KEY (group_id, user_id) |
| 120 | ) STRICT, WITHOUT ROWID; |
| 121 | |
| 122 | -- You share *yourself*, not a phone: there is no per-device dimension here. |
| 123 | CREATE TABLE shares ( |
| 124 | id INTEGER PRIMARY KEY, |
| 125 | owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 126 | -- Exactly one of these two. |
| 127 | viewer_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, |
| 128 | viewer_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, |
| 129 | trail_visible INTEGER NOT NULL DEFAULT 1 CHECK (trail_visible IN (0, 1)), |
| 130 | -- Metres to round the position to before showing it. 0 = exact. |
| 131 | precision_m INTEGER NOT NULL DEFAULT 0, |
| 132 | -- NULL means no expiry. Enforced at query time, never by a background job, |
| 133 | -- so a stalled job can never leak a position. |
| 134 | expires_at INTEGER, |
| 135 | revoked_at INTEGER, |
| 136 | created_at INTEGER NOT NULL, |
| 137 | |
| 138 | CHECK ((viewer_user_id IS NULL) <> (viewer_group_id IS NULL)), |
| 139 | CHECK (owner_user_id <> viewer_user_id) |
| 140 | ) STRICT; |
| 141 | |
| 142 | CREATE INDEX shares_owner ON shares(owner_user_id); |
| 143 | CREATE INDEX shares_viewer_user ON shares(viewer_user_id); |
| 144 | CREATE INDEX shares_viewer_group ON shares(viewer_group_id); |
| 145 | |
| 146 | -- OSM tile proxy cache metadata. The bytes live on the filesystem at |
| 147 | -- {cache_dir}/tiles/{z}/{x}/{y}.png; this table is the index and the accounting |
| 148 | -- that makes max_cache_bytes enforceable. |
| 149 | CREATE TABLE tiles ( |
| 150 | z INTEGER NOT NULL, |
| 151 | x INTEGER NOT NULL, |
| 152 | y INTEGER NOT NULL, |
| 153 | etag TEXT, |
| 154 | last_modified TEXT, |
| 155 | fetched_at INTEGER NOT NULL, |
| 156 | expires_at INTEGER NOT NULL, |
| 157 | bytes INTEGER NOT NULL, |
| 158 | last_access INTEGER NOT NULL, |
| 159 | PRIMARY KEY (z, x, y) |
| 160 | ) STRICT, WITHOUT ROWID; |
| 161 | |
| 162 | -- Drives least-recently-used eviction. |
| 163 | CREATE INDEX tiles_last_access ON tiles(last_access); |
| 164 | |
| 165 | CREATE TABLE audit_log ( |
| 166 | id INTEGER PRIMARY KEY, |
| 167 | at INTEGER NOT NULL, |
| 168 | -- Nullable: failed logins have no authenticated user yet. |
| 169 | user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, |
| 170 | action TEXT NOT NULL, |
| 171 | detail TEXT NOT NULL DEFAULT '', |
| 172 | src_ip TEXT |
| 173 | ) STRICT; |
| 174 | |
| 175 | CREATE INDEX audit_log_at ON audit_log(at); |
| 176 | |
| 177 | CREATE TABLE settings ( |
| 178 | key TEXT PRIMARY KEY, |
| 179 | value TEXT NOT NULL |
| 180 | ) STRICT, WITHOUT ROWID; |
| 181 |