@usine/gateway 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +200 -2
  3. package/dist/admin/api.d.ts +146 -0
  4. package/dist/admin/api.d.ts.map +1 -0
  5. package/dist/admin/api.js +105 -0
  6. package/dist/admin/api.js.map +1 -0
  7. package/dist/admin/server.d.ts +10 -0
  8. package/dist/admin/server.d.ts.map +1 -0
  9. package/dist/admin/server.js +79 -0
  10. package/dist/admin/server.js.map +1 -0
  11. package/dist/attachment/attachment.d.ts +84 -0
  12. package/dist/attachment/attachment.d.ts.map +1 -0
  13. package/dist/attachment/attachment.js +67 -0
  14. package/dist/attachment/attachment.js.map +1 -0
  15. package/dist/attachment/store.d.ts +34 -0
  16. package/dist/attachment/store.d.ts.map +1 -0
  17. package/dist/attachment/store.js +133 -0
  18. package/dist/attachment/store.js.map +1 -0
  19. package/dist/caller/caller.d.ts +60 -0
  20. package/dist/caller/caller.d.ts.map +1 -0
  21. package/dist/caller/caller.js +131 -0
  22. package/dist/caller/caller.js.map +1 -0
  23. package/dist/chatgpt/auth-file.d.ts +47 -0
  24. package/dist/chatgpt/auth-file.d.ts.map +1 -0
  25. package/dist/chatgpt/auth-file.js +132 -0
  26. package/dist/chatgpt/auth-file.js.map +1 -0
  27. package/dist/chatgpt/logins.d.ts +50 -0
  28. package/dist/chatgpt/logins.d.ts.map +1 -0
  29. package/dist/chatgpt/logins.js +303 -0
  30. package/dist/chatgpt/logins.js.map +1 -0
  31. package/dist/egress/client-hello.d.ts +120 -0
  32. package/dist/egress/client-hello.d.ts.map +1 -0
  33. package/dist/egress/client-hello.js +155 -0
  34. package/dist/egress/client-hello.js.map +1 -0
  35. package/dist/egress/egress.d.ts +66 -0
  36. package/dist/egress/egress.d.ts.map +1 -0
  37. package/dist/egress/egress.js +114 -0
  38. package/dist/egress/egress.js.map +1 -0
  39. package/dist/gateway.d.ts +27 -0
  40. package/dist/gateway.d.ts.map +1 -0
  41. package/dist/gateway.js +27 -0
  42. package/dist/gateway.js.map +1 -0
  43. package/dist/github/github.d.ts +54 -0
  44. package/dist/github/github.d.ts.map +1 -0
  45. package/dist/github/github.js +112 -0
  46. package/dist/github/github.js.map +1 -0
  47. package/dist/github/rename.d.ts +30 -0
  48. package/dist/github/rename.d.ts.map +1 -0
  49. package/dist/github/rename.js +83 -0
  50. package/dist/github/rename.js.map +1 -0
  51. package/dist/llm/llm.d.ts +113 -0
  52. package/dist/llm/llm.d.ts.map +1 -0
  53. package/dist/llm/llm.js +144 -0
  54. package/dist/llm/llm.js.map +1 -0
  55. package/dist/node/egress-proxy.d.ts +30 -0
  56. package/dist/node/egress-proxy.d.ts.map +1 -0
  57. package/dist/node/egress-proxy.js +491 -0
  58. package/dist/node/egress-proxy.js.map +1 -0
  59. package/dist/node/environment.d.ts +113 -0
  60. package/dist/node/environment.d.ts.map +1 -0
  61. package/dist/node/environment.js +140 -0
  62. package/dist/node/environment.js.map +1 -0
  63. package/dist/node/main.d.ts +3 -0
  64. package/dist/node/main.d.ts.map +1 -0
  65. package/dist/node/main.js +13 -0
  66. package/dist/node/main.js.map +1 -0
  67. package/dist/node/node.d.ts +28 -0
  68. package/dist/node/node.d.ts.map +1 -0
  69. package/dist/node/node.js +81 -0
  70. package/dist/node/node.js.map +1 -0
  71. package/dist/node/reload.d.ts +27 -0
  72. package/dist/node/reload.d.ts.map +1 -0
  73. package/dist/node/reload.js +170 -0
  74. package/dist/node/reload.js.map +1 -0
  75. package/dist/proxy/forward.d.ts +12 -0
  76. package/dist/proxy/forward.d.ts.map +1 -0
  77. package/dist/proxy/forward.js +44 -0
  78. package/dist/proxy/forward.js.map +1 -0
  79. package/dist/proxy/proxy.d.ts +29 -0
  80. package/dist/proxy/proxy.d.ts.map +1 -0
  81. package/dist/proxy/proxy.js +297 -0
  82. package/dist/proxy/proxy.js.map +1 -0
  83. package/dist/settings.d.ts +52 -0
  84. package/dist/settings.d.ts.map +1 -0
  85. package/dist/settings.js +27 -0
  86. package/dist/settings.js.map +1 -0
  87. package/dist/upstream.d.ts +5 -0
  88. package/dist/upstream.d.ts.map +1 -0
  89. package/dist/upstream.js +6 -0
  90. package/dist/upstream.js.map +1 -0
  91. package/package.json +58 -3
package/README.md CHANGED
@@ -1,3 +1,201 @@
1
- # Temporary Holding Version
1
+ # @usine/gateway
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The service on the gateway host in front of usine's worker VMs ([ADR 0027](../../docs/adr/0027-gateway-credential-proxy.md), [#223](https://github.com/basaltbytes/usine/issues/223)). Worker VMs have no public address, GitHub token, API key, or subscription login. They reach GitHub through the gateway, which adds the credential. They reach an LLM provider the same way if the gateway has that provider's credential: an API key, a Claude subscription token, or a ChatGPT login. Otherwise they use the harness's own login through the gateway's forward proxy, which allows only allowlisted hosts: package registries and the harness login hosts. [Workers and the gateway](https://docs.usine.dev/architecture/workers-and-gateway/) describes the deployment.
4
+
5
+ The gateway serves:
6
+
7
+ - **A reverse proxy** for workers on the private network. `git.<domain>` serves git smart HTTP at `/OWNER/REPO.git`, release downloads, and the GitHub REST and GraphQL API under `/api/`, as GitHub Enterprise Server does, so `gh` works with `GH_HOST=git.<domain>`. It adds the installation token that the executor attached for that worker and repo; see [GitHub paths](#github-paths). `llm.<domain>` serves the Anthropic API under `/anthropic`, and under `/openai` an OpenAI-compatible API or, with a [ChatGPT login](#chatgpt-login), the ChatGPT backend that Codex uses, with the gateway's credential. Other hosts are refused; the forward proxy serves the allowlisted ones.
8
+ - **A forward proxy** for `HTTPS_PROXY` and `HTTP_PROXY`: `CONNECT` on port 443 for HTTPS and absolute-form requests for plain HTTP mirrors, to allowlisted hosts only. A tunnel opens only if the client's TLS ClientHello names the CONNECT host. A ClientHello naming another host or none, an encrypted ClientHello, or non-TLS bytes end the tunnel (`server-name-mismatch`, `server-name-missing`, `not-tls`). It resolves each name itself and refuses private, loopback, and link-local addresses. Before a plain HTTP request leaves, it removes `X-Usine-Gateway-Token` and any `Authorization` or `x-api-key` value that is a caller token, and leaves other credentials alone.
9
+ - **An admin API** for the executor: attach, renew, detach, and list a worker's repo credentials, and list the [LLM providers](#llm-providers) it has credentials for. It requires the admin token as a bearer token. Attachments are kept in memory; the executor attaches again when it renews or restarts. A [reload](#reload) keeps them.
10
+
11
+ Refused requests log `Gateway refused a request` at warning level, with the reason, host, client address, and the caller token's worker. Forwarded requests log at info with the worker, repo, method, path, and status, and with the host of the response's `Location` header (`target`), if any; release downloads log as `Served a GitHub release download`. A [rewritten](#github-paths) `Location` header also logs `Rewrote a GitHub Location header to the gateway`, with the original host (`from`) and the new path. A repo's [new name](#github-paths) that an attachment learns from GitHub logs `Learned the new name of an attached repository`, with `newName`; a new name that GitHub's API doesn't confirm logs `GitHub's API didn't confirm the new name of an attached repository` at warning level, with `newName` and the `reason`. A ChatGPT login logs each load, refresh, and failure; see [ChatGPT login](#chatgpt-login). No token, key, request body, or query string is logged.
12
+
13
+ ## Install
14
+
15
+ `@usine/gateway` is on npm from 0.1.0. On the gateway host, this installs it with its `usine-gateway` command in `node_modules/.bin`:
16
+
17
+ ```sh
18
+ npm install --omit=dev @usine/gateway
19
+ ```
20
+
21
+ To run an unreleased build, pack it in a usine checkout with the `@usine/core` it depends on, whose published version can lag, and install both tarballs with one `npm install`:
22
+
23
+ ```sh
24
+ pnpm build
25
+ pnpm --filter @usine/core --filter @usine/gateway pack --pack-destination .
26
+ npm install --omit=dev ./usine-core-<version>.tgz ./usine-gateway-<version>.tgz
27
+ ```
28
+
29
+ On Hetzner Cloud, `gateway.sh install` does either on the gateway VM and restarts the service; see [`infra/hetzner/gateway/README.md`](../../infra/hetzner/gateway/README.md).
30
+
31
+ ## Entry points
32
+
33
+ | Import | Contents |
34
+ | --- | --- |
35
+ | `@usine/gateway` | The platform-free core: `GatewaySettings`, `SettingsInForce` (the allowlist and LLM providers that a reload replaces), `Attachments`, `CallerTokens`, `ChatGptLogins` (the ChatGPT logins and their refresh), `GatewayProxy` (the reverse proxy as an `HttpServerRequest` handler), `adminRoutes` (the admin API for an `HttpRouter`), `gatewayServices`, the egress allowlist parser, and the domain records. It needs an `HttpClient` that leaves `content-encoding` alone, and a `FileSystem` for ChatGPT auth files. |
36
+ | `@usine/gateway/admin` | `GatewayAdminApi`, the admin API contract for `HttpApiClient.make` in the executor, with the records a client sends and reads (`AttachRequest`, `InstallationCredential`, `Attached`, `AttachmentId`, `CallerToken`, `Detached`, `AttachmentList`, `LlmProviderList`) and `GATEWAY_TOKEN_HEADER`. |
37
+ | `@usine/gateway/node` | `gatewayLayer` (the three servers on Node, with undici upstream), `gatewayFromEnvironment`, `settingsFromEnvironment`, `GatewayAddresses`, and `GatewayReload`. |
38
+ | `usine-gateway` | The gateway VM's command. It reads the settings below, reloads on SIGHUP, and logs one JSON object per line. |
39
+
40
+ `GatewayDomain` is re-exported from `@usine/core/worker`; gateway settings and worker configuration share its lowercase DNS domain schema.
41
+
42
+ ## Attachments and caller tokens
43
+
44
+ At the start of an attempt, the executor attaches the attempt's installation token for the worker and repo, with its expiry:
45
+
46
+ ```http
47
+ POST /v1/attachments
48
+ Authorization: Bearer <admin token>
49
+
50
+ {"worker": "worker-one", "repository": "acme/demo",
51
+ "credential": {"_tag": "InstallationCredential", "token": "ghs_..."},
52
+ "expiresAt": "2026-10-13T18:00:00Z"}
53
+ ```
54
+
55
+ The response has the attachment without the token, including its assigned `id`, and a caller token, which the executor gives the attempt's children in place of every credential. The caller token names the attachment id, worker, and repo, and is signed with the gateway's caller key. Each attempt gets its own attachment, even on a shared worker and repo.
56
+
57
+ Before the installation token expires, the executor renews with `PUT /v1/attachments/<id>` and the same body with the new token. The response has the same caller token, so children keep working, and after a gateway restart the renewal stores the attachment again under its id. `DELETE /v1/attachments/<id>` detaches; `GET /v1/attachments` lists attachments in force.
58
+
59
+ A caller token works only while its attachment is in force. After a detach or token expiry, every request with it is refused, LLM requests included, and a new attach for the same worker and repo gets a new id, so the old token stays refused (`stale-caller-token`). A request whose path names another repo than the token is refused. An `AnonymousCredential` attachment adds no credential, which is enough to read a public repo.
60
+
61
+ A child sends its caller token wherever its tool sends a credential: the `X-Usine-Gateway-Token` header, `Authorization` as `Bearer`, `token`, or `Basic` (password), or `x-api-key`. The gateway removes all of them before forwarding.
62
+
63
+ ## GitHub paths
64
+
65
+ `git.<domain>` serves these paths with the attachment's installation token. A path that names another repo than the caller token is refused with `repository-mismatch`, unless it's the repo's new name after a rename (see below); other paths, such as web pages, with `unsupported-path`. Paths that name no repo, such as `/api/graphql`, `/api/v3/user`, and `/api/v3/repositories/ID/...`, have no repo for the gateway to check: the installation token, issued for the attempt's repo only, limits them.
66
+
67
+ | Path on `git.<domain>` | Upstream | Token sent as |
68
+ | --- | --- | --- |
69
+ | `/OWNER/REPO.git/info/refs`, `/OWNER/REPO.git/git-upload-pack`, `/OWNER/REPO.git/git-receive-pack`, `/OWNER/REPO.git/info/lfs/...`, each also without `.git` | `https://github.com` | the `x-access-token` password |
70
+ | `/OWNER/REPO/releases/download/TAG/FILE`, `/OWNER/REPO/releases/latest/download/FILE` | `https://github.com` | the `x-access-token` password |
71
+ | `/api/v3/...`, `/api/graphql` | `https://api.github.com` | a bearer token |
72
+ | `/api/uploads/...` | `https://uploads.github.com` | a bearer token |
73
+
74
+ GitHub answers a release download with a redirect to an asset host, such as `release-assets.githubusercontent.com` or `objects.githubusercontent.com`, in a URL signed for that file for a few minutes. The gateway returns the redirect unchanged and doesn't follow it, so the token goes only to github.com. The worker fetches the asset through the forward proxy, which needs the asset hosts on the egress allowlist: the GitHub release assets rows of [`egress-allowlist`](../../infra/hetzner/gateway/egress-allowlist). On a worker, where `GH_TOKEN` holds the caller token:
75
+
76
+ ```sh
77
+ curl -fL -u ":$GH_TOKEN" -o tool.tar.gz http://git.<domain>/OWNER/REPO/releases/download/TAG/FILE
78
+ ```
79
+
80
+ curl sends `-u` credentials only to the host of the first URL; a header set with `-H` would also reach the asset host.
81
+
82
+ gh can't download from these hosts. It sends every request to the gateway through its socket, followed redirects included, so `gh release download` reaches the reverse proxy for the asset host, which refuses it with `unknown-host`. For a host on the egress allowlist, the refusal says to use curl instead: the command above for a release asset, or this one for an archive, which GitHub redirects to `codeload.github.com`:
83
+
84
+ ```sh
85
+ curl -fL -u ":$GH_TOKEN" -o repo.tar.gz http://git.<domain>/api/v3/repos/OWNER/REPO/tarball/REF
86
+ ```
87
+
88
+ Actions logs and artifacts redirect to `results-receiver.actions.githubusercontent.com` and Azure Blob Storage, which the allowlist doesn't list, so `gh run view --log`, `gh run view --log-failed`, and `gh run download` don't work on a worker, with gh or curl ([#540](https://github.com/basaltbytes/usine/issues/540)). [ADR 0027](../../docs/adr/0027-gateway-credential-proxy.md) records why the reverse proxy doesn't fetch other hosts.
89
+
90
+ GitHub redirects some requests for a renamed or transferred repo: API calls to `https://api.github.com/repositories/ID/...`, and release downloads to the new name on github.com. The gateway rewrites a `Location` on github.com or api.github.com to `http://git.<domain>`, with API paths under `/api/v3`, and keeps the path and query, so the client follows it through the gateway. A `Location` on any other host passes unchanged.
91
+
92
+ The gateway checks the followed request like any other. An attachment learns the repo's new name only from a permanent redirect (301 or 308) of the repo itself to the same path with another owner or repo name: git's `/OWNER/REPO/info/refs`, which starts every clone and fetch, or the API's `/repos/OWNER/REPO`. Before the attachment learns the name, the gateway confirms it with GitHub's API and the attachment's credential: `/repos/OWNER/REPO` with the attached name must redirect to `/repositories/ID`, and the `full_name` of that repo must be the new name. The attachment then serves paths with either name until it's detached or expires. Other attachments, temporary redirects, redirects of anything else, such as a release download or `/repositories/ID/...`, and names that the API doesn't confirm teach no name, so a path with another name is still refused with `repository-mismatch`. A release download under the repo's former name works once a git clone or fetch under that name has taught the attachment the new name.
93
+
94
+ ## LLM providers
95
+
96
+ `GET /v1/providers` lists the providers the gateway has a credential for, with each credential's kind, never the credential:
97
+
98
+ ```json
99
+ {"providers": [{"provider": "anthropic", "credential": "api-key"}]}
100
+ ```
101
+
102
+ `credential` is `api-key` for `ANTHROPIC_API_KEY` and `OPENAI_API_KEY`, and `subscription` for `USINE_GATEWAY_ANTHROPIC_SUBSCRIPTION_TOKEN` and `USINE_GATEWAY_CHATGPT_AUTH_FILE`. Providers without a credential are absent. The list follows a [reload](#reload). The executor reads it when it checks a worker whose `llm_providers` lists a provider, so usage counts Claude Code and Codex children on the gateway's API key in the API keys pool, and on a subscription in the Claude or ChatGPT plan pool ([ADR 0013](../../docs/adr/0013-cost-accounting-native-meters-guardrails.md)). Gateways released before this endpoint answer 404.
103
+
104
+ ## Worker environment
105
+
106
+ `infra/hetzner/gateway/worker-cloud-init.yaml` sets up the machine. For each attempt, the executor adds the caller token and sends each variable that contains it over the SSH connection, not the command line; the worker's sshd accepts them with `AcceptEnv`. The Claude Code row applies if the gateway has the Anthropic credential, and the Codex row if it has the OpenAI credential; otherwise the harness uses its login on the worker and `HTTPS_PROXY`.
107
+
108
+ | Tool | Setting |
109
+ | --- | --- |
110
+ | git | remote `http://git.<domain>/OWNER/REPO.git`; `GIT_CONFIG_KEY_0=http.http://git.<domain>/.extraHeader`, `GIT_CONFIG_VALUE_0="X-Usine-Gateway-Token: <caller token>"`, and `url.http://git.<domain>/.insteadOf` for `https://github.com/` and `https://git.<domain>/` |
111
+ | gh | `GH_HOST=git.<domain>`, `GH_ENTERPRISE_TOKEN=<caller token>`, `GH_TOKEN=<caller token>`; gh's config sets `http_unix_socket: /run/usine-gateway/http.sock`, which `systemd-socket-proxyd` forwards to the gateway, because gh speaks only HTTPS to a custom host but plain HTTP over a socket |
112
+ | Claude Code | `ANTHROPIC_BASE_URL=http://llm.<domain>/anthropic`, `ANTHROPIC_AUTH_TOKEN=<caller token>` |
113
+ | Codex | `CODEX_HOME=<root>/workers/<worker>/codex/<attempt>`, whose `config.toml` sets `model_provider = "usine-gateway"` with `base_url = "http://llm.<domain>/openai/v1"`, `env_key = "USINE_GATEWAY_TOKEN"`, `wire_api = "responses"`, and `requires_openai_auth = false`; `USINE_GATEWAY_TOKEN=<caller token>` |
114
+ | package managers, harness logins | `HTTPS_PROXY` and `HTTP_PROXY` set to `http://<gateway>:3128`, `NO_PROXY=<domain>` |
115
+
116
+ Codex with this `model_provider` sends its caller token as a bearer token, needs no login, and calls only `POST <base_url>/responses` over HTTP with server-sent events, since a custom provider uses neither the Responses WebSocket nor the remote model list. The same configuration works whether the gateway holds an API key or a ChatGPT login.
117
+
118
+ A client that sends every request through `HTTPS_PROXY`, as Claude Code does, still reaches `git.<domain>` and `llm.<domain>`: the forward proxy sends their port 80 requests to the reverse proxy.
119
+
120
+ ## Configuration
121
+
122
+ The [CLI reference](https://docs.usine.dev/reference/configuration/cli/) lists every `usine-gateway` variable, with defaults and the environment file rules. Changing `USINE_GATEWAY_CALLER_KEY` invalidates every caller token.
123
+
124
+ Each provider holds one credential; setting both of a provider's variables is an error.
125
+
126
+ | Provider | Variable | Credential | Sent upstream as |
127
+ | --- | --- | --- | --- |
128
+ | `anthropic` | `ANTHROPIC_API_KEY` | An Anthropic API key | `x-api-key` |
129
+ | `anthropic` | `USINE_GATEWAY_ANTHROPIC_SUBSCRIPTION_TOKEN` | A Claude subscription token from `claude setup-token` | A bearer token, with the OAuth beta flag merged into `anthropic-beta` |
130
+ | `openai` | `OPENAI_API_KEY` | An OpenAI-compatible API key | A bearer token |
131
+ | `openai` | `USINE_GATEWAY_CHATGPT_AUTH_FILE` | The absolute path of a Codex `auth.json` with a ChatGPT login | A bearer token with the login's access token, and `ChatGPT-Account-ID`; see [ChatGPT login](#chatgpt-login) |
132
+
133
+ `USINE_GATEWAY_ANTHROPIC_UPSTREAM` (default `https://api.anthropic.com`) is the Anthropic upstream. `USINE_GATEWAY_OPENAI_UPSTREAM` (default `https://api.openai.com`) is the upstream for an OpenAI API key and can be any OpenAI-compatible provider, and `USINE_GATEWAY_CHATGPT_UPSTREAM` (default `https://chatgpt.com/backend-api/codex`) the one for a ChatGPT login. A provider without a credential is refused with `provider-not-configured`.
134
+
135
+ ### LLM routes
136
+
137
+ Each provider serves only the routes its harness calls, whatever the credential, so a worker can't use the gateway's credential for the rest of the provider's API, such as files, batches, or organization administration. A route matches whole path segments, so a percent-encoded separator (`%2F`, `%5C`), a doubled or trailing `/`, or a dot segment, which URL parsing resolves first, never reaches the upstream. Other paths are refused with `unsupported-path` before any credential is used or refreshed.
138
+
139
+ | Prefix | Routes | Harness |
140
+ | --- | --- | --- |
141
+ | `/anthropic` | `/v1/messages`, `/v1/messages/count_tokens`, `/v1/models`, `/v1/models/MODEL` | Claude Code |
142
+ | `/openai` | `/v1/responses`, `/v1/responses/compact`, `/v1/models` | Codex |
143
+
144
+ The query string passes unchanged, such as Claude Code's `?beta=true`.
145
+
146
+ ## Claude subscription token
147
+
148
+ A `claude setup-token` token is valid for one year and has no refresh token, so the gateway doesn't refresh it ([authentication](https://code.claude.com/docs/en/authentication)). Before it expires, run `claude setup-token` again, replace `USINE_GATEWAY_ANTHROPIC_SUBSCRIPTION_TOKEN`, and reload. The token is opaque, so the gateway can't tell when it expires; once it has, Anthropic refuses each request and Claude Code reports the error. Run the placeholder swap test in [Tests](#tests) before you rely on the subscription token.
149
+
150
+ ## ChatGPT login
151
+
152
+ With `USINE_GATEWAY_CHATGPT_AUTH_FILE`, the gateway serves Codex's [LLM routes](#llm-routes) under `/openai` on `llm.<domain>` from the ChatGPT backend, which has them without `/v1`: Codex's `/openai/v1/responses` goes to `https://chatgpt.com/backend-api/codex/responses`. The gateway replaces the caller token with `Authorization: Bearer <access token>`, sets `ChatGPT-Account-ID` to the login's account, and passes Codex's other headers, such as `originator` and `session-id`, and the streamed body unchanged.
153
+
154
+ The file is the `auth.json` that `codex login` writes in `CODEX_HOME`. The gateway reads its `tokens.access_token`, `tokens.refresh_token`, and `tokens.account_id` (or the account in `tokens.id_token`) on first use and on each [reload](#reload), and keeps them in memory. It refreshes the access token as Codex does:
155
+
156
+ - When the access token expires within five minutes, or the backend answered a request with 401, the next request first sends the refresh token to `https://auth.openai.com/oauth/token` with Codex's client ID. A 401 for a token refreshed less than a minute earlier doesn't start another refresh.
157
+ - Refreshes run one at a time, and requests that arrive during a refresh wait for its result. A refresh runs apart from the request that started it, so a request that ends early doesn't stop it between the rotation and the write-back.
158
+ - The response's new access token, rotated refresh token, and ID token go to the file, through a temporary file with mode 0600 in the same directory that replaces it, so the service needs write access to the directory. The file's other fields stay. If the file holds a login the gateway didn't refresh, such as one you just copied in, it's left for the next reload.
159
+ - If a refresh fails, the waiting requests are refused with `provider-login-unavailable`, and nothing is sent upstream with the old token. After a network error, a server error, a 408, or a 429 from the token endpoint, the refusal is 503, which Codex retries: requests fail without calling the token endpoint for ten seconds, then the next one tries again. Any other client error, such as `invalid_grant`, or the codes `refresh_token_expired`, `refresh_token_reused`, and `refresh_token_invalidated`, rejects the refresh token: the refusal is 401, which Codex doesn't retry, and the gateway doesn't try again until a reload reads another login. An auth file that can't be read is refused with 503. Refusals give a fixed message and never the token endpoint's text; the log has its error code.
160
+
161
+ At startup and each reload, the gateway reads the file and checks that it can write next to it, and fails or keeps the previous providers if it can't. A reload keeps the tokens in memory if the file has their refresh token or one they replaced, so it never goes back to a used refresh token, and otherwise uses the file's login.
162
+
163
+ | Log line | Level | When |
164
+ | --- | --- | --- |
165
+ | `Loaded the ChatGPT login` | info | The file was read, with `authFile`, the access token's `expiresAt`, and `replaced` on a reload |
166
+ | `Refreshing the ChatGPT login`, `Refreshed the ChatGPT login` | info | A refresh started (`reason` is `expiring` or `refused-by-upstream`) and ended, with `rotated` |
167
+ | `Wrote the refreshed ChatGPT login to its auth file` | info | The rotated tokens are in the file |
168
+ | `Left the auth file as it is: it holds another ChatGPT login` | warning | The file has a login the gateway didn't refresh; reload to use it |
169
+ | `Could not write the refreshed ChatGPT login to its auth file` | error | The login works from memory, but after a restart the file's refresh token may no longer work |
170
+ | `Could not refresh the ChatGPT login; a later request tries again` | error | A network error or server error, with `error` and the `backoff` |
171
+ | `The token endpoint rejected the ChatGPT login's refresh token` | error | The login needs a new `auth.json` and a reload |
172
+ | `The ChatGPT backend refused the gateway's access token` | warning | The backend answered 401; the next request refreshes first, unless the token is less than a minute old |
173
+
174
+ Make the gateway's login separate from the one on your own machine. A refresh token works once, so if two copies of one `auth.json` refresh, the second is rejected with `refresh_token_reused`. [`infra/hetzner/gateway/README.md`](../../infra/hetzner/gateway/README.md) gives the steps.
175
+
176
+ ## Reload
177
+
178
+ On SIGHUP, `usine-gateway` rereads its environment files and allowlist, and replaces the egress allowlist and the LLM provider settings: `ANTHROPIC_API_KEY`, `USINE_GATEWAY_ANTHROPIC_SUBSCRIPTION_TOKEN`, `OPENAI_API_KEY`, `USINE_GATEWAY_CHATGPT_AUTH_FILE`, the three upstreams, and `USINE_GATEWAY_EGRESS_ALLOWLIST`. It also rereads the ChatGPT auth file, so copying a new `auth.json` over the old one and reloading replaces the login. Under systemd, `ExecReload=kill -HUP $MAINPID` makes `systemctl reload usine-gateway` send it.
179
+
180
+ Attachments, the admin API, listening sockets, and open tunnels stay, so running attempts keep their GitHub and LLM requests. An open tunnel stays until a side closes it; each new `CONNECT` or plain HTTP request uses the new allowlist, and each later LLM request the new key. The domain, listeners, admin token, and caller key change only with a restart. A reload that finds one of them changed logs `Settings changed that take effect only after a restart` at warning level with the variable names, and applies the rest.
181
+
182
+ Each reload ends with `Reloaded the gateway settings`, which gives the environment files, the allowlist path and rule count, whether the allowlist and providers were reloaded or kept, and the names, never values, of changed provider settings (`changed`). Unusable input keeps what's in force:
183
+
184
+ | Problem | The reload keeps | Log line, at error level |
185
+ | --- | --- | --- |
186
+ | The allowlist can't be read or doesn't parse | The previous allowlist | `Kept the previous egress allowlist`, with the path, the line, and the problem |
187
+ | A provider setting is invalid, both of a provider's credentials are set, or the ChatGPT auth file can't be read or isn't a ChatGPT login | The previous providers | `Kept the previous LLM providers` |
188
+ | An environment file can't be read | Everything | `Kept the gateway settings: an environment file could not be read`, with the path |
189
+
190
+ `systemctl reload` returns before the reload ends; the journal has the result. Variables from the process environment at startup don't change, so a gateway without environment files reloads only its allowlist.
191
+
192
+ ## Tests
193
+
194
+ `pnpm --filter @usine/gateway test` runs the unit tests and an end-to-end test that starts the gateway on loopback ports, clones `octocat/Hello-World` through it, calls `gh api` through a Unix socket as a worker does, and opens a tunnel to `registry.npmjs.org`. It needs network access but no secret.
195
+
196
+ The unit tests for the ChatGPT login (`test/chatgpt.test.ts`) use stand-ins for the token endpoint and the ChatGPT backend: refresh timing, write-back of rotated tokens, one refresh for concurrent requests, failed and rejected refreshes, header rewriting, refused paths, and streaming through the Node gateway.
197
+
198
+ `pnpm --filter @usine/gateway test:placeholder-swap` runs the harnesses themselves against the gateway and local stand-ins, so CI, which has neither binary, doesn't run it. Run it on a machine with the harnesses on `PATH` before you rely on a subscription credential at the gateway:
199
+
200
+ - Claude Code (needs `claude`): the worker has only a caller token as `CLAUDE_CODE_OAUTH_TOKEN`, and the gateway sends a subscription token instead. The first case runs Claude Code against a local stand-in for the Anthropic API and checks what reached it. The second runs against `api.anthropic.com` with the real token in `CLAUDE_CODE_OAUTH_TOKEN`, and is skipped without it.
201
+ - Codex (needs `codex`): `codex exec` runs with the `model_provider` the executor writes and only a caller token, against a stand-in for the ChatGPT backend and its token endpoint. The login's access token expires within a minute, so the case checks that the gateway refreshes it, sends the new token and the account to the backend, and writes the rotated refresh token to the auth file. Nothing reaches OpenAI.
@@ -0,0 +1,146 @@
1
+ import { Schema } from "effect";
2
+ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiMiddleware, HttpApiSecurity } from "effect/http-api";
3
+ import { type Attachment, AttachmentAlreadyExpired, AttachmentConflict } from "../attachment/attachment.ts";
4
+ import { type LlmProviders } from "../llm/llm.ts";
5
+ export { AttachmentId, InstallationCredential } from "../attachment/attachment.ts";
6
+ export { CallerToken, GATEWAY_TOKEN_HEADER } from "../caller/caller.ts";
7
+ declare const AttachRequest_base: Schema.Class<AttachRequest, Schema.Struct<{
8
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
9
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
10
+ readonly credential: Schema.Union<readonly [typeof import("../attachment/attachment.ts").InstallationCredential, typeof import("../attachment/attachment.ts").AnonymousCredential]>;
11
+ readonly expiresAt: Schema.DateTimeUtc;
12
+ }>, {}>;
13
+ /** A request to attach one Attempt's credential for one Worker and repository until it expires. */
14
+ export declare class AttachRequest extends AttachRequest_base {
15
+ }
16
+ declare const AttachmentSummary_base: Schema.Class<AttachmentSummary, Schema.Struct<{
17
+ readonly id: Schema.brand<Schema.String, "AttachmentId">;
18
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
19
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
20
+ readonly credential: Schema.Literals<readonly ["InstallationCredential", "AnonymousCredential"]>;
21
+ readonly expiresAt: Schema.DateTimeUtc;
22
+ }>, {}>;
23
+ /** An attachment without its token. */
24
+ export declare class AttachmentSummary extends AttachmentSummary_base {
25
+ static fromAttachment(attachment: Attachment): AttachmentSummary;
26
+ }
27
+ declare const Attached_base: Schema.Class<Attached, Schema.Struct<{
28
+ readonly attachment: typeof AttachmentSummary;
29
+ readonly callerToken: Schema.Redacted<Schema.NonEmptyString>;
30
+ }>, {}>;
31
+ /**
32
+ * The response to an attach or renew: the attachment and the Worker VM's caller token.
33
+ * Renewing the same attachment ID returns the same token.
34
+ */
35
+ export declare class Attached extends Attached_base {
36
+ }
37
+ declare const Detached_base: Schema.Class<Detached, Schema.Struct<{
38
+ readonly detached: Schema.Boolean;
39
+ }>, {}>;
40
+ /** The response to a detach. `detached` is whether the attachment existed. */
41
+ export declare class Detached extends Detached_base {
42
+ }
43
+ declare const AttachmentList_base: Schema.Class<AttachmentList, Schema.Struct<{
44
+ readonly attachments: Schema.$Array<typeof AttachmentSummary>;
45
+ }>, {}>;
46
+ export declare class AttachmentList extends AttachmentList_base {
47
+ }
48
+ declare const LlmProviderSummary_base: Schema.Class<LlmProviderSummary, Schema.Struct<{
49
+ readonly provider: Schema.Literals<readonly ["anthropic", "openai"]>;
50
+ readonly credential: Schema.Literals<readonly ["api-key", "subscription"]>;
51
+ }>, {}>;
52
+ /** An LLM provider the Gateway has a credential for, with the credential kind but not the credential. */
53
+ export declare class LlmProviderSummary extends LlmProviderSummary_base {
54
+ }
55
+ declare const LlmProviderList_base: Schema.Class<LlmProviderList, Schema.Struct<{
56
+ readonly providers: Schema.$Array<typeof LlmProviderSummary>;
57
+ }>, {}>;
58
+ /** The LLM providers the Gateway forwards to, with each credential kind. Providers without a credential aren't listed. */
59
+ export declare class LlmProviderList extends LlmProviderList_base {
60
+ static fromProviders({ anthropic, openai }: LlmProviders): LlmProviderList;
61
+ }
62
+ declare const AdminAuthorization_base: HttpApiMiddleware.ServiceClass<AdminAuthorization, "@usine/gateway/AdminAuthorization", {
63
+ requires: never;
64
+ provides: never;
65
+ error: typeof HttpApiError.Unauthorized;
66
+ clientError: never;
67
+ requiredForClient: false;
68
+ security: {
69
+ readonly bearer: HttpApiSecurity.Http;
70
+ };
71
+ }, HttpApiMiddleware.HttpApiMiddlewareSecurity<{
72
+ readonly bearer: HttpApiSecurity.Http;
73
+ }, never, typeof HttpApiError.Unauthorized, never>>;
74
+ /**
75
+ * Middleware that requires the admin token as a bearer token on every admin request, and
76
+ * responds with 401 otherwise. Clients add the header themselves, for example with
77
+ * `HttpClientRequest.bearerToken`.
78
+ */
79
+ export declare class AdminAuthorization extends AdminAuthorization_base {
80
+ }
81
+ /**
82
+ * The admin API the executor uses to attach, renew, and detach a Worker VM's GitHub credential
83
+ * for an Attempt, and to read the Gateway's LLM provider credential kinds (ADR 0013).
84
+ * Attachments are in memory; after a Gateway restart, the executor's next renewal re-creates
85
+ * each one under the same ID, so caller tokens stay valid.
86
+ */
87
+ export declare const GatewayAdminApi: HttpApi.HttpApi<"GatewayAdminApi", HttpApiGroup.HttpApiGroup<"attachments", HttpApiEndpoint.HttpApiEndpoint<"attach", "POST", "/v1/attachments", never, never, Schema.toCodecJson<typeof AttachRequest>, never, Schema.toCodecJson<typeof Attached>, Schema.toCodecJson<Schema.decodeTo<Schema.declareConstructor<AttachmentAlreadyExpired, {
88
+ readonly _tag: "AttachmentAlreadyExpired";
89
+ readonly worker: string;
90
+ readonly repository: string;
91
+ readonly expiresAt: import("effect/DateTime").Utc;
92
+ }, readonly [Schema.TaggedStruct<"AttachmentAlreadyExpired", {
93
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
94
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
95
+ readonly expiresAt: Schema.DateTimeUtc;
96
+ }>], {
97
+ readonly _tag: "AttachmentAlreadyExpired";
98
+ readonly worker: string & import("effect/Brand").Brand<"WorkerName">;
99
+ readonly repository: string & import("effect/Brand").Brand<"RepositoryName">;
100
+ readonly expiresAt: import("effect/DateTime").Utc;
101
+ }>, Schema.TaggedStruct<"AttachmentAlreadyExpired", {
102
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
103
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
104
+ readonly expiresAt: Schema.DateTimeUtc;
105
+ }>, never, never>>, AdminAuthorization, never> | HttpApiEndpoint.HttpApiEndpoint<"detach", "DELETE", "/v1/attachments/:id", Schema.toCodecStringTree<Schema.Struct<{
106
+ id: Schema.brand<Schema.String, "AttachmentId">;
107
+ }>>, never, never, never, Schema.toCodecJson<typeof Detached>, never, AdminAuthorization, never> | HttpApiEndpoint.HttpApiEndpoint<"list", "GET", "/v1/attachments", never, never, never, never, Schema.toCodecJson<typeof AttachmentList>, never, AdminAuthorization, never> | HttpApiEndpoint.HttpApiEndpoint<"renew", "PUT", "/v1/attachments/:id", Schema.toCodecStringTree<Schema.Struct<{
108
+ id: Schema.brand<Schema.String, "AttachmentId">;
109
+ }>>, never, Schema.toCodecJson<typeof AttachRequest>, never, Schema.toCodecJson<typeof Attached>, Schema.toCodecJson<Schema.decodeTo<Schema.declareConstructor<AttachmentAlreadyExpired, {
110
+ readonly _tag: "AttachmentAlreadyExpired";
111
+ readonly worker: string;
112
+ readonly repository: string;
113
+ readonly expiresAt: import("effect/DateTime").Utc;
114
+ }, readonly [Schema.TaggedStruct<"AttachmentAlreadyExpired", {
115
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
116
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
117
+ readonly expiresAt: Schema.DateTimeUtc;
118
+ }>], {
119
+ readonly _tag: "AttachmentAlreadyExpired";
120
+ readonly worker: string & import("effect/Brand").Brand<"WorkerName">;
121
+ readonly repository: string & import("effect/Brand").Brand<"RepositoryName">;
122
+ readonly expiresAt: import("effect/DateTime").Utc;
123
+ }>, Schema.TaggedStruct<"AttachmentAlreadyExpired", {
124
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
125
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
126
+ readonly expiresAt: Schema.DateTimeUtc;
127
+ }>, never, never> | Schema.decodeTo<Schema.declareConstructor<AttachmentConflict, {
128
+ readonly _tag: "AttachmentConflict";
129
+ readonly id: string;
130
+ readonly worker: string;
131
+ readonly repository: string;
132
+ }, readonly [Schema.TaggedStruct<"AttachmentConflict", {
133
+ readonly id: Schema.brand<Schema.String, "AttachmentId">;
134
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
135
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
136
+ }>], {
137
+ readonly _tag: "AttachmentConflict";
138
+ readonly id: string & import("effect/Brand").Brand<"AttachmentId">;
139
+ readonly worker: string & import("effect/Brand").Brand<"WorkerName">;
140
+ readonly repository: string & import("effect/Brand").Brand<"RepositoryName">;
141
+ }>, Schema.TaggedStruct<"AttachmentConflict", {
142
+ readonly id: Schema.brand<Schema.String, "AttachmentId">;
143
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
144
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
145
+ }>, never, never>>, AdminAuthorization, never>, false> | HttpApiGroup.HttpApiGroup<"llm", HttpApiEndpoint.HttpApiEndpoint<"providers", "GET", "/v1/providers", never, never, never, never, Schema.toCodecJson<typeof LlmProviderList>, never, AdminAuthorization, never>, false>>;
146
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/admin/api.ts"],"names":[],"mappings":"AAEA,OAAO,EAAU,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EACL,OAAO,EACP,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,eAAe,EAChB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,KAAK,UAAU,EACf,wBAAwB,EACxB,kBAAkB,EAGnB,MAAM,6BAA6B,CAAC;AAErC,OAAO,EAIL,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AACnF,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;;;;;;;AAExE,mGAAmG;AACnG,qBAAa,aAAc,SAAQ,kBAKjC;CAAG;;;;;;;;AAEL,uCAAuC;AACvC,qBAAa,iBAAkB,SAAQ,sBAQrC;IACA,MAAM,CAAC,cAAc,CAAC,UAAU,EAAE,UAAU,GAAG,iBAAiB,CAQ/D;CACF;;;;;AAED;;;GAGG;AACH,qBAAa,QAAS,SAAQ,aAG5B;CAAG;;;;AAEL,8EAA8E;AAC9E,qBAAa,QAAS,SAAQ,aAE5B;CAAG;;;;AAEL,qBAAa,cAAe,SAAQ,mBAElC;CAAG;;;;;AAEL,yGAAyG;AACzG,qBAAa,kBAAmB,SAAQ,uBAKtC;CAAG;;;;AAEL,0HAA0H;AAC1H,qBAAa,eAAgB,SAAQ,oBAInC;IACA,MAAM,CAAC,aAAa,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,YAAY,GAAG,eAAe,CAWzE;CACF;;;;;;;;;;;;;AAED;;;;GAIG;AACH,qBAAa,kBAAmB,SAAQ,uBAGvC;CAAG;AAEJ;;;;;GAKG;AACH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iRA4B3B,CAAC"}
@@ -0,0 +1,105 @@
1
+ import { RepositoryName } from "@usine/core/repository";
2
+ import { GatewayCredentialKind, WorkerName } from "@usine/core/worker";
3
+ import { Option, Schema } from "effect";
4
+ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiMiddleware, HttpApiSecurity, } from "effect/http-api";
5
+ import { AttachmentAlreadyExpired, AttachmentConflict, AttachmentCredential, AttachmentId, } from "../attachment/attachment.js";
6
+ import { CallerToken } from "../caller/caller.js";
7
+ import { credentialKind, LlmProviderName, } from "../llm/llm.js";
8
+ export { AttachmentId, InstallationCredential } from "../attachment/attachment.js";
9
+ export { CallerToken, GATEWAY_TOKEN_HEADER } from "../caller/caller.js";
10
+ /** A request to attach one Attempt's credential for one Worker and repository until it expires. */
11
+ export class AttachRequest extends Schema.Class("@usine/gateway/AttachRequest")({
12
+ worker: WorkerName,
13
+ repository: RepositoryName,
14
+ credential: AttachmentCredential,
15
+ expiresAt: Schema.DateTimeUtc,
16
+ }) {
17
+ }
18
+ /** An attachment without its token. */
19
+ export class AttachmentSummary extends Schema.Class("@usine/gateway/AttachmentSummary")({
20
+ id: AttachmentId,
21
+ worker: WorkerName,
22
+ repository: RepositoryName,
23
+ credential: Schema.Literals(["InstallationCredential", "AnonymousCredential"]),
24
+ expiresAt: Schema.DateTimeUtc,
25
+ }) {
26
+ static fromAttachment(attachment) {
27
+ return new AttachmentSummary({
28
+ id: attachment.id,
29
+ worker: attachment.worker,
30
+ repository: attachment.repository,
31
+ credential: attachment.credential._tag,
32
+ expiresAt: attachment.expiresAt,
33
+ });
34
+ }
35
+ }
36
+ /**
37
+ * The response to an attach or renew: the attachment and the Worker VM's caller token.
38
+ * Renewing the same attachment ID returns the same token.
39
+ */
40
+ export class Attached extends Schema.Class("@usine/gateway/Attached")({
41
+ attachment: AttachmentSummary,
42
+ callerToken: CallerToken,
43
+ }) {
44
+ }
45
+ /** The response to a detach. `detached` is whether the attachment existed. */
46
+ export class Detached extends Schema.Class("@usine/gateway/Detached")({
47
+ detached: Schema.Boolean,
48
+ }) {
49
+ }
50
+ export class AttachmentList extends Schema.Class("@usine/gateway/AttachmentList")({
51
+ attachments: Schema.Array(AttachmentSummary),
52
+ }) {
53
+ }
54
+ /** An LLM provider the Gateway has a credential for, with the credential kind but not the credential. */
55
+ export class LlmProviderSummary extends Schema.Class("@usine/gateway/LlmProviderSummary")({
56
+ provider: LlmProviderName,
57
+ credential: GatewayCredentialKind,
58
+ }) {
59
+ }
60
+ /** The LLM providers the Gateway forwards to, with each credential kind. Providers without a credential aren't listed. */
61
+ export class LlmProviderList extends Schema.Class("@usine/gateway/LlmProviderList")({
62
+ providers: Schema.Array(LlmProviderSummary),
63
+ }) {
64
+ static fromProviders({ anthropic, openai }) {
65
+ const listed = (provider, configured) => Option.toArray(Option.map(configured, (held) => new LlmProviderSummary({ provider, credential: credentialKind(held) })));
66
+ return new LlmProviderList({
67
+ providers: [...listed("anthropic", anthropic), ...listed("openai", openai)],
68
+ });
69
+ }
70
+ }
71
+ /**
72
+ * Middleware that requires the admin token as a bearer token on every admin request, and
73
+ * responds with 401 otherwise. Clients add the header themselves, for example with
74
+ * `HttpClientRequest.bearerToken`.
75
+ */
76
+ export class AdminAuthorization extends HttpApiMiddleware.Service()("@usine/gateway/AdminAuthorization", { error: HttpApiError.Unauthorized, security: { bearer: HttpApiSecurity.bearer } }) {
77
+ }
78
+ /**
79
+ * The admin API the executor uses to attach, renew, and detach a Worker VM's GitHub credential
80
+ * for an Attempt, and to read the Gateway's LLM provider credential kinds (ADR 0013).
81
+ * Attachments are in memory; after a Gateway restart, the executor's next renewal re-creates
82
+ * each one under the same ID, so caller tokens stay valid.
83
+ */
84
+ export const GatewayAdminApi = HttpApi.make("GatewayAdminApi").add(HttpApiGroup.make("attachments")
85
+ .add(HttpApiEndpoint.post("attach", "/v1/attachments", {
86
+ payload: AttachRequest,
87
+ success: Attached,
88
+ error: AttachmentAlreadyExpired.annotate({ httpApiStatus: 422 }),
89
+ }), HttpApiEndpoint.put("renew", "/v1/attachments/:id", {
90
+ params: { id: AttachmentId },
91
+ payload: AttachRequest,
92
+ success: Attached,
93
+ error: [
94
+ AttachmentAlreadyExpired.annotate({ httpApiStatus: 422 }),
95
+ AttachmentConflict.annotate({ httpApiStatus: 409 }),
96
+ ],
97
+ }), HttpApiEndpoint.delete("detach", "/v1/attachments/:id", {
98
+ params: { id: AttachmentId },
99
+ success: Detached,
100
+ }), HttpApiEndpoint.get("list", "/v1/attachments", { success: AttachmentList }))
101
+ .annotate(HttpApi.PayloadParseOptions, { onExcessProperty: "error" })
102
+ .middleware(AdminAuthorization), HttpApiGroup.make("llm")
103
+ .add(HttpApiEndpoint.get("providers", "/v1/providers", { success: LlmProviderList }))
104
+ .middleware(AdminAuthorization));
105
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/admin/api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import { Layer } from "effect";
2
+ import { Attachments } from "../attachment/store.ts";
3
+ import { CallerTokens } from "../caller/caller.ts";
4
+ import { GatewaySettings, SettingsInForce } from "../settings.ts";
5
+ /**
6
+ * The admin API routes for an `HttpRouter`. Bind them to a loopback or private address; the
7
+ * executor connects through SSH.
8
+ */
9
+ export declare const adminRoutes: Layer.Layer<never, never, import("effect/FileSystem").FileSystem | GatewaySettings | import("effect/http/Etag").Generator | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/HttpRouter").HttpRouter | import("effect/Path").Path | import("effect/http/HttpRouter").Request<"Requires", Attachments> | import("effect/http/HttpRouter").Request<"Requires", CallerTokens> | import("effect/http/HttpRouter").Request<"Requires", SettingsInForce>>;
10
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/admin/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,EAAY,MAAM,QAAQ,CAAC;AAGjD,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAgB,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AA8GlE;;;GAGG;AACH,eAAO,MAAM,WAAW,0cAGvB,CAAC"}
@@ -0,0 +1,79 @@
1
+ import { Effect, Layer, Redacted } from "effect";
2
+ import { HttpApiBuilder, HttpApiError } from "effect/http-api";
3
+ import { Attachment, newAttachmentId } from "../attachment/attachment.js";
4
+ import { Attachments } from "../attachment/store.js";
5
+ import { CallerClaims, CallerTokens } from "../caller/caller.js";
6
+ import { GatewaySettings, SettingsInForce } from "../settings.js";
7
+ import { AdminAuthorization, Attached, AttachmentList, AttachmentSummary, Detached, GatewayAdminApi, LlmProviderList, } from "./api.js";
8
+ const encoder = new TextEncoder();
9
+ const digest = (value) => Effect.promise(() => crypto.subtle.digest("SHA-256", encoder.encode(value))).pipe(Effect.map((bytes) => new Uint8Array(bytes)));
10
+ /** Returns whether two digests are equal, in constant time. */
11
+ const sameDigest = (left, right) => {
12
+ let difference = left.length ^ right.length;
13
+ for (let index = 0; index < Math.min(left.length, right.length); index++)
14
+ difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
15
+ return difference === 0;
16
+ };
17
+ const authorization = Layer.effect(AdminAuthorization, Effect.gen(function* () {
18
+ const { adminToken } = yield* GatewaySettings;
19
+ const expected = yield* digest(Redacted.value(adminToken));
20
+ return {
21
+ bearer: (httpEffect, { credential, endpoint }) => Effect.gen(function* () {
22
+ const presented = yield* digest(Redacted.value(credential));
23
+ if (!sameDigest(expected, presented)) {
24
+ yield* Effect.logWarning("Gateway refused an admin request", {
25
+ reason: "bad-admin-token",
26
+ endpoint: endpoint.identifier,
27
+ });
28
+ return yield* new HttpApiError.Unauthorized({});
29
+ }
30
+ return yield* httpEffect;
31
+ }),
32
+ };
33
+ }));
34
+ /** Stores the attachment under `id` and returns its caller token. */
35
+ const store = Effect.fn("GatewayAdmin.store")(function* (id, request) {
36
+ yield* Effect.annotateCurrentSpan({
37
+ attachment: id,
38
+ worker: request.worker,
39
+ repository: request.repository,
40
+ });
41
+ const attachments = yield* Attachments;
42
+ const callers = yield* CallerTokens;
43
+ const attachment = new Attachment({ id, ...request });
44
+ yield* attachments.attach(attachment);
45
+ const callerToken = yield* callers.issue(new CallerClaims({ attachment: id, worker: request.worker, repository: request.repository }));
46
+ return new Attached({ attachment: AttachmentSummary.fromAttachment(attachment), callerToken });
47
+ });
48
+ const attachmentHandlers = HttpApiBuilder.group(GatewayAdminApi, "attachments", (routes) => routes
49
+ .handle("attach", ({ payload }) => Effect.flatMap(newAttachmentId, (id) => store(id, payload)).pipe(
50
+ // A new 128-bit ID can't belong to another attachment.
51
+ Effect.catchTag("AttachmentConflict", Effect.die)))
52
+ .handle("renew", ({ params, payload }) => store(params.id, payload))
53
+ .handle("detach", Effect.fn("GatewayAdmin.detach")(function* ({ params }) {
54
+ yield* Effect.annotateCurrentSpan({ attachment: params.id });
55
+ const attachments = yield* Attachments;
56
+ return new Detached({ detached: yield* attachments.detach(params.id) });
57
+ }))
58
+ .handle("list", Effect.fn("GatewayAdmin.list")(function* () {
59
+ const attachments = yield* Attachments;
60
+ const current = yield* attachments.list();
61
+ return new AttachmentList({ attachments: current.map(AttachmentSummary.fromAttachment) });
62
+ })));
63
+ /** Handlers that return the current LLM providers, including changes from a reload. */
64
+ const llmHandlers = HttpApiBuilder.group(GatewayAdminApi, "llm", (routes) => routes.handle("providers", Effect.fn("GatewayAdmin.providers")(function* () {
65
+ const { providers } = yield* (yield* SettingsInForce).current;
66
+ const list = LlmProviderList.fromProviders(providers);
67
+ yield* Effect.annotateCurrentSpan({
68
+ providers: list.providers
69
+ .map(({ provider, credential }) => `${provider}:${credential}`)
70
+ .join(","),
71
+ });
72
+ return list;
73
+ })));
74
+ /**
75
+ * The admin API routes for an `HttpRouter`. Bind them to a loopback or private address; the
76
+ * executor connects through SSH.
77
+ */
78
+ export const adminRoutes = HttpApiBuilder.layer(GatewayAdminApi).pipe(Layer.provide([attachmentHandlers, llmHandlers]), Layer.provide(authorization));
79
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/admin/server.ts"],"names":[],"mappings":"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"}