Преглед изворни кода

r3: api_tokens dropped (arch 24, P3-2) — saddles.token is the single credential store: the legacy table, its entity mapping, the startup ensure_saddle_tokens migration (main.vala's call + the service), token_for_saddle (the saddles page and the /start callback token read the row directly), unregister's sweep, and saddle_for_token's fallback read all die. Nothing deployed predates the plan-10 row token (operator decision), so no credential loses validity — a legacy-shaped token string now 401s everywhere, pinned by the rewritten restart scenario: the fresh schema carries no api_tokens table, the row token survives a restart unchanged and still authenticates, and the legacy-shaped string authorises nothing. 850 -> 851 checks.

clanker пре 1 месец
родитељ
комит
e236aa7079

+ 0 - 20
src/Data/Entities.vala

@@ -456,26 +456,6 @@ namespace ClankerSaddle.Data {
         }
     }
 
-    /**
-     * A bearer token authorising one saddle daemon's calls against the
-     * `/api/saddle/*` endpoints, generated at registration.
-     */
-    public class ApiToken : Object {
-
-        public int64 id { get; set; }
-        public int64 saddle_id { get; set; }
-        public string token { get; set; default = ""; }
-        public DateTime? created_at { get; set; default = null; }
-
-        public static void entity_mapping(EntityMapperBuilder<ApiToken> cfg) {
-            cfg.table("api_tokens")
-                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("saddle_id", o => o.saddle_id, (o, v) => o.saddle_id = v)
-                .column<string>("token", o => o.token, (o, v) => o.token = v)
-                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
-        }
-    }
-
     /**
      * A user's Web Push subscription endpoint (plan 11 task E shape):
      * `p256dh`/`auth` are the browser's application server keys; the

+ 0 - 15
src/Data/Migrations/M0001_Initial.vala

@@ -311,20 +311,6 @@ namespace ClankerSaddle.Data.Migrations {
                 t.unique("uq_saddle_entry_dedup_pair", {"saddle_id", "entry_id"});
             });
 
-            b.create_table("api_tokens", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("saddle_id")
-                    .not_null()
-                    .references("saddles", "id")
-                    .indexed();
-                t.column<string>("token")
-                    .not_null()
-                    .unique();
-                t.column<DateTime?>("created_at");
-            });
-
             b.create_table("rules", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -499,7 +485,6 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("task_rules");
             b.drop_table("rule_projects");
             b.drop_table("rules");
-            b.drop_table("api_tokens");
             b.drop_table("session_commands");
             b.drop_table("sessions");
             b.drop_table("saddles");

+ 9 - 69
src/Services/SaddleService.vala

@@ -191,70 +191,21 @@ namespace ClankerSaddle.Services {
             }
         }
 
-        /**
-         * Startup migration of the per-saddle token: every saddle row
-         * gets its canonical token — the latest legacy `api_tokens`
-         * value it already holds (daemons registered before plan 10
-         * keep their credential), or a freshly minted one. Existing
-         * rows with a token are left alone.
-         */
-        public async void ensure_saddle_tokens() throws Error {
-            foreach (var saddle in yield list_saddles()) {
-                if ((saddle.token ?? "").length > 0) {
-                    continue;
-                }
-                string? legacy = yield token_for_saddle(saddle.id);
-                saddle.token = legacy != null && ((!)legacy).length > 0
-                    ? (!)legacy : mint_saddle_token();
-                yield db.update_async<Saddle>(saddle);
-                print(@"[ClankerSaddle] Saddle '$(saddle.name)' token ready ($(saddle.token.substring(0, 12))…)\n");
-            }
-        }
-
         /**
          * The saddle a bearer token belongs to, or null — the
          * credential check for the id-less `/api/saddle/*` endpoints
-         * (`next-task`, `note`, `task`): the row token
-         * first, then any legacy `api_tokens` row (mapped to its
-         * saddle). Removing the saddle revokes both.
+         * (`next-task`, `note`, `task`): the per-saddle row token,
+         * the single credential store since plan 10 (arch 24 dropped
+         * the legacy api_tokens fallback — nothing deployed predates
+         * the row token). Removing the saddle revokes it.
          */
         public async Saddle? saddle_for_token(string token) throws Error {
             if (token.length == 0) {
                 return null;
             }
-            var by_row = yield db.query<Saddle>()
-                .where(expr("token == $0", elem(token)))
-                .first_async();
-            if (by_row != null) {
-                return by_row;
-            }
-            var legacy = yield db.query<ApiToken>()
+            return yield db.query<Saddle>()
                 .where(expr("token == $0", elem(token)))
                 .first_async();
-            if (legacy == null) {
-                return null;
-            }
-            return yield get_saddle(((!)legacy).saddle_id);
-        }
-
-        /**
-         * The most recent API token issued for `saddle_id`, or null: the
-         * row token first, then the latest legacy `api_tokens` row.
-         */
-        public async string? token_for_saddle(int64 saddle_id) throws Error {
-            var saddle = yield get_saddle(saddle_id);
-            if (saddle != null && (((!)saddle).token ?? "").length > 0) {
-                return ((!)saddle).token;
-            }
-            ApiToken? latest = null;
-            var rows = yield db.query<ApiToken>()
-                .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
-                .order_by(expr("id"))
-                .materialise_async();
-            foreach (var row in rows) {
-                latest = row;
-            }
-            return latest != null ? ((!)latest).token : null;
         }
 
         /**
@@ -283,10 +234,9 @@ namespace ClankerSaddle.Services {
          * Removes a saddle registration; the task it was running
          * releases through the ONE path — back to the queue, its open
          * sessions closed stopped (no orphan open session outlives the
-         * row). Revocation: the row (and its per-saddle token) dies
-         * here, and every legacy `api_tokens` row held for the
-         * saddle is deleted — nothing authorises `/api/saddle/*` for
-         * it afterwards.
+         * row). Revocation: the row dies here, and its per-saddle
+         * token with it — nothing authorises `/api/saddle/*` for it
+         * afterwards.
          */
         public async void unregister_saddle(int64 id) throws Error {
             var saddle = yield get_saddle(id);
@@ -296,11 +246,6 @@ namespace ClankerSaddle.Services {
             if (((!)saddle).current_task_id > 0) {
                 yield task_service.release_task(((!)saddle).current_task_id, OUTCOME_STOPPED, true);
             }
-            foreach (var row in yield db.query<ApiToken>()
-                    .where(expr("saddle_id == $0", elem<int64?>(id)))
-                    .materialise_async()) {
-                yield db.delete_async<ApiToken>(row);
-            }
             yield db.delete_async<Saddle>((!)saddle);
             yield pulse.bump(0, "saddle removed");
         }
@@ -1005,17 +950,12 @@ namespace ClankerSaddle.Services {
             // daemon presents when posting back — its own per-saddle
             // credential (plan 10), which every `/api/saddle/*` call
             // carries.
-            string callback_token = saddle.token ?? "";
-            if (callback_token.length == 0) {
-                string? legacy = yield token_for_saddle(saddle.id);
-                callback_token = legacy ?? "";
-            }
             payload.set_member_name("callback");
             payload.begin_object();
             payload.set_member_name("base_url");
             payload.add_string_value(callback_base_url());
             payload.set_member_name("token");
-            payload.add_string_value(callback_token);
+            payload.add_string_value(saddle.token ?? "");
             payload.end_object();
 
             payload.set_member_name("llm_config");

+ 1 - 2
src/entrypoints/SaddlesEntrypoint.vala

@@ -175,8 +175,7 @@ namespace ClankerSaddle {
                     refresh = action_registry.author_private<RefreshSaddleStatusAction, SaddleActionPrivate>(sealed),
                     attach_capability = action_registry.author_private<AttachSaddleCapabilityAction, SaddleActionPrivate>(sealed)
                 };
-                string? token = yield saddle_service.token_for_saddle(saddle.id);
-                view.token = token ?? "";
+                view.token = saddle.token ?? "";
                 var work = yield saddle_service.current_work_view(saddle);
                 if (work != null) {
                     view.current_task_title = ((!)work).task_title;

+ 0 - 18
src/main.vala

@@ -52,7 +52,6 @@ int main(string[] args) {
         db.add_entity<Session>(Session.entity_mapping);
         db.add_entity<SessionCommand>(SessionCommand.entity_mapping);
         db.add_entity<SaddleEntryDedup>(SaddleEntryDedup.entity_mapping);
-        db.add_entity<ApiToken>(ApiToken.entity_mapping);
         db.add_entity<Rule>(Rule.entity_mapping);
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
@@ -194,7 +193,6 @@ int main(string[] args) {
 
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);
-            ensure_saddle_tokens.begin(application.container);
             start_dispatcher.begin(application.container);
             return false;
         });
@@ -237,19 +235,3 @@ private async void start_dispatcher(Container container) {
         printerr("[ClankerSaddle] Dispatcher startup failed: %s\n", e.message);
     }
 }
-
-/**
- * Startup migration of the per-saddle tokens (plan 10): every saddle
- * row gets its canonical credential — the legacy `api_tokens` value it
- * already holds, or a freshly minted one — so the operator can copy it
- * to daemons registered before the flip (see
- * {@link ClankerSaddle.Services.SaddleService.ensure_saddle_tokens}).
- */
-private async void ensure_saddle_tokens(Container container) {
-    try {
-        var scope = container.create_scope();
-        yield scope.resolve<SaddleService>().ensure_saddle_tokens();
-    } catch (Error e) {
-        printerr("[ClankerSaddle] Saddle token migration failed: %s\n", e.message);
-    }
-}

+ 28 - 39
test/web_stack_test.py

@@ -30,7 +30,8 @@ POSTs) and asserts the Workstream B features against the wire contract:
 Plan 10 phase 1 (work acquisition flip):
 
 - per-saddle tokens: registration mints + /register-delivers them,
-  legacy tokens migrate onto the row at startup, removal revokes
+  the row is the single credential store (the api_tokens fallback
+  died with arch 24), removal revokes
 - token auth on every /api/saddle/* endpoint
 - next-task allocation: concurrent asks race exactly one winner,
   affinity pinning for parked tasks, resume_notice on suspended resumes
@@ -4944,11 +4945,12 @@ def bundle_answers(ctx):
                    timeout=20, what="unreachable flag") is not None,
           "queue row shows the unreachable state in the window")
 
-    print("== phase 1: startup migrates the per-saddle token ==")
-    # Saddles registered before the flip hold legacy api_tokens rows.
-    # On startup every saddle row gets its canonical token: the
-    # legacy value it already holds (daemons keep their credential)
-    # or a freshly minted one. Fixture: this shard's own racer.
+    print("== phase 1: saddles.token is the single credential store (arch 24) ==")
+    # The legacy api_tokens table and every fallback read died with the
+    # poll era: a restart proves the migrated schema carries no
+    # api_tokens table, the row token survives and still authenticates,
+    # and a legacy-shaped token string authorises nothing (no fallback
+    # read exists to honour it). Fixture: this shard's own racer.
     fake2 = FakeSaddle()
     fake2.ask_on_register = False
     fake2.serve(port=free_port())
@@ -4957,15 +4959,8 @@ def bundle_answers(ctx):
                                           "host": "127.0.0.1:%d" % fake2.port})
     racer_two_id = int([s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
                         if s["name"] == "racer-two"][0]["id"])
-    conn = db()
-    conn.execute("insert into api_tokens (saddle_id, token, created_at)"
-                 " values (?, 'saddle-legacy-copied-token', strftime('%s','now'))",
-                 (racer_two_id,))
-    conn.execute("update saddles set token = null where name = 'racer-two'")
-    conn.execute("insert into saddles (name, host, status, current_task_id)"
-                 " values ('tokenless-orphan', '127.0.0.1:1', 'offline', 0)")
-    conn.commit()
-    conn.close()
+    racer_two_token = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                       if s["name"] == "racer-two"][0]["token"]
 
     app.terminate()
     app.wait(timeout=10)
@@ -4974,37 +4969,31 @@ def bundle_answers(ctx):
     ctx.app = app  # teardown tracks the restarted process
     ctx.auth = auth
 
-    def saddle_row(name):
+    def schema_tables():
         conn = db()
         try:
-            return conn.execute("select id, token from saddles where name = ?",
-                                (name,)).fetchone()
+            return {row[0] for row in conn.execute(
+                "select name from sqlite_master where type = 'table'")}
         finally:
             conn.close()
 
-    check(wait_for(lambda: saddle_row("tokenless-orphan") is not None
-                   and (saddle_row("tokenless-orphan")[1] or "").startswith("saddle-")
-                   or False, timeout=20, what="tokenless row minted") is not None,
-          "startup mints a token for a saddle row without one")
-    check(wait_for(lambda: saddle_row("racer-two") is not None
-                   and saddle_row("racer-two")[1] == "saddle-legacy-copied-token"
-                   or False, timeout=20, what="legacy token copied") is not None,
-          "startup copies the legacy api_tokens value onto the row")
-    fake2.token = "saddle-legacy-copied-token"  # the daemon asks with it
-    status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-legacy-copied-token")
-    check(status in (200, 204),
-          "the legacy token still authenticates (copied, not replaced)")
+    check("api_tokens" not in schema_tables(),
+          "the fresh schema carries no api_tokens table (arch 24)")
     conn = db()
-    conn.execute("delete from saddles where name = 'tokenless-orphan'")
-    conn.commit()
+    row_token = conn.execute("select token from saddles where id = ?",
+                             (racer_two_id,)).fetchone()[0]
     conn.close()
-
-    # The restart wiped the in-memory ask bookkeeping; the fleet's
-    # daemons re-establish sleeping with a fresh ask each (the real
-    # daemons' idle-awake ask / 15-minute safety self-ask).
-    for daemon in (fake2,):
-        daemon.ask_now()
-    check(True, "fleet re-asks after the restart (sleeping re-established)")
+    check(row_token == racer_two_token and (row_token or "").startswith("saddle-"),
+          "the per-saddle row token survives the restart unchanged")
+    status, _ = saddle_api("POST", "/api/saddle/next-task", racer_two_token)
+    check(status in (200, 204),
+          "the row token still authenticates after the restart")
+    status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-legacy-copied-token")
+    check(status == 401,
+          "a legacy api_tokens-shaped credential authorises nothing (fallback gone)")
+    fake2.token = racer_two_token
+    fake2.ask_now()
+    check(True, "the fleet re-asks after the restart (sleeping re-established)")
 
 
 # =============================================================================