ankka-console 0.10.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.
- package/README.md +32 -0
- package/dist/auth/oidc.d.ts +64 -0
- package/dist/auth/oidc.js +199 -0
- package/dist/client/control-plane.d.ts +67 -0
- package/dist/client/control-plane.js +208 -0
- package/dist/client/errors.d.ts +26 -0
- package/dist/client/errors.js +59 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +3 -0
- package/dist/client/schemas.d.ts +365 -0
- package/dist/client/schemas.js +221 -0
- package/dist/context.d.ts +83 -0
- package/dist/context.js +156 -0
- package/dist/extensions/render.d.ts +20 -0
- package/dist/extensions/render.js +49 -0
- package/dist/extensions/types.d.ts +44 -0
- package/dist/extensions/types.js +30 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/middleware.d.ts +14 -0
- package/dist/middleware.js +48 -0
- package/dist/options.d.ts +45 -0
- package/dist/options.js +39 -0
- package/dist/routes/auth.callback.d.ts +11 -0
- package/dist/routes/auth.callback.js +43 -0
- package/dist/routes/auth.sign-in.d.ts +11 -0
- package/dist/routes/auth.sign-in.js +29 -0
- package/dist/routes/auth.sign-out.d.ts +19 -0
- package/dist/routes/auth.sign-out.js +37 -0
- package/dist/routes/front.d.ts +41 -0
- package/dist/routes/front.js +25 -0
- package/dist/routes/logs.d.ts +67 -0
- package/dist/routes/logs.js +72 -0
- package/dist/routes/members.d.ts +45 -0
- package/dist/routes/members.js +59 -0
- package/dist/routes/organization-new.d.ts +14 -0
- package/dist/routes/organization-new.js +30 -0
- package/dist/routes/organization.d.ts +43 -0
- package/dist/routes/organization.js +82 -0
- package/dist/routes/project-new.d.ts +27 -0
- package/dist/routes/project-new.js +27 -0
- package/dist/routes/project.d.ts +59 -0
- package/dist/routes/project.js +61 -0
- package/dist/routes/service-apply.d.ts +44 -0
- package/dist/routes/service-apply.js +65 -0
- package/dist/routes/service.d.ts +71 -0
- package/dist/routes/service.js +73 -0
- package/dist/routes/stream.project.d.ts +3 -0
- package/dist/routes/stream.project.js +25 -0
- package/dist/routes/stream.service.d.ts +9 -0
- package/dist/routes/stream.service.js +68 -0
- package/dist/routes/tokens.d.ts +47 -0
- package/dist/routes/tokens.js +50 -0
- package/dist/routes.d.ts +28 -0
- package/dist/routes.js +48 -0
- package/dist/runtime.d.ts +28 -0
- package/dist/runtime.js +77 -0
- package/dist/server/create-console-server.d.ts +33 -0
- package/dist/server/create-console-server.js +203 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +13 -0
- package/dist/server/transport.d.ts +25 -0
- package/dist/server/transport.js +97 -0
- package/dist/session/cookie-store.d.ts +51 -0
- package/dist/session/cookie-store.js +66 -0
- package/dist/session/cookies.d.ts +15 -0
- package/dist/session/cookies.js +33 -0
- package/dist/session/seal.d.ts +12 -0
- package/dist/session/seal.js +38 -0
- package/dist/session/token-cache.d.ts +21 -0
- package/dist/session/token-cache.js +47 -0
- package/dist/session/token-source.d.ts +41 -0
- package/dist/session/token-source.js +63 -0
- package/dist/stream/log-follow.d.ts +18 -0
- package/dist/stream/log-follow.js +40 -0
- package/dist/stream/registry.d.ts +6 -0
- package/dist/stream/registry.js +34 -0
- package/dist/stream/sse.d.ts +14 -0
- package/dist/stream/sse.js +87 -0
- package/dist/styles.css +481 -0
- package/dist/testing/fake-control-plane.d.ts +161 -0
- package/dist/testing/fake-control-plane.js +715 -0
- package/dist/testing/fake-issuer.d.ts +66 -0
- package/dist/testing/fake-issuer.js +303 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/scenarios.d.ts +6 -0
- package/dist/testing/scenarios.js +44 -0
- package/dist/ui/console.d.ts +60 -0
- package/dist/ui/console.js +73 -0
- package/dist/ui/errors.d.ts +6 -0
- package/dist/ui/errors.js +30 -0
- package/dist/ui/refused.d.ts +6 -0
- package/dist/ui/refused.js +12 -0
- package/dist/ui/status.d.ts +5 -0
- package/dist/ui/status.js +23 -0
- package/dist/ui/use-stream.d.ts +59 -0
- package/dist/ui/use-stream.js +67 -0
- package/package.json +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ankka-console
|
|
2
|
+
|
|
3
|
+
The ankka console as a package: a typed client for the control plane's API, sign-in through the
|
|
4
|
+
installation's realm with sessions a browser script cannot read, and the pages — organizations,
|
|
5
|
+
projects, services, members, deploy tokens — as routes a [React Router](https://reactrouter.com)
|
|
6
|
+
host mounts under a prefix of its choosing, inside its own layout.
|
|
7
|
+
|
|
8
|
+
The installation's own console is one host of this package. A product built on ankka can be another,
|
|
9
|
+
adding panels and actions to the pages and hiding operations it handles itself.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// app/routes.ts
|
|
13
|
+
import { layout, prefix, type RouteConfig } from "@react-router/dev/routes";
|
|
14
|
+
import { consoleRoutes } from "ankka-console/server";
|
|
15
|
+
|
|
16
|
+
export default [layout("./layout.tsx", [...prefix("console", consoleRoutes())])] satisfies RouteConfig;
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// app/root.tsx
|
|
21
|
+
import { consoleMiddleware, consoleOptionsFromEnv } from "ankka-console/server";
|
|
22
|
+
export const middleware = [consoleMiddleware(() => consoleOptionsFromEnv(process.env))];
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`ankka-console` is the browser-safe entry point (the provider, hooks and types a layout uses);
|
|
26
|
+
`ankka-console/server` is everything that runs only on the server; `ankka-console/client` is the
|
|
27
|
+
control plane client; `ankka-console/testing` holds a fake control plane and a fake identity provider
|
|
28
|
+
for a host's own tests.
|
|
29
|
+
|
|
30
|
+
The package's version is the platform's: pin the one your installation runs. See
|
|
31
|
+
[the package reference](https://docs.ankka.cloud/reference/console-package/) for what a host supplies
|
|
32
|
+
and every extension point.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in through the installation's realm: the authorization code flow with PKCE, as a
|
|
3
|
+
* confidential client, over two addresses.
|
|
4
|
+
*
|
|
5
|
+
* The browser is sent to the realm's external address; the console's own calls — discovery, keys,
|
|
6
|
+
* token, revocation, logout — go to an address the installation names (in a cluster, Keycloak's own
|
|
7
|
+
* service, verified against the service authority), because the external name may not resolve
|
|
8
|
+
* inside the cluster at all. Keycloak answers discovery with its external URLs whichever address
|
|
9
|
+
* asked, so the document is fetched here and its backchannel endpoints re-pointed, rather than handed
|
|
10
|
+
* to `discovery()`, which rightly refuses a document whose issuer is not the address it asked.
|
|
11
|
+
*/
|
|
12
|
+
import * as client from "openid-client";
|
|
13
|
+
import type { FetchLike } from "../server/transport.ts";
|
|
14
|
+
export interface IssuerOptions {
|
|
15
|
+
/** The realm's issuer exactly as tokens name it: the control plane's `GET /auth` says what it is. */
|
|
16
|
+
issuer: string;
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecret: string;
|
|
19
|
+
/** Where server-side calls go instead of the issuer's origin, e.g. `https://ankka-keycloak-service.ankka-auth.svc:8443`. */
|
|
20
|
+
backchannelUrl?: string;
|
|
21
|
+
/** How server-side calls are made: trusts the installation's authority in a cluster. */
|
|
22
|
+
fetch?: FetchLike;
|
|
23
|
+
/** Permits a plain-HTTP issuer: the development Keycloak. */
|
|
24
|
+
allowInsecure?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare class IdentityProviderUnavailable extends Error {
|
|
27
|
+
constructor(cause: unknown);
|
|
28
|
+
}
|
|
29
|
+
/** The identity provider refused a session: sign in again. */
|
|
30
|
+
export declare class SessionEnded extends Error {
|
|
31
|
+
constructor(message?: string);
|
|
32
|
+
}
|
|
33
|
+
export interface Tokens {
|
|
34
|
+
accessToken: string;
|
|
35
|
+
refreshToken: string;
|
|
36
|
+
expiresIn: number;
|
|
37
|
+
}
|
|
38
|
+
export interface BeganSignIn {
|
|
39
|
+
url: URL;
|
|
40
|
+
state: string;
|
|
41
|
+
nonce: string;
|
|
42
|
+
verifier: string;
|
|
43
|
+
}
|
|
44
|
+
export declare class Issuer {
|
|
45
|
+
#private;
|
|
46
|
+
constructor(options: IssuerOptions);
|
|
47
|
+
/** The configuration, discovered on first use and kept; a failed discovery is tried again next time. */
|
|
48
|
+
configuration(): Promise<client.Configuration>;
|
|
49
|
+
/** Where to send the browser, and what the callback must check. */
|
|
50
|
+
beginSignIn(redirectUri: string): Promise<BeganSignIn>;
|
|
51
|
+
/** Exchanges the callback's code, having checked state, nonce, PKCE and the identity token. */
|
|
52
|
+
finishSignIn(callbackUrl: URL, checks: {
|
|
53
|
+
state: string;
|
|
54
|
+
nonce: string;
|
|
55
|
+
verifier: string;
|
|
56
|
+
}): Promise<Tokens>;
|
|
57
|
+
refresh(refreshToken: string): Promise<Tokens>;
|
|
58
|
+
/**
|
|
59
|
+
* Ends the person's session at the identity provider, server to server: Keycloak's logout
|
|
60
|
+
* endpoint accepts a confidential client's refresh token and ends the user session it belongs to,
|
|
61
|
+
* so no identity token has to be kept for sign-out and no confirmation page is shown.
|
|
62
|
+
*/
|
|
63
|
+
endSession(refreshToken: string): Promise<void>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in through the installation's realm: the authorization code flow with PKCE, as a
|
|
3
|
+
* confidential client, over two addresses.
|
|
4
|
+
*
|
|
5
|
+
* The browser is sent to the realm's external address; the console's own calls — discovery, keys,
|
|
6
|
+
* token, revocation, logout — go to an address the installation names (in a cluster, Keycloak's own
|
|
7
|
+
* service, verified against the service authority), because the external name may not resolve
|
|
8
|
+
* inside the cluster at all. Keycloak answers discovery with its external URLs whichever address
|
|
9
|
+
* asked, so the document is fetched here and its backchannel endpoints re-pointed, rather than handed
|
|
10
|
+
* to `discovery()`, which rightly refuses a document whose issuer is not the address it asked.
|
|
11
|
+
*/
|
|
12
|
+
import * as client from "openid-client";
|
|
13
|
+
export class IdentityProviderUnavailable extends Error {
|
|
14
|
+
constructor(cause) {
|
|
15
|
+
super(`the identity provider cannot be reached: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
16
|
+
this.name = "IdentityProviderUnavailable";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The identity provider refused a session: sign in again. */
|
|
20
|
+
export class SessionEnded extends Error {
|
|
21
|
+
constructor(message = "the identity provider no longer honours this session") {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "SessionEnded";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function repoint(endpoint, backchannel) {
|
|
27
|
+
if (!endpoint || !backchannel)
|
|
28
|
+
return endpoint;
|
|
29
|
+
const url = new URL(endpoint);
|
|
30
|
+
url.protocol = backchannel.protocol;
|
|
31
|
+
url.host = backchannel.host;
|
|
32
|
+
return url.toString();
|
|
33
|
+
}
|
|
34
|
+
const isNetworkFailure = (e) => e instanceof TypeError ||
|
|
35
|
+
(e instanceof Error && /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|socket hang up|fetch failed|other side closed/i.test(e.message + String(e.cause ?? "")));
|
|
36
|
+
function isInvalidGrant(e) {
|
|
37
|
+
if (!(e instanceof Error))
|
|
38
|
+
return false;
|
|
39
|
+
const err = e;
|
|
40
|
+
return err.error === "invalid_grant" || /invalid_grant/.test(err.message);
|
|
41
|
+
}
|
|
42
|
+
export class Issuer {
|
|
43
|
+
#options;
|
|
44
|
+
#config;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.#options = options;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Server-side calls, carrying the public address as forwarded headers when they go to the
|
|
50
|
+
* backchannel. Keycloak keeps only a host name and takes the scheme and port of its issuer from
|
|
51
|
+
* each request, trusting the forwarded headers the gateway adds; asked directly on its in-cluster
|
|
52
|
+
* TLS port, it would name itself `https://auth.<base>:8443` — a different issuer from the one every
|
|
53
|
+
* browser-obtained token carries, so discovery would be refused and every token it issued here
|
|
54
|
+
* would be refused by the control plane. The headers make the backchannel look, to Keycloak, like
|
|
55
|
+
* the front door.
|
|
56
|
+
*/
|
|
57
|
+
#fetch() {
|
|
58
|
+
const base = this.#options.fetch ?? ((url, init) => fetch(url, init));
|
|
59
|
+
if (!this.#options.backchannelUrl)
|
|
60
|
+
return base;
|
|
61
|
+
const issuer = new URL(this.#options.issuer);
|
|
62
|
+
const proto = issuer.protocol.replace(":", "");
|
|
63
|
+
const port = issuer.port || (proto === "https" ? "443" : "80");
|
|
64
|
+
return (url, init = {}) => {
|
|
65
|
+
const headers = new Headers(init.headers);
|
|
66
|
+
headers.set("x-forwarded-proto", proto);
|
|
67
|
+
headers.set("x-forwarded-host", issuer.host);
|
|
68
|
+
headers.set("x-forwarded-port", port);
|
|
69
|
+
return base(url, { ...init, headers });
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** The configuration, discovered on first use and kept; a failed discovery is tried again next time. */
|
|
73
|
+
configuration() {
|
|
74
|
+
if (!this.#config) {
|
|
75
|
+
this.#config = this.#discover().catch((e) => {
|
|
76
|
+
this.#config = undefined;
|
|
77
|
+
throw e;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return this.#config;
|
|
81
|
+
}
|
|
82
|
+
async #discover() {
|
|
83
|
+
const { issuer, backchannelUrl, clientId, clientSecret, allowInsecure } = this.#options;
|
|
84
|
+
const backchannel = backchannelUrl ? new URL(backchannelUrl) : undefined;
|
|
85
|
+
const discoveryUrl = repoint(`${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`, backchannel);
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await this.#fetch()(discoveryUrl, { headers: { accept: "application/json" } });
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
throw new IdentityProviderUnavailable(e);
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
throw new IdentityProviderUnavailable(new Error(`discovery answered ${response.status} at ${discoveryUrl}`));
|
|
95
|
+
const document = (await response.json());
|
|
96
|
+
if (document.issuer !== issuer) {
|
|
97
|
+
throw new Error(`the identity provider names its issuer '${document.issuer}' but the control plane expects '${issuer}'; ` +
|
|
98
|
+
"they must be the same string, port included");
|
|
99
|
+
}
|
|
100
|
+
const metadata = {
|
|
101
|
+
...document,
|
|
102
|
+
token_endpoint: repoint(document.token_endpoint, backchannel),
|
|
103
|
+
jwks_uri: repoint(document.jwks_uri, backchannel),
|
|
104
|
+
revocation_endpoint: repoint(document.revocation_endpoint, backchannel),
|
|
105
|
+
end_session_endpoint: repoint(document.end_session_endpoint, backchannel),
|
|
106
|
+
};
|
|
107
|
+
const config = new client.Configuration(metadata, clientId, clientSecret, client.ClientSecretBasic(clientSecret));
|
|
108
|
+
const outbound = this.#fetch();
|
|
109
|
+
config[client.customFetch] = (url, options) => outbound(url, options);
|
|
110
|
+
if (allowInsecure)
|
|
111
|
+
client.allowInsecureRequests(config);
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
/** Where to send the browser, and what the callback must check. */
|
|
115
|
+
async beginSignIn(redirectUri) {
|
|
116
|
+
const config = await this.#guard(() => this.configuration());
|
|
117
|
+
const verifier = client.randomPKCECodeVerifier();
|
|
118
|
+
const state = client.randomState();
|
|
119
|
+
const nonce = client.randomNonce();
|
|
120
|
+
const url = client.buildAuthorizationUrl(config, {
|
|
121
|
+
redirect_uri: redirectUri,
|
|
122
|
+
// The realm declares its own scopes and none of Keycloak's built-ins; `openid` is the one asked
|
|
123
|
+
// for, and the control plane's scope is a default of the client, so the access token carries
|
|
124
|
+
// the audience and claims the control plane reads.
|
|
125
|
+
scope: "openid",
|
|
126
|
+
code_challenge: await client.calculatePKCECodeChallenge(verifier),
|
|
127
|
+
code_challenge_method: "S256",
|
|
128
|
+
state,
|
|
129
|
+
nonce,
|
|
130
|
+
});
|
|
131
|
+
return { url, state, nonce, verifier };
|
|
132
|
+
}
|
|
133
|
+
/** Exchanges the callback's code, having checked state, nonce, PKCE and the identity token. */
|
|
134
|
+
async finishSignIn(callbackUrl, checks) {
|
|
135
|
+
const config = await this.#guard(() => this.configuration());
|
|
136
|
+
const response = await this.#guard(() => client.authorizationCodeGrant(config, callbackUrl, {
|
|
137
|
+
pkceCodeVerifier: checks.verifier,
|
|
138
|
+
expectedState: checks.state,
|
|
139
|
+
expectedNonce: checks.nonce,
|
|
140
|
+
idTokenExpected: true,
|
|
141
|
+
}));
|
|
142
|
+
return this.#tokens(response, undefined);
|
|
143
|
+
}
|
|
144
|
+
async refresh(refreshToken) {
|
|
145
|
+
const config = await this.#guard(() => this.configuration());
|
|
146
|
+
try {
|
|
147
|
+
const response = await client.refreshTokenGrant(config, refreshToken);
|
|
148
|
+
return this.#tokens(response, refreshToken);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (isInvalidGrant(e))
|
|
152
|
+
throw new SessionEnded();
|
|
153
|
+
if (isNetworkFailure(e))
|
|
154
|
+
throw new IdentityProviderUnavailable(e);
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Ends the person's session at the identity provider, server to server: Keycloak's logout
|
|
160
|
+
* endpoint accepts a confidential client's refresh token and ends the user session it belongs to,
|
|
161
|
+
* so no identity token has to be kept for sign-out and no confirmation page is shown.
|
|
162
|
+
*/
|
|
163
|
+
async endSession(refreshToken) {
|
|
164
|
+
const config = await this.#guard(() => this.configuration());
|
|
165
|
+
const endpoint = config.serverMetadata().end_session_endpoint;
|
|
166
|
+
if (endpoint) {
|
|
167
|
+
const body = new URLSearchParams({ refresh_token: refreshToken });
|
|
168
|
+
const credentials = Buffer.from(`${encodeURIComponent(this.#options.clientId)}:${encodeURIComponent(this.#options.clientSecret)}`).toString("base64");
|
|
169
|
+
const response = await this.#guard(() => this.#fetch()(endpoint, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${credentials}` },
|
|
172
|
+
body,
|
|
173
|
+
}));
|
|
174
|
+
// 400 means the session had already ended, which is what was asked for.
|
|
175
|
+
if (!response.ok && response.status !== 400)
|
|
176
|
+
throw new IdentityProviderUnavailable(new Error(`logout answered ${response.status}`));
|
|
177
|
+
await response.body?.cancel();
|
|
178
|
+
}
|
|
179
|
+
await this.#guard(() => client.tokenRevocation(config, refreshToken, { token_type_hint: "refresh_token" })).catch(() => undefined);
|
|
180
|
+
}
|
|
181
|
+
#tokens(response, previousRefresh) {
|
|
182
|
+
const refreshToken = response.refresh_token ?? previousRefresh;
|
|
183
|
+
if (!refreshToken)
|
|
184
|
+
throw new Error("the identity provider issued no refresh token");
|
|
185
|
+
return { accessToken: response.access_token, refreshToken, expiresIn: response.expires_in ?? 60 };
|
|
186
|
+
}
|
|
187
|
+
async #guard(f) {
|
|
188
|
+
try {
|
|
189
|
+
return await f();
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (e instanceof IdentityProviderUnavailable || e instanceof SessionEnded)
|
|
193
|
+
throw e;
|
|
194
|
+
if (isNetworkFailure(e))
|
|
195
|
+
throw new IdentityProviderUnavailable(e);
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type AuthDiscovery, type CreateDeployToken, type CreateOrganization, type DeployTokenCreated, type DeployTokenSummary, type HistoryEntry, type LogsResponse, type MembersResponse, type OrganizationSummary, type ProjectDetail, type ProjectSummary, type Quota, type Role, type ServiceStatus, type SetRegistry, type Whoami } from "./schemas.ts";
|
|
2
|
+
/** `fetch`'s shape; the server supplies one that presents the console's certificate. */
|
|
3
|
+
export type Transport = (url: string, init: RequestInit) => Promise<Response>;
|
|
4
|
+
/**
|
|
5
|
+
* Where the bearer comes from. Called once per request; `refresh: true` means the last one was
|
|
6
|
+
* refused and a new one must be obtained. `null` means nobody is signed in.
|
|
7
|
+
*/
|
|
8
|
+
export type BearerSource = (options: {
|
|
9
|
+
refresh: boolean;
|
|
10
|
+
}) => Promise<string | null>;
|
|
11
|
+
export interface LogsQuery {
|
|
12
|
+
instance?: string;
|
|
13
|
+
previous?: boolean;
|
|
14
|
+
tail?: number;
|
|
15
|
+
since?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ControlPlaneClientOptions {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
bearer: BearerSource;
|
|
20
|
+
transport?: Transport;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The control plane's HTTP API, one method per route the console uses, as the signed-in person.
|
|
24
|
+
*
|
|
25
|
+
* A `401` is retried once with a refreshed bearer and then surfaces as `SignInRequired`; any other
|
|
26
|
+
* refusal is a `ControlPlaneError` carrying the body's reason. Responses are decoded against the
|
|
27
|
+
* wire schemas, with unknown fields dropped.
|
|
28
|
+
*/
|
|
29
|
+
export declare class ControlPlaneClient {
|
|
30
|
+
#private;
|
|
31
|
+
constructor(options: ControlPlaneClientOptions);
|
|
32
|
+
authDiscovery(): Promise<AuthDiscovery>;
|
|
33
|
+
whoami(): Promise<Whoami>;
|
|
34
|
+
listOrganizations(): Promise<OrganizationSummary[]>;
|
|
35
|
+
getOrganization(id: string): Promise<OrganizationSummary>;
|
|
36
|
+
createOrganization(id: string, body: CreateOrganization): Promise<void>;
|
|
37
|
+
renameOrganization(id: string, name: string): Promise<void>;
|
|
38
|
+
deleteOrganization(id: string): Promise<void>;
|
|
39
|
+
disableOrganization(id: string): Promise<void>;
|
|
40
|
+
enableOrganization(id: string): Promise<void>;
|
|
41
|
+
setQuota(id: string, quota: Quota): Promise<void>;
|
|
42
|
+
clearQuota(id: string): Promise<void>;
|
|
43
|
+
members(id: string): Promise<MembersResponse>;
|
|
44
|
+
invite(id: string, email: string, role: Role): Promise<void>;
|
|
45
|
+
removeMember(id: string, subject: string): Promise<void>;
|
|
46
|
+
changeRole(id: string, subject: string, role: Role): Promise<void>;
|
|
47
|
+
withdrawInvitation(id: string, email: string): Promise<void>;
|
|
48
|
+
repair(id: string, subject: string, role: Role): Promise<void>;
|
|
49
|
+
tokens(id: string): Promise<DeployTokenSummary[]>;
|
|
50
|
+
createToken(id: string, body: CreateDeployToken): Promise<DeployTokenCreated>;
|
|
51
|
+
revokeToken(id: string, tokenId: string): Promise<void>;
|
|
52
|
+
listProjects(): Promise<ProjectSummary[]>;
|
|
53
|
+
getProject(id: string): Promise<ProjectDetail>;
|
|
54
|
+
createProject(id: string, name: string, organizationId: string): Promise<void>;
|
|
55
|
+
renameProject(id: string, name: string): Promise<void>;
|
|
56
|
+
deleteProject(id: string): Promise<void>;
|
|
57
|
+
setRegistry(id: string, body: SetRegistry): Promise<void>;
|
|
58
|
+
clearRegistry(id: string): Promise<void>;
|
|
59
|
+
listServices(projectId: string): Promise<ServiceStatus[]>;
|
|
60
|
+
getService(projectId: string, name: string): Promise<ServiceStatus>;
|
|
61
|
+
/** The descriptor is sent as the text the person gave; the control plane is the one that validates it. */
|
|
62
|
+
applyService(projectId: string, name: string, descriptor: string): Promise<ServiceStatus>;
|
|
63
|
+
serviceOperation(projectId: string, name: string, operation: "pause" | "resume" | "restart" | "expose" | "unexpose"): Promise<void>;
|
|
64
|
+
deleteService(projectId: string, name: string): Promise<void>;
|
|
65
|
+
history(projectId: string, name: string): Promise<HistoryEntry[]>;
|
|
66
|
+
logs(projectId: string, name: string, query?: LogsQuery): Promise<LogsResponse>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { ControlPlaneError, ControlPlaneUnreachable, SignInRequired } from "./errors.js";
|
|
2
|
+
import { authDiscoverySchema, deployTokenCreatedSchema, deployTokenSummarySchema, historyEntrySchema, logsResponseSchema, membersResponseSchema, organizationSummarySchema, projectDetailSchema, projectSummarySchema, serviceStatusSchema, whoamiSchema, } from "./schemas.js";
|
|
3
|
+
const arrayOf = (schema) => ({
|
|
4
|
+
parse: (value) => {
|
|
5
|
+
if (!Array.isArray(value))
|
|
6
|
+
throw new TypeError("expected a JSON array");
|
|
7
|
+
return value.map((item) => schema.parse(item));
|
|
8
|
+
},
|
|
9
|
+
});
|
|
10
|
+
const segment = encodeURIComponent;
|
|
11
|
+
/**
|
|
12
|
+
* The control plane's HTTP API, one method per route the console uses, as the signed-in person.
|
|
13
|
+
*
|
|
14
|
+
* A `401` is retried once with a refreshed bearer and then surfaces as `SignInRequired`; any other
|
|
15
|
+
* refusal is a `ControlPlaneError` carrying the body's reason. Responses are decoded against the
|
|
16
|
+
* wire schemas, with unknown fields dropped.
|
|
17
|
+
*/
|
|
18
|
+
export class ControlPlaneClient {
|
|
19
|
+
#baseUrl;
|
|
20
|
+
#bearer;
|
|
21
|
+
#transport;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.#baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
24
|
+
this.#bearer = options.bearer;
|
|
25
|
+
this.#transport = options.transport ?? ((url, init) => fetch(url, init));
|
|
26
|
+
}
|
|
27
|
+
// ── Identity ──────────────────────────────────────────────────────────────
|
|
28
|
+
authDiscovery() {
|
|
29
|
+
return this.#call("GET", "/auth", { schema: authDiscoverySchema, anonymous: true });
|
|
30
|
+
}
|
|
31
|
+
whoami() {
|
|
32
|
+
return this.#call("GET", "/auth/whoami", { schema: whoamiSchema });
|
|
33
|
+
}
|
|
34
|
+
// ── Organizations ─────────────────────────────────────────────────────────
|
|
35
|
+
listOrganizations() {
|
|
36
|
+
return this.#call("GET", "/organizations", { schema: arrayOf(organizationSummarySchema) });
|
|
37
|
+
}
|
|
38
|
+
getOrganization(id) {
|
|
39
|
+
return this.#call("GET", `/organizations/${segment(id)}`, { schema: organizationSummarySchema });
|
|
40
|
+
}
|
|
41
|
+
createOrganization(id, body) {
|
|
42
|
+
return this.#call("POST", `/organizations/${segment(id)}`, { body });
|
|
43
|
+
}
|
|
44
|
+
renameOrganization(id, name) {
|
|
45
|
+
return this.#call("PUT", `/organizations/${segment(id)}/name`, { body: { name } });
|
|
46
|
+
}
|
|
47
|
+
deleteOrganization(id) {
|
|
48
|
+
return this.#call("DELETE", `/organizations/${segment(id)}`);
|
|
49
|
+
}
|
|
50
|
+
disableOrganization(id) {
|
|
51
|
+
return this.#call("POST", `/organizations/${segment(id)}/disable`);
|
|
52
|
+
}
|
|
53
|
+
enableOrganization(id) {
|
|
54
|
+
return this.#call("POST", `/organizations/${segment(id)}/enable`);
|
|
55
|
+
}
|
|
56
|
+
setQuota(id, quota) {
|
|
57
|
+
return this.#call("PUT", `/organizations/${segment(id)}/quota`, { body: quota });
|
|
58
|
+
}
|
|
59
|
+
clearQuota(id) {
|
|
60
|
+
return this.#call("DELETE", `/organizations/${segment(id)}/quota`);
|
|
61
|
+
}
|
|
62
|
+
// ── Members and invitations ───────────────────────────────────────────────
|
|
63
|
+
members(id) {
|
|
64
|
+
return this.#call("GET", `/organizations/${segment(id)}/members`, { schema: membersResponseSchema });
|
|
65
|
+
}
|
|
66
|
+
invite(id, email, role) {
|
|
67
|
+
return this.#call("POST", `/organizations/${segment(id)}/members`, { body: { email, role } });
|
|
68
|
+
}
|
|
69
|
+
removeMember(id, subject) {
|
|
70
|
+
return this.#call("DELETE", `/organizations/${segment(id)}/members/${segment(subject)}`);
|
|
71
|
+
}
|
|
72
|
+
changeRole(id, subject, role) {
|
|
73
|
+
return this.#call("PUT", `/organizations/${segment(id)}/members/${segment(subject)}/role`, {
|
|
74
|
+
body: { role },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
withdrawInvitation(id, email) {
|
|
78
|
+
return this.#call("DELETE", `/organizations/${segment(id)}/invitations/${segment(email)}`);
|
|
79
|
+
}
|
|
80
|
+
repair(id, subject, role) {
|
|
81
|
+
return this.#call("POST", `/organizations/${segment(id)}/members/${segment(subject)}/repair`, {
|
|
82
|
+
body: { role },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// ── Deploy tokens ─────────────────────────────────────────────────────────
|
|
86
|
+
tokens(id) {
|
|
87
|
+
return this.#call("GET", `/organizations/${segment(id)}/tokens`, {
|
|
88
|
+
schema: arrayOf(deployTokenSummarySchema),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
createToken(id, body) {
|
|
92
|
+
return this.#call("POST", `/organizations/${segment(id)}/tokens`, {
|
|
93
|
+
body,
|
|
94
|
+
schema: deployTokenCreatedSchema,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
revokeToken(id, tokenId) {
|
|
98
|
+
return this.#call("DELETE", `/organizations/${segment(id)}/tokens/${segment(tokenId)}`);
|
|
99
|
+
}
|
|
100
|
+
// ── Projects ──────────────────────────────────────────────────────────────
|
|
101
|
+
listProjects() {
|
|
102
|
+
return this.#call("GET", "/projects", { schema: arrayOf(projectSummarySchema) });
|
|
103
|
+
}
|
|
104
|
+
getProject(id) {
|
|
105
|
+
return this.#call("GET", `/projects/${segment(id)}`, { schema: projectDetailSchema });
|
|
106
|
+
}
|
|
107
|
+
createProject(id, name, organizationId) {
|
|
108
|
+
return this.#call("POST", `/projects/${segment(id)}`, { body: { name, organizationId } });
|
|
109
|
+
}
|
|
110
|
+
renameProject(id, name) {
|
|
111
|
+
return this.#call("PUT", `/projects/${segment(id)}/name`, { body: { name } });
|
|
112
|
+
}
|
|
113
|
+
deleteProject(id) {
|
|
114
|
+
return this.#call("DELETE", `/projects/${segment(id)}`);
|
|
115
|
+
}
|
|
116
|
+
setRegistry(id, body) {
|
|
117
|
+
return this.#call("PUT", `/projects/${segment(id)}/registry`, { body });
|
|
118
|
+
}
|
|
119
|
+
clearRegistry(id) {
|
|
120
|
+
return this.#call("DELETE", `/projects/${segment(id)}/registry`);
|
|
121
|
+
}
|
|
122
|
+
// ── Services ──────────────────────────────────────────────────────────────
|
|
123
|
+
listServices(projectId) {
|
|
124
|
+
return this.#call("GET", `/services/${segment(projectId)}`, { schema: arrayOf(serviceStatusSchema) });
|
|
125
|
+
}
|
|
126
|
+
getService(projectId, name) {
|
|
127
|
+
return this.#call("GET", `/services/${segment(projectId)}/${segment(name)}`, { schema: serviceStatusSchema });
|
|
128
|
+
}
|
|
129
|
+
/** The descriptor is sent as the text the person gave; the control plane is the one that validates it. */
|
|
130
|
+
applyService(projectId, name, descriptor) {
|
|
131
|
+
return this.#call("PUT", `/services/${segment(projectId)}/${segment(name)}`, {
|
|
132
|
+
rawBody: descriptor,
|
|
133
|
+
schema: serviceStatusSchema,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
serviceOperation(projectId, name, operation) {
|
|
137
|
+
return this.#call("POST", `/services/${segment(projectId)}/${segment(name)}/${operation}`);
|
|
138
|
+
}
|
|
139
|
+
deleteService(projectId, name) {
|
|
140
|
+
return this.#call("DELETE", `/services/${segment(projectId)}/${segment(name)}`);
|
|
141
|
+
}
|
|
142
|
+
history(projectId, name) {
|
|
143
|
+
return this.#call("GET", `/services/${segment(projectId)}/${segment(name)}/history`, {
|
|
144
|
+
schema: arrayOf(historyEntrySchema),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
logs(projectId, name, query = {}) {
|
|
148
|
+
const params = new URLSearchParams();
|
|
149
|
+
if (query.instance)
|
|
150
|
+
params.set("instance", query.instance);
|
|
151
|
+
if (query.previous)
|
|
152
|
+
params.set("previous", "true");
|
|
153
|
+
if (query.tail !== undefined)
|
|
154
|
+
params.set("tail", String(query.tail));
|
|
155
|
+
if (query.since !== undefined)
|
|
156
|
+
params.set("since", String(query.since));
|
|
157
|
+
const q = params.size > 0 ? `?${params}` : "";
|
|
158
|
+
return this.#call("GET", `/services/${segment(projectId)}/${segment(name)}/logs${q}`, {
|
|
159
|
+
schema: logsResponseSchema,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// ── Transport ─────────────────────────────────────────────────────────────
|
|
163
|
+
async #call(method, path, options = {}) {
|
|
164
|
+
const url = this.#baseUrl + path;
|
|
165
|
+
const body = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body));
|
|
166
|
+
const send = async (bearer) => {
|
|
167
|
+
const headers = { accept: "application/json" };
|
|
168
|
+
if (bearer)
|
|
169
|
+
headers.authorization = `Bearer ${bearer}`;
|
|
170
|
+
if (body !== undefined)
|
|
171
|
+
headers["content-type"] = "application/json";
|
|
172
|
+
try {
|
|
173
|
+
return await this.#transport(url, { method, headers, body });
|
|
174
|
+
}
|
|
175
|
+
catch (cause) {
|
|
176
|
+
throw new ControlPlaneUnreachable(this.#baseUrl, cause);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
let response;
|
|
180
|
+
if (options.anonymous) {
|
|
181
|
+
response = await send(null);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const bearer = await this.#bearer({ refresh: false });
|
|
185
|
+
if (bearer === null)
|
|
186
|
+
throw new SignInRequired();
|
|
187
|
+
response = await send(bearer);
|
|
188
|
+
if (response.status === 401) {
|
|
189
|
+
await response.body?.cancel();
|
|
190
|
+
const fresh = await this.#bearer({ refresh: true });
|
|
191
|
+
if (fresh === null)
|
|
192
|
+
throw new SignInRequired();
|
|
193
|
+
response = await send(fresh);
|
|
194
|
+
if (response.status === 401) {
|
|
195
|
+
await response.body?.cancel();
|
|
196
|
+
throw new SignInRequired();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!response.ok)
|
|
201
|
+
throw await ControlPlaneError.from(response);
|
|
202
|
+
if (!options.schema) {
|
|
203
|
+
await response.body?.cancel();
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
return options.schema.parse(await response.json());
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A refusal or failure from the control plane: its HTTP status and the reason its body gave, which
|
|
3
|
+
* the console shows verbatim beside the thing refused. A refusal is a value the person should read,
|
|
4
|
+
* not a crash.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ControlPlaneError extends Error {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly reason: string;
|
|
9
|
+
constructor(status: number, reason: string);
|
|
10
|
+
/** Whether sending the same request again, unchanged, could succeed. */
|
|
11
|
+
get retryable(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The individual problems of a refused descriptor. The control plane joins them into one message,
|
|
14
|
+
* `invalid descriptor: a; b`; anything else is one problem, the whole reason.
|
|
15
|
+
*/
|
|
16
|
+
get problems(): string[];
|
|
17
|
+
static from(response: Response): Promise<ControlPlaneError>;
|
|
18
|
+
}
|
|
19
|
+
/** The control plane could not be reached at all: no status, only the transport's failure. */
|
|
20
|
+
export declare class ControlPlaneUnreachable extends Error {
|
|
21
|
+
constructor(url: string, cause: unknown);
|
|
22
|
+
}
|
|
23
|
+
/** The request's access token was refused, and a fresh one was refused too: sign in again. */
|
|
24
|
+
export declare class SignInRequired extends Error {
|
|
25
|
+
constructor();
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { errorBodySchema } from "./schemas.js";
|
|
2
|
+
/**
|
|
3
|
+
* A refusal or failure from the control plane: its HTTP status and the reason its body gave, which
|
|
4
|
+
* the console shows verbatim beside the thing refused. A refusal is a value the person should read,
|
|
5
|
+
* not a crash.
|
|
6
|
+
*/
|
|
7
|
+
export class ControlPlaneError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
reason;
|
|
10
|
+
constructor(status, reason) {
|
|
11
|
+
super(`control plane answered ${status}: ${reason}`);
|
|
12
|
+
this.name = "ControlPlaneError";
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
}
|
|
16
|
+
/** Whether sending the same request again, unchanged, could succeed. */
|
|
17
|
+
get retryable() {
|
|
18
|
+
return this.status === 503 || this.status === 504;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The individual problems of a refused descriptor. The control plane joins them into one message,
|
|
22
|
+
* `invalid descriptor: a; b`; anything else is one problem, the whole reason.
|
|
23
|
+
*/
|
|
24
|
+
get problems() {
|
|
25
|
+
const prefix = "invalid descriptor: ";
|
|
26
|
+
return this.reason.startsWith(prefix)
|
|
27
|
+
? this.reason.slice(prefix.length).split("; ").filter((p) => p.length > 0)
|
|
28
|
+
: [this.reason];
|
|
29
|
+
}
|
|
30
|
+
static async from(response) {
|
|
31
|
+
const text = await response.text().catch(() => "");
|
|
32
|
+
let reason = text.trim() || response.statusText || `HTTP ${response.status}`;
|
|
33
|
+
try {
|
|
34
|
+
const body = errorBodySchema.safeParse(JSON.parse(text));
|
|
35
|
+
if (body.success)
|
|
36
|
+
reason = body.data.error;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Not JSON: a proxy's page, say. The status is still the truth.
|
|
40
|
+
}
|
|
41
|
+
return new ControlPlaneError(response.status, reason);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The control plane could not be reached at all: no status, only the transport's failure. */
|
|
45
|
+
export class ControlPlaneUnreachable extends Error {
|
|
46
|
+
constructor(url, cause) {
|
|
47
|
+
super(`could not reach the control plane at ${url}: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
48
|
+
cause,
|
|
49
|
+
});
|
|
50
|
+
this.name = "ControlPlaneUnreachable";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The request's access token was refused, and a fresh one was refused too: sign in again. */
|
|
54
|
+
export class SignInRequired extends Error {
|
|
55
|
+
constructor() {
|
|
56
|
+
super("sign-in required");
|
|
57
|
+
this.name = "SignInRequired";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ControlPlaneClient } from "./control-plane.ts";
|
|
2
|
+
export type { BearerSource, ControlPlaneClientOptions, LogsQuery, Transport } from "./control-plane.ts";
|
|
3
|
+
export { ControlPlaneError, ControlPlaneUnreachable, SignInRequired } from "./errors.ts";
|
|
4
|
+
export * from "./schemas.ts";
|