@samebase/alchemy-convex 0.20.0 → 0.22.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 +15 -6
- package/lib/{HubClient-Dzw8uZNy.js → HubClient-B6hjkbjW.js} +9 -9
- package/lib/{HubClient-Dzw8uZNy.js.map → HubClient-B6hjkbjW.js.map} +1 -1
- package/lib/cli.js +7 -5
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +7 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +28 -11
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/HubClient.ts +10 -5
- package/src/SharedHubClient.ts +55 -21
- package/src/cli.ts +5 -4
package/src/SharedHubClient.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// Convex.SharedHubClient: one deployment of an app as a client of the central identity hub, for an
|
|
2
|
-
// app that deploys in its own GitHub Actions run.
|
|
2
|
+
// app that deploys in its own GitHub Actions run, on any host.
|
|
3
3
|
//
|
|
4
4
|
// The run asks samebase.com for a new client with a GitHub OIDC token, so the stack needs no
|
|
5
|
-
// Samebase key.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Samebase key. The token names the repository. Samebase reads `/samebase-verification.json` at the
|
|
6
|
+
// origin of the return address, which must name the same repository, and registers the client on
|
|
7
|
+
// that address. The answer holds the client's own registration access token (RFC 7591), and each
|
|
8
|
+
// later deploy reads the client on the hub with that token (RFC 7592). The hub keeps only hashes, so
|
|
9
|
+
// the state is the only record of the secret and the token.
|
|
9
10
|
//
|
|
10
11
|
// A push removes a variable that a deploy stops passing, so a failure here must not drop working
|
|
11
12
|
// variables or stop the deploy. A renewal that fails keeps the saved values. Only a client that
|
|
12
|
-
// cannot work asks for a new one: it is gone, or it has
|
|
13
|
-
//
|
|
13
|
+
// cannot work asks for a new one: it is gone, or it has another return address. If that request
|
|
14
|
+
// fails, the run gives the off values, so the app has guest sign-in and not a broken button.
|
|
14
15
|
// The off values are the `no-hub` variables of a checkout without a hub: Convex needs the variables,
|
|
15
16
|
// and the app hides the button for this client ID.
|
|
16
17
|
//
|
|
@@ -19,6 +20,7 @@ import { Resource } from "alchemy";
|
|
|
19
20
|
import { isResolved } from "alchemy/Diff";
|
|
20
21
|
import * as Provider from "alchemy/Provider";
|
|
21
22
|
import * as Effect from "effect/Effect";
|
|
23
|
+
import * as Option from "effect/Option";
|
|
22
24
|
import * as Redacted from "effect/Redacted";
|
|
23
25
|
import * as Schema from "effect/Schema";
|
|
24
26
|
import type { Providers } from "./Providers.ts";
|
|
@@ -29,8 +31,12 @@ const REQUEST_TIMEOUT_MS = 30_000;
|
|
|
29
31
|
export interface SharedHubClientProps {
|
|
30
32
|
/** The stable name of the app, set once in the stack. */
|
|
31
33
|
readonly app: string;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The return address of the hub's sign-ins, such as `${site.siteUrl}/auth/hub/callback`: an https
|
|
36
|
+
* URL whose origin serves `/samebase-verification.json` with the numeric GitHub id of this
|
|
37
|
+
* repository, `{ "repository": "123" }`. It can come from a resource that this run creates.
|
|
38
|
+
*/
|
|
39
|
+
readonly redirectUri: string;
|
|
34
40
|
readonly deploymentType: "production" | "preview";
|
|
35
41
|
/** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
|
|
36
42
|
readonly softwareVersion?: string;
|
|
@@ -47,9 +53,8 @@ export interface SharedHubClientAttributes {
|
|
|
47
53
|
/** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
|
|
48
54
|
readonly registrationAccessToken: Redacted.Redacted<string>;
|
|
49
55
|
readonly registrationClientUri: string;
|
|
50
|
-
/** The deployment that the client serves. */
|
|
51
|
-
readonly deployment: string;
|
|
52
56
|
readonly deploymentType: "production" | "preview";
|
|
57
|
+
/** The return address that the client serves. Empty for the off values. */
|
|
53
58
|
readonly redirectUri: string;
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -82,13 +87,44 @@ const NewClient = Schema.Struct({
|
|
|
82
87
|
|
|
83
88
|
/** The other answers of `POST /api/hub-clients`, by status. */
|
|
84
89
|
const REFUSALS: Readonly<Record<number, string>> = {
|
|
90
|
+
400: "Samebase refused the request: the return address must be https on a DNS name",
|
|
85
91
|
401: "Samebase refused the GitHub OIDC token",
|
|
86
|
-
403: "Samebase does not allow this repository or
|
|
92
|
+
403: "Samebase does not allow this repository or return address",
|
|
87
93
|
404: "hub clients are off on Samebase",
|
|
88
94
|
429: "Samebase limits the requests of this repository",
|
|
89
95
|
502: "the identity hub is not available",
|
|
90
96
|
};
|
|
91
97
|
|
|
98
|
+
/** The reasons of a 403 that a log line shows. Any other text of the answer is never logged. */
|
|
99
|
+
const NOT_ALLOWED = new Map([
|
|
100
|
+
["run_not_allowed", "Samebase gives clients only to runs of main and of pull requests"],
|
|
101
|
+
["repository_not_connected", "the repository is not connected to Samebase"],
|
|
102
|
+
["owner_mismatch", "the repository's owner is not the owner connected to Samebase"],
|
|
103
|
+
["host_unreachable", "Samebase could not reach the host of the return address"],
|
|
104
|
+
[
|
|
105
|
+
"verification_file_missing",
|
|
106
|
+
"the host of the return address does not serve /samebase-verification.json",
|
|
107
|
+
],
|
|
108
|
+
["verification_file_invalid", "/samebase-verification.json on the host holds no repository id"],
|
|
109
|
+
["repository_mismatch", "/samebase-verification.json on the host names another repository"],
|
|
110
|
+
["hub_refused", "the identity hub refused this repository"],
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const Refusal = Schema.Struct({ reason: Schema.String });
|
|
114
|
+
|
|
115
|
+
/** The text of a refusal: a known reason of a 403, else the text of its status. */
|
|
116
|
+
const refusalOf = (status: number, body: unknown) => {
|
|
117
|
+
const reason =
|
|
118
|
+
status === 403
|
|
119
|
+
? Option.getOrUndefined(Schema.decodeUnknownOption(Refusal)(body))?.reason
|
|
120
|
+
: undefined;
|
|
121
|
+
return (
|
|
122
|
+
(reason === undefined ? undefined : NOT_ALLOWED.get(reason)) ??
|
|
123
|
+
REFUSALS[status] ??
|
|
124
|
+
"Samebase refused the request"
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
92
128
|
const send = (who: string, url: string, token: string, init: { method: string; body?: string }) =>
|
|
93
129
|
Effect.tryPromise({
|
|
94
130
|
try: async () => {
|
|
@@ -138,14 +174,14 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
|
|
|
138
174
|
method: "POST",
|
|
139
175
|
body: JSON.stringify({
|
|
140
176
|
app: props.app,
|
|
141
|
-
|
|
177
|
+
redirectUri: props.redirectUri,
|
|
142
178
|
softwareVersion: props.softwareVersion ?? "unknown",
|
|
143
179
|
}),
|
|
144
180
|
},
|
|
145
181
|
);
|
|
146
182
|
if (status !== 201) {
|
|
147
183
|
return yield* new SharedHubClientFailed({
|
|
148
|
-
reason: `${
|
|
184
|
+
reason: `${refusalOf(status, body)} (HTTP ${status})`,
|
|
149
185
|
});
|
|
150
186
|
}
|
|
151
187
|
const client = yield* decode(NewClient, "Samebase")(body);
|
|
@@ -156,7 +192,6 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
|
|
|
156
192
|
clientSecret: Redacted.make(client.client_secret),
|
|
157
193
|
registrationAccessToken: Redacted.make(client.registration_access_token),
|
|
158
194
|
registrationClientUri: client.registration_client_uri,
|
|
159
|
-
deployment: props.deployment,
|
|
160
195
|
deploymentType: props.deploymentType,
|
|
161
196
|
redirectUri: client.redirect_uri,
|
|
162
197
|
};
|
|
@@ -164,7 +199,7 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
|
|
|
164
199
|
|
|
165
200
|
/** A new client, or `saved`, or the off values. `saved` holds no off values. */
|
|
166
201
|
const newClient = (props: SharedHubClientProps) => {
|
|
167
|
-
const label = `Convex.SharedHubClient: ${props.app} on ${props.
|
|
202
|
+
const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
|
|
168
203
|
const off: SharedHubClientAttributes = {
|
|
169
204
|
status: "off",
|
|
170
205
|
issuer: "http://127.0.0.1:1",
|
|
@@ -172,7 +207,6 @@ const newClient = (props: SharedHubClientProps) => {
|
|
|
172
207
|
clientSecret: Redacted.make("no-hub"),
|
|
173
208
|
registrationAccessToken: Redacted.make(""),
|
|
174
209
|
registrationClientUri: "",
|
|
175
|
-
deployment: props.deployment,
|
|
176
210
|
deploymentType: props.deploymentType,
|
|
177
211
|
redirectUri: "",
|
|
178
212
|
};
|
|
@@ -212,7 +246,7 @@ const renew = (saved: SharedHubClientAttributes) =>
|
|
|
212
246
|
return undefined;
|
|
213
247
|
case 403:
|
|
214
248
|
yield* Effect.logWarning(
|
|
215
|
-
`Convex.SharedHubClient: the hub suspended the client of ${saved.
|
|
249
|
+
`Convex.SharedHubClient: the hub suspended the client of ${saved.redirectUri}. It keeps its values, and nobody can sign in with it.`,
|
|
216
250
|
);
|
|
217
251
|
return { ...saved, status: "suspended" as const };
|
|
218
252
|
default:
|
|
@@ -223,7 +257,7 @@ const renew = (saved: SharedHubClientAttributes) =>
|
|
|
223
257
|
}).pipe(
|
|
224
258
|
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
225
259
|
Effect.logWarning(
|
|
226
|
-
`Convex.SharedHubClient: ${saved.
|
|
260
|
+
`Convex.SharedHubClient: ${saved.redirectUri} keeps the saved client: ${error.reason}.`,
|
|
227
261
|
).pipe(Effect.as(saved)),
|
|
228
262
|
),
|
|
229
263
|
);
|
|
@@ -239,7 +273,7 @@ export const SharedHubClientProvider = () =>
|
|
|
239
273
|
|
|
240
274
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
241
275
|
const saved = output?.status === "off" ? undefined : output;
|
|
242
|
-
const renewed = saved?.
|
|
276
|
+
const renewed = saved?.redirectUri === news.redirectUri ? yield* renew(saved) : undefined;
|
|
243
277
|
return renewed ?? (yield* newClient(news));
|
|
244
278
|
}),
|
|
245
279
|
|
|
@@ -264,7 +298,7 @@ export const SharedHubClientProvider = () =>
|
|
|
264
298
|
}).pipe(
|
|
265
299
|
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
266
300
|
Effect.logWarning(
|
|
267
|
-
`Convex.SharedHubClient: the client of ${output.
|
|
301
|
+
`Convex.SharedHubClient: the client of ${output.redirectUri} stays on the hub until its removal date: ${error.reason}.`,
|
|
268
302
|
),
|
|
269
303
|
),
|
|
270
304
|
),
|
package/src/cli.ts
CHANGED
|
@@ -27,14 +27,15 @@ const { positionals, values } = parseArgs({
|
|
|
27
27
|
team: { type: "string" },
|
|
28
28
|
app: { type: "string" },
|
|
29
29
|
"callback-path": { type: "string" },
|
|
30
|
+
"env-prefix": { type: "string" },
|
|
30
31
|
},
|
|
31
32
|
});
|
|
32
|
-
const { team, app, "callback-path": callbackPath } = values;
|
|
33
|
+
const { team, app, "callback-path": callbackPath, "env-prefix": envPrefix } = values;
|
|
33
34
|
const USAGE = `Usage:
|
|
34
|
-
alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
|
|
35
|
+
alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]
|
|
35
36
|
alchemy-convex stack-comment [--team <team>]`;
|
|
36
37
|
|
|
37
|
-
const hubClient = (hub: { team: string; app: string; callbackPath: string }) =>
|
|
38
|
+
const hubClient = (hub: { team: string; app: string; callbackPath: string; envPrefix?: string }) =>
|
|
38
39
|
Effect.gen(function* () {
|
|
39
40
|
const [command, ...prefix] = yield* installedCli(process.cwd());
|
|
40
41
|
const callbackUrl = yield* registerDevelopmentClient({
|
|
@@ -67,7 +68,7 @@ const program: Effect.Effect<unknown, unknown> | undefined =
|
|
|
67
68
|
team !== undefined &&
|
|
68
69
|
app !== undefined &&
|
|
69
70
|
callbackPath !== undefined
|
|
70
|
-
? hubClient({ team, app, callbackPath })
|
|
71
|
+
? hubClient({ team, app, callbackPath, ...(envPrefix === undefined ? {} : { envPrefix }) })
|
|
71
72
|
: undefined;
|
|
72
73
|
if (program === undefined) {
|
|
73
74
|
console.error(USAGE);
|