nomoreide-daemon 0.20.1

The NoMoreIDE daemon: the local HTTP server, its route registry, and the embedded web dashboard.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
//! The deploy provider's dashboard surface: who you are signed in as, which
//! account you are acting as, which project this repository deploys, and what
//! it has deployed.
//!
//! The Rust half of `src/web/routes/provider-routes.ts`, minus the OAuth pair.
//! Nothing here names Vercel or Cloudflare — the id is a path segment and the
//! client comes from the registry — so a third provider adds no route.
//!
//! **Every route answers failure differently, and none of it is a house
//! style.** Each follows from where the reference's `try` starts, and the
//! dashboard is the reference's dashboard:
//!
//! - `status` reports a *connected* provider's refusal as a **200** carrying a
//!   status field, because the connection panel it feeds has a screen for
//!   "signed out" and none for a failed fetch. Only an id no provider claims
//!   is a 404.
//! - `projects` and `deployments` answer **500** for everything — an unknown
//!   provider, one that is not connected, a vendor that refused. Giving the
//!   unknown provider a 404 there would be more tasteful and would diverge.
//! - `connect` and `scope` answer **400** for everything, because everything
//!   they can refuse is the caller's doing.
//! - `project` answers **400** on the way in and **500** on the way out: a
//!   rejected write is the caller's problem and a failed read is not.
//! - the deployment reads answer **500** for everything, the id that will not
//!   percent-decode included, because the reference decodes inside the same
//!   `try` a vendor refusal lands in.
//! - the action route answers **404** for a name the manifest does not declare
//!   and **400** for everything else — the 404 comes first, before any
//!   credential is resolved.
//!
//! **A missing project is not a failure.** `deployments` answers 200 with an
//! empty list and an explicit `project: null`, because the dashboard's job in
//! that state is to help the user link one, and an error would render as a
//! broken panel instead.
//!
//! **Where the method check sits is observable.** `connect` looks the provider
//! up *before* checking the verb, so a GET to an unknown provider is a 400
//! naming the provider rather than a 405; `scope` checks the verb first, so
//! the same request there is a 405; `status` checks no verb at all. That is
//! why every route below is `any()` with an explicit match rather than
//! `get()`/`post()` — the router would answer 405 in places the reference does
//! not.
//!
//! **The write boundary is one door.** Every deploy-changing operation is a
//! `POST` to `deployments/:deployment/:action`, so there is one place to audit
//! what a provider can change and one place a guard would go. Which names are
//! legal comes from the manifest, never from this file.
//!
//! The OAuth pair is deliberately still the reference's. It holds a login
//! session in memory across two unrelated requests and serves an HTML page to
//! a browser tab, which is its own slice rather than a detail of these — and
//! being stateful, it belongs in a `deploy_providers/` submodule of its own
//! when it lands, the way `github/api.rs` sits under `github.rs`.

use crate::server::app::AppState;
use crate::server::body::{decode_uri_component, parse_form, read_json_object, string_field};
use crate::server::errors::{error, method_not_allowed};
use crate::server::routes::query::query_value;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::any;
use axum::{Json, Router};
use nomoreide_core::config::{selected_git_repository, ProviderConnectionDef};
use nomoreide_core::providers::deploy::{DeployActionInput, ProviderProject};
use nomoreide_core::providers::registry::{
    cli_missing, provider_cli_session, provider_cli_status, public_provider_connection,
    require_deploy_provider, require_provider_actions, require_provider_context, DeployActions,
    ProviderAccount, ProviderContext,
};
use serde_json::{json, Map, Value};

/// The most deployments one request will return, however large a `limit` asks
/// for.
const MAX_DEPLOYMENTS: u32 = 100;

/// The line caps the two log routes apply, and the number each vendor's
/// manager falls back to when the caller names none.
///
/// The defaults are spelled here rather than left to the manager because a
/// route that sends no limit and a route that sends the manager's own default
/// make the *same* vendor request — but only while the two numbers agree, and
/// nothing else would notice if they stopped.
const MAX_BUILD_LOG_LINES: u32 = 2_000;
const DEFAULT_BUILD_LOG_LINES: u32 = 500;
const MAX_RUNTIME_LOG_LINES: u32 = 1_000;
const DEFAULT_RUNTIME_LOG_LINES: u32 = 200;

/// What both vendors' managers fall back to when the caller names no limit.
/// Spelled here because the route has to send *something*, and sending a
/// different number would be a divergence nobody reading the route would see.
const DEFAULT_DEPLOYMENTS: u32 = 20;

pub(crate) mod oauth;

pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .merge(oauth::routes())
        // The reference's pattern routes do not guard the verb for these
        // reads. Keeping that observable behavior matters until the reference
        // and native route can tighten it together.
        .route("/api/providers/:provider/status", any(status))
        .route("/api/providers/:provider/connect", any(connect))
        .route("/api/providers/:provider/scope", any(scope))
        .route("/api/providers/:provider/project", any(project))
        .route("/api/providers/:provider/env", any(env))
        .route("/api/providers/:provider/env/:env/reveal", any(reveal_env))
        .route("/api/providers/:provider/env/:env", any(change_env))
        .route("/api/providers/:provider/domains", any(domains))
        .route("/api/providers/:provider/projects", any(projects))
        .route("/api/providers/:provider/deployments", any(deployments))
        // Static before dynamic, which is also the reference's dispatch order:
        // `logs` and `runtime-logs` are matched as themselves, so a POST to
        // either is a 405 rather than an unknown action. Axum prefers a literal
        // segment over `:action` on its own, and the order here says so anyway.
        .route(
            "/api/providers/:provider/deployments/:deployment/logs",
            any(build_logs),
        )
        .route(
            "/api/providers/:provider/deployments/:deployment/runtime-logs",
            any(runtime_logs),
        )
        .route(
            "/api/providers/:provider/deployments/:deployment/:action",
            any(run_action),
        )
        .route(
            "/api/providers/:provider/deployments/:deployment",
            any(deployment),
        )
}

fn ok() -> Response {
    Json(json!({ "ok": true })).into_response()
}

/// Everything the connection panel opens with: who the user is signed in as,
/// which scopes they can switch to, and whether the repository has a project.
///
/// **A refusal from a connected provider is a 200 here.** The panel's job in
/// that state is to say "your sign-in expired, reconnect" — which is a screen,
/// not an error toast — so the failure travels in `status` alongside the
/// manifest the panel needs to render itself either way. Only an id no
/// provider claims is a 404, and only because the reference's `try` closes
/// around the lookup.
async fn status(State(state): State<AppState>, Path(provider): Path<String>) -> Response {
    let manifest = match require_deploy_provider(&provider) {
        Ok(manifest) => manifest,
        Err(message) => return error(StatusCode::NOT_FOUND, &message),
    };
    // A config that will not load is reported as 404 too. That is the
    // reference's outer `catch`, which does not distinguish what threw — and
    // the panel reads the message, not the status.
    let config = match state.config_store.load().await {
        Ok(config) => config,
        Err(failure) => return error(StatusCode::NOT_FOUND, &failure.to_string()),
    };

    let cli = provider_cli_status(&provider).await;
    let mut base = Map::new();
    base.insert("ok".into(), Value::Bool(true));
    base.insert("provider".into(), manifest);
    let connection = public_provider_connection(&config, &provider);
    if let Some(connection) = connection.clone() {
        base.insert("connection".into(), connection);
    }
    base.insert("cliAvailable".into(), Value::Bool(cli.available));
    if let Some(cli_error) = cli.error {
        base.insert("cliError".into(), Value::String(cli_error));
    }
    if let Some(repository) = selected_git_repository(&config) {
        base.insert(
            "repositoryName".into(),
            Value::String(repository.name.clone()),
        );
    }

    if connection.is_none() && !cli.available {
        base.insert("status".into(), Value::String("not_configured".into()));
        return Json(Value::Object(base)).into_response();
    }

    let cwd = state.workspace_cwd().await;
    let context =
        match require_provider_context(&provider, &state.config_store, &config, &cwd).await {
            Ok(context) => context,
            // Nothing reached the vendor, so there is no status to read: an
            // unresolvable credential is a connection problem, not a rejected
            // one.
            Err(message) => return unreachable_provider(base, false, message),
        };

    // Both reads are started together, as the reference's `Promise.all` does.
    // The scope list is the *optional* half — a token that may read projects
    // but not the account list still gives a working dashboard, just without
    // the switcher — so its failure is swallowed where the account's is not.
    let (account, scopes) = tokio::join!(context.client.account(), context.client.list_scopes());
    match account {
        Ok(account) => connected_panel(
            base,
            connection,
            &context,
            account,
            scopes.unwrap_or_default(),
        ),
        Err(failure) => unreachable_provider(base, failure.is_auth(), failure.message),
    }
}

/// A provider that answered: who you are signed in as, what you can switch to,
/// and what this repository deploys.
fn connected_panel(
    mut base: Map<String, Value>,
    connection: Option<Value>,
    context: &ProviderContext,
    account: ProviderAccount,
    scopes: Vec<Value>,
) -> Response {
    base.insert(
        "connection".into(),
        // A CLI login is enough to work: someone who never opened this tab
        // gets a connected dashboard rather than a setup screen.
        connection.unwrap_or_else(|| json!({ "source": "cli" })),
    );
    base.insert(
        "status".into(),
        Value::String(
            if context.project.is_some() {
                "connected"
            } else {
                "no_project"
            }
            .into(),
        ),
    );
    // Only the two fields the panel shows, and each only when the vendor had
    // one — an absent key and a null are different answers here.
    let mut user = Map::new();
    if let Some(username) = account.username {
        user.insert("username".into(), username);
    }
    if let Some(avatar) = account.avatar {
        user.insert("avatar".into(), avatar);
    }
    base.insert("user".into(), Value::Object(user));
    base.insert("scopes".into(), json!(scopes));
    if let Some(project) = context.project.as_ref() {
        base.insert("project".into(), json!(project));
    }
    // The scope actually in force, which is not always the stored one: an
    // unscoped connection adopts the sole team or account it can see.
    if let Some(scope_id) = context.credential.scope_id.as_ref() {
        base.insert("scopeId".into(), Value::String(scope_id.clone()));
    }
    Json(Value::Object(base)).into_response()
}

/// A provider that is configured but did not answer, reported as the state it
/// is in rather than as a failed request.
fn unreachable_provider(mut base: Map<String, Value>, auth: bool, message: String) -> Response {
    base.insert(
        "status".into(),
        Value::String(
            if auth {
                "auth_error"
            } else {
                "connection_error"
            }
            .into(),
        ),
    );
    base.insert("error".into(), Value::String(message));
    Json(Value::Object(base)).into_response()
}

/// Save a connection (`POST`) or forget one (`DELETE`).
///
/// The two sources differ in what is stored, and the difference is the policy:
/// a pasted token is written to config, while `cli` stores **only the scope**
/// and re-reads the token from the vendor CLI's own auth file at use time — so
/// `vercel logout` and `wrangler logout` revoke our access too.
async fn connect(
    State(state): State<AppState>,
    Path(provider): Path<String>,
    method: Method,
    body: Bytes,
) -> Response {
    // Before the verb check, so a GET to an unknown provider names the
    // provider rather than the method. The reference's lookup opens its `try`
    // and its verb check is inside it.
    if let Err(message) = require_deploy_provider(&provider) {
        return error(StatusCode::BAD_REQUEST, &message);
    }

    if method == Method::DELETE {
        return match state.config_store.remove_connection(&provider).await {
            Ok(_) => {
                // Not housekeeping: without this a disconnect after a failed
                // browser sign-in leaves `oauth/status` reporting that error
                // for an account that is no longer connected.
                state.provider_logins.forget(&provider);
                ok()
            }
            Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
        };
    }
    if method != Method::POST {
        return method_not_allowed().await;
    }

    let form = parse_form(&body);
    let connection = if form.get("source").map(|source| source.trim()) == Some("cli") {
        let Some(session) = provider_cli_session(&provider).await else {
            return error(
                StatusCode::BAD_REQUEST,
                cli_missing(&provider).unwrap_or_default(),
            );
        };
        ProviderConnectionDef {
            source: "cli".into(),
            scope_id: session.current_scope,
            ..empty_connection()
        }
    } else {
        let token = form
            .get("token")
            .map(|token| token.trim())
            .filter(|token| !token.is_empty());
        let Some(token) = token else {
            return error(StatusCode::BAD_REQUEST, "token is required");
        };
        ProviderConnectionDef {
            source: "stored".into(),
            token: Some(token.to_string()),
            ..empty_connection()
        }
    };

    match state
        .config_store
        .set_connection(&provider, connection)
        .await
    {
        Ok(_) => ok(),
        Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
    }
}

/// Every field a connection can carry, empty. Spelled once so the two branches
/// above name only what they actually set — and so a field added to the stored
/// shape cannot silently arrive here carrying a stale value.
fn empty_connection() -> ProviderConnectionDef {
    ProviderConnectionDef {
        source: String::new(),
        token: None,
        refresh_token: None,
        expires_at: None,
        client_id: None,
        scope_id: None,
        scope_slug: None,
        username: None,
        legacy_team_id: None,
        legacy_team_slug: None,
    }
}

/// Point an existing connection at a different team or account.
///
/// The verb is checked *before* the provider, which is the opposite of
/// `connect` above and is what the reference does: its method guard sits
/// outside the `try` that holds the lookup.
async fn scope(
    State(state): State<AppState>,
    Path(provider): Path<String>,
    method: Method,
    body: Bytes,
) -> Response {
    if method != Method::PUT {
        return method_not_allowed().await;
    }
    if let Err(message) = require_deploy_provider(&provider) {
        return error(StatusCode::BAD_REQUEST, &message);
    }
    // A non-string field is no field: the reference reads `typeof === "string"`
    // and passes `undefined` otherwise, which clears the scope rather than
    // storing a number. The store then trims what is left.
    let payload = read_json_object(&body);
    match state
        .config_store
        .set_connection_scope(
            &provider,
            string_field(&payload, "scopeId").map(str::to_string),
            string_field(&payload, "scopeSlug").map(str::to_string),
        )
        .await
    {
        Ok(_) => ok(),
        Err(failure) => error(StatusCode::BAD_REQUEST, &failure.to_string()),
    }
}

/// `GET` reads the linked project in full; `PUT` pins one, or clears the pin
/// when `projectId` is absent or empty.
///
/// The read is not the project `status` already carries. That one may have
/// come from a *listing*, and Vercel's listing omits the build settings — so
/// this always reads the single-project endpoint, which is what the settings
/// panel is there to show.
async fn project(
    State(state): State<AppState>,
    Path(provider): Path<String>,
    method: Method,
    body: Bytes,
) -> Response {
    // A rejected write is the caller's problem; a failed read is not. The
    // reference picks its status from the verb in one place, at the end, so
    // every refusal below — the provider lookup included — follows the verb
    // rather than what went wrong.
    let refusal = if method == Method::PUT {
        StatusCode::BAD_REQUEST
    } else {
        StatusCode::INTERNAL_SERVER_ERROR
    };
    if let Err(message) = require_deploy_provider(&provider) {
        return error(refusal, &message);
    }

    if method == Method::PUT {
        let payload = read_json_object(&body);
        let config = match state.config_store.load().await {
            Ok(config) => config,
            Err(failure) => return error(refusal, &failure.to_string()),
        };
        let Some(repository) = selected_git_repository(&config) else {
            return error(refusal, "No Git repository is selected.");
        };
        let repository = repository.name.clone();
        return match state
            .config_store
            .set_provider_project(
                &provider,
                &repository,
                string_field(&payload, "projectId").map(str::to_string),
            )
            .await
        {
            Ok(_) => ok(),
            Err(failure) => error(refusal, &failure.to_string()),
        };
    }
    if method != Method::GET {
        return method_not_allowed().await;
    }

    let context = match context(&state, &provider).await {
        Ok(context) => context,
        Err(response) => return response,
    };
    let Some(linked) = context
        .project
        .as_ref()
        .and_then(ProviderProject::identifier)
    else {
        return error(refusal, "No project is linked to this repository.");
    };
    match context.client.get_project(linked).await {
        Ok(project) => Json(json!({ "ok": true, "project": project })).into_response(),
        Err(failure) => error(refusal, &failure.message),
    }
}

async fn context(state: &AppState, provider: &str) -> Result<ProviderContext, Response> {
    provider_context(state, provider)
        .await
        .map_err(|failure| error(StatusCode::INTERNAL_SERVER_ERROR, &failure))
}

/// The same, leaving the status to the caller — the env routes answer 400 or
/// 500 for the *same* unresolved provider depending on the verb, so they
/// cannot use the 500 above.
async fn provider_context(state: &AppState, provider: &str) -> Result<ProviderContext, String> {
    let config = state
        .config_store
        .load()
        .await
        .map_err(|failure| failure.to_string())?;
    let cwd = state.workspace_cwd().await;
    require_provider_context(provider, &state.config_store, &config, &cwd).await
}

/// The linked project, or the sentence the routes report when there is none.
fn require_project(context: &ProviderContext) -> Result<&str, String> {
    context
        .project
        .as_ref()
        .and_then(ProviderProject::identifier)
        .ok_or_else(|| "No project is linked to this repository.".to_string())
}

/// The write-capable client, resolved separately from the read context on
/// purpose — see [`require_provider_actions`].
async fn actions(state: &AppState, provider: &str) -> Result<DeployActions, String> {
    let config = state
        .config_store
        .load()
        .await
        .map_err(|failure| failure.to_string())?;
    require_provider_actions(provider, &state.config_store, &config).await
}

/// The `env` path segment as it was written, before percent-decoding.
///
/// Axum hands back a decoded parameter, but the reference decodes with
/// `decodeURIComponent`, which *throws* on a broken escape rather than passing
/// it through — and that throw is a 400 the caller sees. Reading the raw
/// segment back off the URI is what keeps that difference.
fn raw_env_segment(uri: &Uri) -> &str {
    raw_segment(uri, 5)
}

/// The nth `/`-separated piece of the path, counting the empty piece before the
/// leading slash as zero — so `/api/providers/<id>/deployments/<deployment>`
/// puts the provider at 3 and the deployment at 5.
fn raw_segment(uri: &Uri, index: usize) -> &str {
    uri.path().split('/').nth(index).unwrap_or_default()
}

/// `GET` lists the project's variables; `POST` adds one.
///
/// **The context is resolved before the verb is checked**, which is the
/// reference's order and is observable: a `PUT` here reaches the vendor to
/// resolve the project and *then* answers 405, while a `PUT` to a provider
/// that will not connect answers 400 instead. Both fall out of the reference's
/// `try` opening before its method guard.
async fn env(
    State(state): State<AppState>,
    Path(provider): Path<String>,
    method: Method,
    body: Bytes,
) -> Response {
    // A failed read is a server-side problem; a rejected write is the
    // caller's. The split the routes this replaced drew too.
    let refusal = if method == Method::GET {
        StatusCode::INTERNAL_SERVER_ERROR
    } else {
        StatusCode::BAD_REQUEST
    };
    if let Err(message) = require_deploy_provider(&provider) {
        return error(refusal, &message);
    }
    let context = match provider_context(&state, &provider).await {
        Ok(context) => context,
        Err(message) => return error(refusal, &message),
    };

    if method == Method::GET {
        let project = match require_project(&context) {
            Ok(project) => project,
            Err(message) => return error(refusal, &message),
        };
        return match context.client.list_env(project).await {
            Ok(env) => Json(json!({ "ok": true, "env": env })).into_response(),
            Err(failure) => error(refusal, &failure.message),
        };
    }
    if method != Method::POST {
        return method_not_allowed().await;
    }

    let payload = read_json_object(&body);
    let actions = match actions(&state, &provider).await {
        Ok(actions) => actions,
        Err(message) => return error(refusal, &message),
    };
    let project = match require_project(&context) {
        Ok(project) => project.to_string(),
        Err(message) => return error(refusal, &message),
    };
    let key = string_field(&payload, "key").unwrap_or_default().trim();
    let environments = string_list(&payload, "environments");
    if key.is_empty() {
        return error(refusal, "A key is required.");
    }
    if environments.is_empty() {
        return error(refusal, "Choose at least one environment.");
    }
    match actions
        .create_env(
            &project,
            key,
            string_field(&payload, "value").unwrap_or_default(),
            &environments,
            // Anything that is not the word `plain` is a secret. The default
            // is the one whose value does not read back.
            if string_field(&payload, "type") == Some("plain") {
                "plain"
            } else {
                "encrypted"
            },
        )
        .await
    {
        Ok(env) => Json(json!({ "ok": true, "env": env })).into_response(),
        Err(failure) => error(refusal, &failure.message),
    }
}

/// Reveal one variable's value.
///
/// `POST` rather than `GET`, and one key at a time, so putting a secret on the
/// wire is always a deliberate act that leaves a request behind — the same
/// reasoning that keeps the database's write half off the agent surface. This
/// route has no MCP tool for that reason.
async fn reveal_env(
    State(state): State<AppState>,
    Path((provider, _env)): Path<(String, String)>,
    method: Method,
    uri: Uri,
) -> Response {
    // The verb is checked before anything else here, unlike `env` above.
    if method != Method::POST {
        return method_not_allowed().await;
    }
    let refusal = StatusCode::BAD_REQUEST;
    if let Err(message) = require_deploy_provider(&provider) {
        return error(refusal, &message);
    }
    let context = match provider_context(&state, &provider).await {
        Ok(context) => context,
        Err(message) => return error(refusal, &message),
    };
    let project = match require_project(&context) {
        Ok(project) => project,
        Err(message) => return error(refusal, &message),
    };
    let Some(env_id) = decode_uri_component(raw_env_segment(&uri)) else {
        return error(refusal, "URI malformed");
    };
    match context.client.get_env_value(project, &env_id).await {
        Ok(value) => Json(json!({ "ok": true, "value": value })).into_response(),
        Err(failure) => error(refusal, &failure.message),
    }
}

/// Update (`PATCH`) or delete (`DELETE`) one variable. The same write boundary
/// as adding one.
async fn change_env(
    State(state): State<AppState>,
    Path((provider, _env)): Path<(String, String)>,
    method: Method,
    uri: Uri,
    body: Bytes,
) -> Response {
    if method != Method::PATCH && method != Method::DELETE {
        return method_not_allowed().await;
    }
    let refusal = StatusCode::BAD_REQUEST;
    if let Err(message) = require_deploy_provider(&provider) {
        return error(refusal, &message);
    }
    // Context, then actions, then the project, then the id — the reference's
    // order, and each step can refuse before the next one runs.
    let context = match provider_context(&state, &provider).await {
        Ok(context) => context,
        Err(message) => return error(refusal, &message),
    };
    let actions = match actions(&state, &provider).await {
        Ok(actions) => actions,
        Err(message) => return error(refusal, &message),
    };
    let project = match require_project(&context) {
        Ok(project) => project.to_string(),
        Err(message) => return error(refusal, &message),
    };
    let Some(env_id) = decode_uri_component(raw_env_segment(&uri)) else {
        return error(refusal, "URI malformed");
    };

    if method == Method::DELETE {
        return match actions.delete_env(&project, &env_id).await {
            Ok(()) => ok(),
            Err(failure) => error(refusal, &failure.message),
        };
    }

    let payload = read_json_object(&body);
    // An empty string is not a new value: it is the dialog saying "leave the
    // value alone and change only the environments".
    let value = string_field(&payload, "value").filter(|value| !value.is_empty());
    // Absent rather than empty when the caller sent no list at all, which means
    // "keep the environments it has".
    let environments = payload
        .get("environments")
        .and_then(Value::as_array)
        .map(|_| string_list(&payload, "environments"));
    match actions
        .update_env(&project, &env_id, value, environments.as_deref())
        .await
    {
        Ok(env) => Json(json!({ "ok": true, "env": env })).into_response(),
        Err(failure) => error(refusal, &failure.message),
    }
}

/// The domains a project serves on. Like the two reads below it, this guards no
/// verb and answers 500 for everything.
async fn domains(State(state): State<AppState>, Path(provider): Path<String>) -> Response {
    let refusal = StatusCode::INTERNAL_SERVER_ERROR;
    if let Err(message) = require_deploy_provider(&provider) {
        return error(refusal, &message);
    }
    let context = match provider_context(&state, &provider).await {
        Ok(context) => context,
        Err(message) => return error(refusal, &message),
    };
    let project = match require_project(&context) {
        Ok(project) => project,
        Err(message) => return error(refusal, &message),
    };
    match context.client.list_domains(project).await {
        Ok(domains) => Json(json!({ "ok": true, "domains": domains })).into_response(),
        Err(failure) => error(refusal, &failure.message),
    }
}

/// The strings in a JSON array field, with everything that is not a string
/// dropped — the reference filters rather than refusing, so `["a", 7]` is a
/// list of one.
fn string_list(payload: &Value, key: &str) -> Vec<String> {
    payload
        .get(key)
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default()
}

async fn projects(
    State(state): State<AppState>,
    Path(provider): Path<String>,
    uri: Uri,
) -> Response {
    let context = match context(&state, &provider).await {
        Ok(context) => context,
        Err(response) => return response,
    };
    // A search of only spaces is no search: the reference trims and then treats
    // the empty string as absent, so `?search=%20%20` reaches the vendor as no
    // filter rather than as a filter nothing matches.
    let search = query_value(&uri, "search")
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty());
    match context.client.list_projects(search.as_deref()).await {
        Ok(projects) => {
            let mut body = serde_json::Map::new();
            body.insert("ok".into(), Value::Bool(true));
            body.insert("projects".into(), json!(projects));
            // Absent rather than null when nothing is linked, because the
            // reference builds this from `context.project?.id`.
            if let Some(linked) = context
                .project
                .as_ref()
                .and_then(|project| project.id.clone())
            {
                body.insert("linkedProjectId".into(), linked);
            }
            Json(Value::Object(body)).into_response()
        }
        Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
    }
}

async fn deployments(
    State(state): State<AppState>,
    Path(provider): Path<String>,
    uri: Uri,
) -> Response {
    let context = match context(&state, &provider).await {
        Ok(context) => context,
        Err(response) => return response,
    };
    let Some(project) = context.project.as_ref() else {
        return Json(json!({ "ok": true, "deployments": [], "project": Value::Null }))
            .into_response();
    };
    let Some(project_id) = project.identifier() else {
        return Json(json!({ "ok": true, "deployments": [], "project": Value::Null }))
            .into_response();
    };

    // Anything that is not one of the two named targets is no target at all,
    // rather than a filter the vendor would reject.
    let target =
        query_value(&uri, "target").filter(|value| value == "production" || value == "preview");
    let limit = capped_limit(query_value(&uri, "limit").as_deref(), MAX_DEPLOYMENTS);

    match context
        .client
        .list_deployments(
            project_id,
            target.as_deref(),
            limit.unwrap_or(DEFAULT_DEPLOYMENTS),
        )
        .await
    {
        Ok(deployments) => Json(json!({
            "ok": true,
            "project": project,
            "deployments": deployments,
        }))
        .into_response(),
        Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
    }
}

/// The shared opening of the three deployment reads: the verb, the provider,
/// the context, and the id as the caller wrote it.
///
/// All four failures are a **500**, which is the reference's answer for every
/// one of them — including a deployment id that will not percent-decode, since
/// it decodes inside the same `try` a vendor refusal lands in. A 400 would read
/// better and would diverge.
async fn deployment_read(
    state: &AppState,
    provider: &str,
    method: Method,
    uri: &Uri,
) -> Result<(ProviderContext, String), Response> {
    if method != Method::GET {
        return Err(method_not_allowed().await);
    }
    let context = context(state, provider).await?;
    let id = decode_uri_component(raw_segment(uri, 5))
        .ok_or_else(|| error(StatusCode::INTERNAL_SERVER_ERROR, "URI malformed"))?;
    Ok((context, id))
}

/// Why a build failed, in the vendor's own output.
async fn build_logs(
    State(state): State<AppState>,
    Path((provider, _deployment)): Path<(String, String)>,
    method: Method,
    uri: Uri,
) -> Response {
    let (context, id) = match deployment_read(&state, &provider, method, &uri).await {
        Ok(read) => read,
        Err(response) => return response,
    };
    let limit = capped_limit(query_value(&uri, "limit").as_deref(), MAX_BUILD_LOG_LINES);
    match context
        .client
        .build_logs(
            context.linked_project().as_deref(),
            &id,
            limit.unwrap_or(DEFAULT_BUILD_LOG_LINES),
        )
        .await
    {
        Ok(logs) => Json(json!({ "ok": true, "logs": logs })).into_response(),
        Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
    }
}

/// Why a *deployed* request failed, which is a different question from why a
/// build did.
///
/// A provider that does not serve these answers an empty list rather than an
/// error: the tab is hidden by the manifest, so reaching this route at all
/// means a stale client, and an empty pane is a better answer for one than a
/// failure.
async fn runtime_logs(
    State(state): State<AppState>,
    Path((provider, _deployment)): Path<(String, String)>,
    method: Method,
    uri: Uri,
) -> Response {
    let (context, id) = match deployment_read(&state, &provider, method, &uri).await {
        Ok(read) => read,
        Err(response) => return response,
    };
    let limit = capped_limit(query_value(&uri, "limit").as_deref(), MAX_RUNTIME_LOG_LINES);
    match context
        .client
        .runtime_logs(&id, limit.unwrap_or(DEFAULT_RUNTIME_LOG_LINES))
        .await
    {
        Ok(logs) => Json(json!({ "ok": true, "logs": logs })).into_response(),
        Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
    }
}

/// One deployment on its own, which is where the fields worth a round trip
/// live — its aliases, when the build started, and why it failed.
async fn deployment(
    State(state): State<AppState>,
    Path((provider, _deployment)): Path<(String, String)>,
    method: Method,
    uri: Uri,
) -> Response {
    let (context, id) = match deployment_read(&state, &provider, method, &uri).await {
        Ok(read) => read,
        Err(response) => return response,
    };
    match context
        .client
        .get_deployment(context.linked_project().as_deref(), &id)
        .await
    {
        Ok(deployment) => Json(json!({ "ok": true, "deployment": deployment })).into_response(),
        Err(failure) => error(StatusCode::INTERNAL_SERVER_ERROR, &failure.message),
    }
}

/// **The write boundary's single door.** Every deploy-changing operation
/// arrives here, POST-only and named in the path, so there is one place to
/// audit what a provider can change and one place a guard would go.
///
/// Which names are legal comes from the manifest, not from this file — an
/// action a provider does not declare is a **404** naming it, before any
/// credential is resolved or any request is made. Everything after that is a
/// **400**: a caller asked for something the vendor would not do.
///
/// The original deployment is read first, and a failure to read it is
/// deliberately swallowed. Vercel's redeploy needs the original's name and
/// target — without the target a production retry silently comes back as a
/// preview — but Cloudflare's needs neither, so a provider that can act without
/// them should not be stopped by a read that did not answer.
async fn run_action(
    State(state): State<AppState>,
    Path((provider, _deployment, _action)): Path<(String, String, String)>,
    method: Method,
    uri: Uri,
) -> Response {
    if method != Method::POST {
        return method_not_allowed().await;
    }
    let refusal = StatusCode::BAD_REQUEST;
    let manifest = match require_deploy_provider(&provider) {
        Ok(manifest) => manifest,
        Err(message) => return error(refusal, &message),
    };
    // Read raw: the reference never decodes the action, so `red%65ploy` is a
    // name no provider declares rather than a redeploy.
    let action = raw_segment(&uri, 6);
    if !declares_action(&manifest, action) {
        let name = manifest
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or(&provider);
        return error(
            StatusCode::NOT_FOUND,
            &format!("{name} has no action \"{action}\"."),
        );
    }

    let context = match provider_context(&state, &provider).await {
        Ok(context) => context,
        Err(message) => return error(refusal, &message),
    };
    let actions = match actions(&state, &provider).await {
        Ok(actions) => actions,
        Err(message) => return error(refusal, &message),
    };
    let Some(deployment_id) = decode_uri_component(raw_segment(&uri, 5)) else {
        return error(refusal, "URI malformed");
    };

    let project = context.linked_project();
    let original = context
        .client
        .get_deployment(project.as_deref(), &deployment_id)
        .await
        .ok();
    let input = DeployActionInput {
        deployment_id,
        project_id: project,
        name: original
            .as_ref()
            .and_then(|detail| detail.deployment.name.clone()),
        target: original
            .as_ref()
            .map(|detail| detail.deployment.target.clone()),
        description: Some(format!("{action} from NoMoreIDE")),
    };

    match actions.run(action, &input).await {
        // Spread into the answer rather than nested under a key, so an action
        // that created nothing sends `{ ok: true }` and nothing else.
        Ok(created) => {
            let mut body = Map::new();
            body.insert("ok".into(), Value::Bool(true));
            if let Some(deployment) = created {
                body.insert("deployment".into(), json!(deployment));
            }
            Json(Value::Object(body)).into_response()
        }
        Err(failure) => error(refusal, &failure.message),
    }
}

/// Whether the provider's manifest lists this action name.
fn declares_action(manifest: &Value, action: &str) -> bool {
    manifest
        .get("actions")
        .and_then(Value::as_array)
        .is_some_and(|actions| actions.iter().any(|name| name == action))
}

/// `Number.parseInt(value, 10)` of the query value, kept only when it came out
/// a positive number, and capped.
///
/// `parseInt` is not `Number()`: it reads the leading digits and ignores
/// whatever follows, so `20abc` is twenty and `abc` is nothing at all. A value
/// at or below zero is *dropped* rather than clamped, which is what lets the
/// vendors' own default apply instead of a limit of one.
fn capped_limit(raw: Option<&str>, cap: u32) -> Option<u32> {
    let parsed = parse_int(raw.unwrap_or(""))?;
    (parsed > 0).then(|| parsed.min(i64::from(cap)) as u32)
}

/// The leading integer of a string, the way `Number.parseInt` reads one.
fn parse_int(raw: &str) -> Option<i64> {
    let text = raw.trim_start();
    let (sign, digits) = match text.strip_prefix('-') {
        Some(rest) => (-1, rest),
        None => (1, text.strip_prefix('+').unwrap_or(text)),
    };
    let leading: String = digits.chars().take_while(char::is_ascii_digit).collect();
    // Saturating, because `parseInt` of a number too large for an integer is a
    // float rather than a failure, and either way it is over the cap.
    leading
        .parse::<i64>()
        .ok()
        .or((!leading.is_empty()).then_some(i64::MAX))
        .map(|value| sign * value)
}